동시 Cowork·Code 실행 시 메인 루프 상태 혼선을 탭별로 분리

Cowork와 Code를 동시에 실행할 때 메인 루프 번호와 라이브 진행 힌트가 서로 섞이던 문제를 수정했다. ChatWindow가 전역 단일 진행 상태를 공유하던 구조를 탭별 현재 run 상태, 진행 스텝, 라이브 힌트, 대기 UI 이벤트로 분리해 현재 탭 기준으로만 렌더링하도록 정리했다.

AppStateService에 탭별 최신 run 상태 추적을 추가하고 ConversationList, TaskSummary, Timeline, V2 라이브 카드가 활성 탭의 run 메타를 읽도록 변경했다. AppStateServiceTests에 탭별 run iteration 분리 회귀 테스트를 추가했고 README와 DEVELOPMENT 문서에도 2026-04-15 22:25 (KST) 기준 이력을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_tab_loop_isolation\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation\\ (경고 0 / 오류 0)
검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AppStateServiceTests" -p:OutputPath=bin\\verify_tab_loop_isolation_tests\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation_tests\\ (통과 45)
This commit is contained in:
2026-04-15 22:26:10 +09:00
parent 2f74780367
commit 82e58bde57
10 changed files with 375 additions and 115 deletions

View File

@@ -194,6 +194,7 @@ public sealed class AppStateService : IAppStateService
public IReadOnlyList<TaskRunStore.TaskRun> ActiveTasks => TaskRuns.ActiveTasks;
public IReadOnlyList<TaskRunStore.TaskRun> RecentTasks => TaskRuns.RecentTasks;
public AgentRunState AgentRun { get; } = new();
private readonly Dictionary<string, AgentRunState> _agentRunsByTab = new(StringComparer.OrdinalIgnoreCase);
public List<AgentRunState> AgentRunHistory { get; } = new();
public List<PermissionEventState> PermissionHistory { get; } = new();
public List<HookEventState> HookHistory { get; } = new();
@@ -278,30 +279,59 @@ public sealed class AppStateService : IAppStateService
TaskRuns.CompleteByPrefix(prefix, summary, status);
}
public void UpsertAgentRun(string runId, string status, string summary, int iteration)
private static string NormalizeTabKey(string? tab)
=> string.IsNullOrWhiteSpace(tab) ? "" : tab.Trim();
private static void ApplyRunSnapshot(AgentRunState target, string runId, string status, string summary, int iteration)
{
if (!string.Equals(AgentRun.RunId, runId, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(target.RunId, runId, StringComparison.OrdinalIgnoreCase))
{
AgentRun.RunId = runId;
AgentRun.StartedAt = DateTime.Now;
target.RunId = runId;
target.StartedAt = DateTime.Now;
}
AgentRun.Status = status;
AgentRun.Summary = summary;
AgentRun.LastIteration = iteration;
AgentRun.UpdatedAt = DateTime.Now;
target.Status = status;
target.Summary = summary;
target.LastIteration = iteration;
target.UpdatedAt = DateTime.Now;
}
private AgentRunState GetOrCreateAgentRunForTab(string? tab)
{
var key = NormalizeTabKey(tab);
if (string.IsNullOrWhiteSpace(key))
return AgentRun;
if (_agentRunsByTab.TryGetValue(key, out var existing))
return existing;
var created = new AgentRunState();
_agentRunsByTab[key] = created;
return created;
}
public AgentRunState? GetAgentRunForTab(string? tab)
{
var key = NormalizeTabKey(tab);
if (string.IsNullOrWhiteSpace(key))
return AgentRun;
return _agentRunsByTab.TryGetValue(key, out var state) ? state : null;
}
public void UpsertAgentRun(string runId, string status, string summary, int iteration, string? tab = null)
{
ApplyRunSnapshot(AgentRun, runId, status, summary, iteration);
if (!string.IsNullOrWhiteSpace(tab))
ApplyRunSnapshot(GetOrCreateAgentRunForTab(tab), runId, status, summary, iteration);
NotifyStateChanged();
}
public void CompleteAgentRun(string runId, string status, string summary, int iteration)
public void CompleteAgentRun(string runId, string status, string summary, int iteration, string? tab = null)
{
if (!string.IsNullOrWhiteSpace(runId))
AgentRun.RunId = runId;
AgentRun.Status = status;
AgentRun.Summary = summary;
AgentRun.LastIteration = iteration;
AgentRun.UpdatedAt = DateTime.Now;
ApplyRunSnapshot(AgentRun, runId, status, summary, iteration);
if (!string.IsNullOrWhiteSpace(tab))
ApplyRunSnapshot(GetOrCreateAgentRunForTab(tab), runId, status, summary, iteration);
AgentRunHistory.Insert(0, new AgentRunState
{
RunId = AgentRun.RunId,
@@ -319,6 +349,7 @@ public sealed class AppStateService : IAppStateService
public void RestoreAgentRunHistory(IEnumerable<ChatAgentRunRecord>? history)
{
AgentRunHistory.Clear();
_agentRunsByTab.Clear();
if (history != null)
{
foreach (var item in history
@@ -398,6 +429,9 @@ public sealed class AppStateService : IAppStateService
}
public void ApplyAgentEvent(AgentEvent evt)
=> ApplyAgentEvent(evt, null);
public void ApplyAgentEvent(AgentEvent evt, string? tab)
{
TaskRuns.ApplyAgentEvent(evt);
if (evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied)
@@ -412,21 +446,24 @@ public sealed class AppStateService : IAppStateService
evt.RunId,
"running",
string.IsNullOrWhiteSpace(evt.Summary) ? "응답을 준비하는 중" : evt.Summary,
evt.Iteration);
evt.Iteration,
tab);
break;
case AgentEventType.Planning:
UpsertAgentRun(
evt.RunId,
"running",
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 계획을 세우는 중" : evt.Summary,
evt.Iteration);
evt.Iteration,
tab);
break;
case AgentEventType.Complete:
CompleteAgentRun(
evt.RunId,
"completed",
string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary,
evt.Iteration);
evt.Iteration,
tab);
break;
case AgentEventType.Error:
if (string.IsNullOrWhiteSpace(evt.ToolName))
@@ -435,7 +472,8 @@ public sealed class AppStateService : IAppStateService
evt.RunId,
"failed",
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary,
evt.Iteration);
evt.Iteration,
tab);
}
break;
case AgentEventType.Paused:
@@ -443,14 +481,16 @@ public sealed class AppStateService : IAppStateService
evt.RunId,
"paused",
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 일시중지" : evt.Summary,
evt.Iteration);
evt.Iteration,
tab);
break;
case AgentEventType.Resumed:
UpsertAgentRun(
evt.RunId,
"running",
string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 재개" : evt.Summary,
evt.Iteration);
evt.Iteration,
tab);
break;
}
}