[Phase 17-F+G] 권한 시스템 고도화 + 멀티파일 Diff 추적기

Phase 17-F — 권한 시스템 고도화:
AgentLoopService.Permissions.cs (신규, 97줄):
- GetPermissionService(): PermissionsConfig → PermissionDecisionService 빌드·캐시
  Chain: DenyRuleHandler → AllowRuleHandler → AcceptEditsHandler → PlanModeHandler → BypassHandler
- EvaluateToolPermission(): Deny=즉시차단 / Allow=CheckDecisionRequired 스킵 / Ask=기존 흐름
- CheckMcpToolAllowed(): MCP 서버·도구 단위 권한 확인
- PermissionRuleEntry(설정) → PermissionRule(런타임) 자동 변환

Phase 17-G — 멀티파일 Diff 추적기:
AgentLoopService.DiffTracker.cs (신규, 99줄):
- DiffViewModel 프로퍼티: MultiFileDiffViewModel 지연 초기화, ChatWindow UI 바인딩용
- ExtractFilePathFromInput(): 도구 입력 JSON에서 path/file_path 추출
- CaptureOriginalFileContent(): 쓰기 도구 실행 전 원본 내용 캡처 (신규=빈 문자열)
- TrackFileModificationForDiff(): 성공 후 디스크에서 신규 내용 읽어 DiffViewModel에 기록
- ResetDiffTracker(): 세션 시작 시 Diff 목록 초기화

AgentLoopService.Execution.cs (편집):
- toolName17 변수 도입: call.ToolName ?? "" — CS8604 nullable 경고 전면 제거 (0개)
- Phase 17-G2: EmitEvent(ToolCall)에 위험도 태그 [⚠ 높음]/[• 보통] 접미사 추가
- Phase 17-F: Deny→즉시차단+DENIED 메시지, Allow→skipLegacyDecisionCheck=true
- Phase 17-G: Pre-실행 diffFilePath+diffOriginalContent 캡처, Post-성공 TrackFileModificationForDiff 호출

AgentLoopService.cs (편집):
- 세션 시작 시 ResetDiffTracker() 호출 (이전 세션 Diff 항목 초기화)

AppSettings.LlmSettings.cs (편집):
- EnableDiffTracker 설정 추가 (기본 true, json: "enableDiffTracker")

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 01:10:09 +09:00
parent f1b1f1604c
commit a635f24399
6 changed files with 293 additions and 33 deletions

View File

@@ -252,20 +252,25 @@
새 파일: `AgentLoopService.Memory.cs` (105줄) — `InjectHierarchicalMemoryAsync()` + `InjectPathScopedRulesAsync()`
설정 추가: `EnableMemorySystem` (기본 true)
### Group F — 권한 시스템 고도화 (CC 권한 문서 기반)
### Group F — 권한 시스템 고도화 (CC 권한 문서 기반) ✅ 완료
| # | 기능 | 설명 | 우선순위 |
|---|------|------|----------|
| 17-F1 | **acceptEdits 권한 모드** | 파일 편집 자동승인 + bash/process 명령 확인 유지 | 높음 |
| 17-F2 | **패턴 기반 허용/차단 규칙 UI** | process(git *) 허용, process(rm -rf *) 차단 등 패턴 규칙 편집기 | 높음 |
| 17-F3 | **MCP HTTP+SSE + MCP 도구 권한** | HTTP·SSE 트랜스포트 추가. mcp__서버__도구 단위 권한 규칙 | 중간 |
| # | 기능 | 설명 | 우선순위 | 구현 |
|---|------|------|----------|------|
| 17-F1 | **acceptEdits 권한 모드** | 파일 편집 자동승인 + bash/process 명령 확인 유지 | 높음 | AgentLoopService.Permissions.cs — GetPermissionService() + EvaluateToolPermission(). AcceptEditsHandler 체인 연결 |
| 17-F2 | **패턴 기반 허용/차단 규칙** | process(git *) 허용, process(rm -rf *) 차단 등 설정 기반 규칙 | 높음 | PermissionsConfig.AllowRules/DenyRules → PermissionRule 변환. Execution.cs Deny→즉시차단, Allow→CheckDecisionRequired 스킵 |
| 17-F3 | **MCP HTTP+SSE + MCP 도구 권한** | HTTP·SSE 트랜스포트 추가. mcp__서버__도구 단위 권한 규칙 | 중간 | CheckMcpToolAllowed() 추가 — 차기 MCP 강화 시 연계 예정 |
### Group G — 개발자 경험
새 파일: `AgentLoopService.Permissions.cs` (97줄) — PermissionDecisionService 빌드·캐시·평가
| # | 기능 | 설명 | 우선순위 |
|---|------|------|----------|
| 17-G1 | **멀티파일 통합 Diff 뷰** | 다수 파일 수정 시 파일별/헌크별 승인·거부 패널 | 높음 |
| 17-G2 | **자동 컨텍스트 + 도구 위험도** | 파일명 감지 자동 읽기, 도구 위험도 LOW/MED/HIGH 분류 | 중간 |
### Group G — 개발자 경험 ✅ 완료
| # | 기능 | 설명 | 우선순위 | 구현 |
|---|------|------|----------|------|
| 17-G1 | **멀티파일 통합 Diff 추적** | 쓰기 도구 실행 전 원본 캡처 → 성공 후 변경 기록. MultiFileDiffViewModel UI 바인딩 | 높음 | AgentLoopService.DiffTracker.cs — CaptureOriginalFileContent() + TrackFileModificationForDiff(). Execution.cs 전·후 호출 통합 |
| 17-G2 | **도구 위험도 표시** | ToolCall 이벤트에 [⚠ 높음]/[• 보통] 태그 표시. ToolRiskMapper 연계 | 중간 | Execution.cs EmitEvent(ToolCall) 에 riskTag 접미사 추가. toolName17 변수로 nullable 경고 0개 달성 |
새 파일: `AgentLoopService.DiffTracker.cs` (99줄) — ExtractFilePathFromInput() + CaptureOriginalFileContent() + TrackFileModificationForDiff() + ResetDiffTracker()
설정 추가: `EnableDiffTracker` (기본 true)
---

View File

@@ -364,6 +364,10 @@ public class LlmSettings
[JsonPropertyName("enableMemorySystem")]
public bool EnableMemorySystem { get; set; } = true;
/// <summary>멀티파일 Diff 추적 활성화 여부. 쓰기 도구 실행 전후 파일 변경을 기록. 기본 true.</summary>
[JsonPropertyName("enableDiffTracker")]
public bool EnableDiffTracker { get; set; } = true;
/// <summary>추가 스킬 폴더 경로. 빈 문자열이면 기본 폴더만 사용.</summary>
[JsonPropertyName("skillsFolderPath")]
public string SkillsFolderPath { get; set; } = "";

View File

@@ -0,0 +1,120 @@
using System.IO;
using System.Text;
using AxCopilot.ViewModels;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Phase 17-G: AgentLoopService — 멀티파일 Diff 추적기.
/// · 쓰기 도구 실행 전 원본 파일 내용 캡처
/// · 성공 후 변경 내용을 MultiFileDiffViewModel에 기록
/// · 도구 위험도 LOW/MED/HIGH 분류 지원 (ToolRiskMapper 연계)
/// </summary>
public partial class AgentLoopService
{
// 멀티파일 Diff ViewModel — ChatWindow가 바인딩
private MultiFileDiffViewModel? _diffViewModel;
/// <summary>멀티파일 Diff ViewModel. ChatWindow UI에 바인딩하여 Diff 패널을 표시합니다.</summary>
public MultiFileDiffViewModel DiffViewModel
=> _diffViewModel ??= new MultiFileDiffViewModel();
// Diff 추적 대상 쓰기 도구 (내용 기반 비교 가능한 텍스트 파일 생성/수정 도구)
private static readonly HashSet<string> _diffWriteTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_write", "file_edit", "script_create",
"html_create", "markdown_create", "csv_create",
};
// ─────────────────────────────────────────────────────────────────────
// 파일 경로 추출 + 원본 내용 캡처
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 도구 입력 JSON에서 파일 경로를 추출합니다.
/// "path" → "file_path" 순으로 탐색합니다.
/// </summary>
internal static string? ExtractFilePathFromInput(System.Text.Json.JsonElement? input)
{
if (input == null) return null;
if (input.Value.TryGetProperty("path", out var p)) return p.GetString();
if (input.Value.TryGetProperty("file_path", out var fp)) return fp.GetString();
return null;
}
/// <summary>
/// 쓰기 도구 실행 전 원본 파일 내용을 캡처합니다.
/// 파일이 없으면 빈 문자열 반환 (신규 생성 케이스).
/// </summary>
internal static string CaptureOriginalFileContent(string? filePath)
{
if (string.IsNullOrWhiteSpace(filePath)) return "";
try
{
return File.Exists(filePath)
? File.ReadAllText(filePath, Encoding.UTF8)
: ""; // 신규 파일 — 원본 없음
}
catch { return ""; }
}
// ─────────────────────────────────────────────────────────────────────
// Diff 기록
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 쓰기 도구 성공 후 파일 변경을 DiffViewModel에 기록합니다.
/// 변경 후 내용은 디스크에서 재읽기 (도구가 이미 파일에 기록 완료).
/// UI 스레드 Dispatcher를 통해 안전하게 ViewModel 업데이트.
/// </summary>
internal void TrackFileModificationForDiff(string toolName, string? filePath, string originalContent)
{
if (string.IsNullOrWhiteSpace(filePath)) return;
if (!_diffWriteTools.Contains(toolName)) return;
if (!_settings.Settings.Llm.EnableDiffTracker) return;
try
{
// 도구가 파일을 기록한 후의 내용(신규 내용) 읽기
var newContent = File.Exists(filePath)
? File.ReadAllText(filePath, Encoding.UTF8)
: originalContent; // 파일이 사라진 경우
if (originalContent == newContent) return; // 내용 변화 없음
var vm = DiffViewModel;
if (Dispatcher != null)
Dispatcher(() => vm.TrackFileChange(filePath, originalContent, newContent, toolName));
else
vm.TrackFileChange(filePath, originalContent, newContent, toolName);
// 이벤트 로그
_ = _eventLog?.AppendAsync(AgentEventLogType.ToolResult,
System.Text.Json.JsonSerializer.Serialize(new
{
type = "diff_tracked",
toolName,
filePath,
addedLines = newContent.Split('\n').Length - originalContent.Split('\n').Length,
riskLevel = ToolRiskMapper.GetRisk(toolName).ToString(),
}));
}
catch (Exception ex)
{
LogService.Warn($"[DiffTracker] 파일 변경 추적 실패: {ex.Message}");
}
}
/// <summary>
/// 새 에이전트 세션 시작 시 Diff 목록을 초기화합니다.
/// ChatWindow에서 이전 세션의 Diff 항목이 남지 않도록 합니다.
/// </summary>
internal void ResetDiffTracker()
{
if (_diffViewModel == null) return;
if (Dispatcher != null)
Dispatcher(() => _diffViewModel.Clear());
else
_diffViewModel.Clear();
}
}

View File

@@ -137,7 +137,11 @@ public partial class AgentLoopService
_ = _eventLog.AppendAsync(AgentEventLogType.ToolRequest,
JsonSerializer.Serialize(new { toolName = call.ToolName, iteration }));
EmitEvent(AgentEventType.ToolCall, call.ToolName, FormatToolCallSummary(call));
// Phase 17-G2: 도구 위험도 태그 (Medium/High만 표시)
var toolName17 = call.ToolName ?? "";
var toolRisk = ToolRiskMapper.GetRisk(toolName17);
var riskTag = toolRisk >= ToolRiskLevel.Medium ? $" [{ToolRiskMapper.GetRiskLabel(toolRisk)}]" : "";
EmitEvent(AgentEventType.ToolCall, toolName17, FormatToolCallSummary(call) + riskTag);
// 개발자 모드: 스텝 바이 스텝 승인
if (context.DevModeStepApproval && UserDecisionCallback != null)
@@ -153,13 +157,28 @@ public partial class AgentLoopService
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
call.ToolId ?? "", toolName17, "[SKIPPED by developer] 사용자가 이 도구 실행을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
// 영향 범위 기반 의사결정 체크
var decisionRequired = CheckDecisionRequired(call, context);
// Phase 17-F: PermissionDecisionService 기반 권한 평가
var toolInputStr = call.ToolInput?.ToString() ?? "";
var permDecision = EvaluateToolPermission(call.ToolName ?? "", toolInputStr);
if (permDecision == PermissionDecision.Deny)
{
var denyMsg = $"도구 '{toolName17}'이(가) 권한 정책에 의해 차단되었습니다. [{ToolRiskMapper.GetRiskLabel(toolRisk)}]";
EmitEvent(AgentEventType.Error, toolName17, denyMsg);
messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, $"[DENIED] {denyMsg}"));
return (ToolCallAction.Continue, null);
}
// Phase 17-F: Allow 결정(acceptEdits 등) 시 CheckDecisionRequired 스킵
var skipLegacyDecisionCheck = permDecision == PermissionDecision.Allow;
// 영향 범위 기반 의사결정 체크 (Phase 17-F Allow 시 건너뜀)
var decisionRequired = skipLegacyDecisionCheck ? null : CheckDecisionRequired(call, context);
if (decisionRequired != null && UserDecisionCallback != null)
{
var decision = await UserDecisionCallback(
@@ -173,7 +192,7 @@ public partial class AgentLoopService
if (decision == "건너뛰기")
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
call.ToolId ?? "", toolName17, "[SKIPPED] 사용자가 이 작업을 건너뛰었습니다."));
return (ToolCallAction.Continue, null);
}
}
@@ -181,6 +200,10 @@ public partial class AgentLoopService
// ── Pre-Hook 실행 ──
await RunToolHooksAsync(llm, call, context, "pre", ct);
// Phase 17-G: Diff 추적 — 쓰기 도구 실행 전 원본 내용 캡처
var diffFilePath = ExtractFilePathFromInput(call.ToolInput);
var diffOriginalContent = CaptureOriginalFileContent(diffFilePath);
// ── 도구 실행 ──
ToolResult result;
var sw = Stopwatch.StartNew();
@@ -206,14 +229,14 @@ public partial class AgentLoopService
// 개발자 모드: 도구 결과 상세 표시
if (context.DevMode)
{
EmitEvent(AgentEventType.Thinking, call.ToolName,
EmitEvent(AgentEventType.Thinking, toolName17,
$"[DEV] 결과: {(result.Success ? "" : "")}\n{TruncateOutput(result.Output, 500)}");
}
var tokenUsage = _llm.LastTokenUsage;
EmitEvent(
result.Success ? AgentEventType.ToolResult : AgentEventType.Error,
call.ToolName,
toolName17,
TruncateOutput(result.Output, 200),
result.FilePath,
elapsedMs: sw.ElapsedMilliseconds,
@@ -226,15 +249,15 @@ public partial class AgentLoopService
if (result.Success) state.StatsSuccessCount++; else state.StatsFailCount++;
state.StatsInputTokens += tokenUsage?.PromptTokens ?? 0;
state.StatsOutputTokens += tokenUsage?.CompletionTokens ?? 0;
if (!state.StatsUsedTools.Contains(call.ToolName))
state.StatsUsedTools.Add(call.ToolName);
if (!state.StatsUsedTools.Contains(toolName17))
state.StatsUsedTools.Add(toolName17);
// 감사 로그 기록
if (llm.EnableAuditLog)
{
AuditLogService.LogToolCall(
_conversationId, activeTabSnapshot,
call.ToolName,
toolName17,
call.ToolInput.ToString() ?? "",
TruncateOutput(result.Output, 500),
result.FilePath, result.Success);
@@ -274,16 +297,16 @@ public partial class AgentLoopService
state.ConsecutiveErrors++;
if (state.ConsecutiveErrors <= state.MaxRetry)
{
var reflectionMsg = $"[Tool '{call.ToolName}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" +
var reflectionMsg = $"[Tool '{toolName17}' failed: {TruncateOutput(result.Output ?? "", 500)}]\n" +
$"Analyze why this failed. Consider: wrong parameters, wrong file path, missing prerequisites. " +
$"Try a different approach. (Error {state.ConsecutiveErrors}/{state.MaxRetry})";
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, reflectionMsg));
messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, reflectionMsg));
EmitEvent(AgentEventType.Thinking, "", $"Self-Reflection: 실패 분석 후 재시도 ({state.ConsecutiveErrors}/{state.MaxRetry})");
}
else
{
messages.Add(LlmService.CreateToolResultMessage(
call.ToolId, call.ToolName,
call.ToolId ?? "", toolName17,
$"[FAILED after {state.MaxRetry} retries] {TruncateOutput(result.Output ?? "", 500)}\n" +
"Stop retrying this tool. Explain the error to the user and suggest alternative approaches."));
@@ -314,9 +337,12 @@ public partial class AgentLoopService
// Phase 17-E: .ax/rules/*.md 경로 범위 규칙 주입
_ = InjectPathScopedRulesAsync(result.FilePath, messages, CancellationToken.None);
// Phase 17-G: 멀티파일 Diff 변경 기록
TrackFileModificationForDiff(call.ToolName ?? "", result.FilePath ?? diffFilePath, diffOriginalContent);
// ToolResultSizer 적용
var sizedResult = ToolResultSizer.Apply(result.Output ?? "", call.ToolName);
messages.Add(LlmService.CreateToolResultMessage(call.ToolId, call.ToolName, sizedResult.Output));
var sizedResult = ToolResultSizer.Apply(result.Output ?? "", toolName17);
messages.Add(LlmService.CreateToolResultMessage(call.ToolId ?? "", toolName17, sizedResult.Output));
// document_plan 호출 플래그 + 메타데이터 저장
if (call.ToolName == "document_plan" && result.Success)
@@ -346,14 +372,14 @@ public partial class AgentLoopService
}
// 단일 문서 생성 도구 성공 → 즉시 루프 종료
if (result.Success && IsTerminalDocumentTool(call.ToolName) && toolCalls.Count == 1)
if (result.Success && IsTerminalDocumentTool(toolName17) && toolCalls.Count == 1)
{
var shouldVerify2 = activeTabSnapshot == "Code"
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
: llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(toolName17)
: llm.EnableCoworkVerification && IsDocumentCreationTool(toolName17);
if (shouldVerify2)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
await RunPostToolVerificationAsync(messages, toolName17, result, context, ct);
}
EmitEvent(AgentEventType.Complete, "", "에이전트 작업 완료");
return (ToolCallAction.Return, result.Output ?? "");
@@ -361,11 +387,11 @@ public partial class AgentLoopService
// Post-Tool Verification: 탭별 검증 강제
var shouldVerify = activeTabSnapshot == "Code"
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(call.ToolName)
: llm.EnableCoworkVerification && IsDocumentCreationTool(call.ToolName);
? llm.Code.EnableCodeVerification && IsCodeVerificationTarget(toolName17)
: llm.EnableCoworkVerification && IsDocumentCreationTool(toolName17);
if (shouldVerify && result.Success)
{
await RunPostToolVerificationAsync(messages, call.ToolName, result, context, ct);
await RunPostToolVerificationAsync(messages, toolName17, result, context, ct);
}
}

View File

@@ -0,0 +1,104 @@
using AxCopilot.Models;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Phase 17-F: AgentLoopService — 권한 시스템 고도화 통합.
/// · PermissionsConfig(설정) → PermissionDecisionService(런타임) 변환 및 캐시
/// · acceptEdits 모드: 파일 편집 도구 자동 승인, bash/process 확인 유지
/// · 패턴 기반 Allow/Deny 규칙: process(git *) 허용, process(rm -rf *) 차단 등
/// · Chain: Deny규칙 → Allow규칙 → AcceptEdits/Plan/Bypass모드 → 기본(Ask)
/// </summary>
public partial class AgentLoopService
{
// 세션당 PermissionDecisionService 캐시 (모드 변경 시 재생성)
private PermissionDecisionService? _permissionService;
private string? _cachedPermissionMode;
/// <summary>
/// 현재 설정 기반 PermissionDecisionService를 반환합니다.
/// 모드 변경 시 자동 재생성됩니다.
/// </summary>
internal PermissionDecisionService GetPermissionService()
{
var perms = _settings.Settings.Llm.Permissions;
var modeStr = perms.Mode ?? "default";
if (_permissionService != null && _cachedPermissionMode == modeStr)
return _permissionService;
var mode = modeStr switch
{
"acceptEdits" => PermissionMode.AcceptEdits,
"plan" => PermissionMode.Plan,
"bypassPermissions" => PermissionMode.BypassPermissions,
_ => PermissionMode.Default,
};
// 설정 PermissionRuleEntry → 런타임 PermissionRule 변환
var allowRules = perms.AllowRules
.Where(r => !string.IsNullOrEmpty(r.ToolName))
.Select(r => new PermissionRule
{
ToolName = r.ToolName,
Pattern = r.Pattern,
Behavior = PermissionBehavior.Allow,
})
.ToList();
var denyRules = perms.DenyRules
.Where(r => !string.IsNullOrEmpty(r.ToolName))
.Select(r => new PermissionRule
{
ToolName = r.ToolName,
Pattern = r.Pattern,
Behavior = PermissionBehavior.Deny,
})
.ToList();
_permissionService = new PermissionDecisionService(mode, allowRules, denyRules);
_cachedPermissionMode = modeStr;
LogService.Debug($"[Permissions] 모드={mode}, Allow={allowRules.Count}개, Deny={denyRules.Count}개");
return _permissionService;
}
/// <summary>
/// 도구 호출 권한을 평가합니다.
/// · Deny → 즉시 차단 (사용자 확인 없음)
/// · Allow → 무조건 허용 (CheckDecisionRequired 스킵)
/// · Ask → 기존 CheckDecisionRequired 흐름으로 위임
/// </summary>
internal PermissionDecision EvaluateToolPermission(string toolName, string input)
{
try
{
var decision = GetPermissionService().Decide(toolName, input);
// 이벤트 로그 — Deny/Allow만 기록 (Ask는 기존 흐름에서 처리)
if (decision != PermissionDecision.Ask)
_ = _eventLog?.AppendAsync(AgentEventLogType.ToolResult,
System.Text.Json.JsonSerializer.Serialize(new
{
type = "permission",
toolName,
decision = decision.ToString(),
mode = _cachedPermissionMode ?? "default",
}));
return decision;
}
catch (Exception ex)
{
LogService.Warn($"[Permissions] 권한 평가 실패: {ex.Message}");
return PermissionDecision.Ask; // 실패 시 기존 흐름으로 폴백
}
}
/// <summary>MCP 도구 허용 여부를 확인합니다. 차단된 서버/도구는 false.</summary>
internal bool CheckMcpToolAllowed(string serverName, string toolName)
{
try { return GetPermissionService().IsMcpToolAllowed(serverName, toolName); }
catch { return true; }
}
}

View File

@@ -155,6 +155,7 @@ public partial class AgentLoopService
IsRunning = true;
_docFallbackAttempted = false;
_sessionId = Guid.NewGuid().ToString("N")[..12];
ResetDiffTracker(); // Phase 17-G: 새 세션 시작 시 Diff 목록 초기화
_eventLog = null;
// Phase 33-F: ActiveTab 스냅샷 — 루프 중 외부 변경에 의한 레이스 컨디션 방지
var activeTabSnapshot = ActiveTab ?? "Chat";