diff --git a/README.md b/README.md index 3e8e857..d7e7937 100644 --- a/README.md +++ b/README.md @@ -2296,3 +2296,9 @@ MIT License - idle 상태는 중립색 구체와 하이라이트로, 실행 중 상태는 `AccentColor` 기반 구체로 표시되게 바꿨고, unread completion 점도 같은 시각 언어에 맞춰 외곽 스트로크와 하이라이트를 맞췄습니다. - 검증: - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_symbol_sphere\\ -p:IntermediateOutputPath=obj\\verify_conversation_symbol_sphere\\` 경고 0 / 오류 0 +- 업데이트: 2026-04-15 22:25 (KST) +- AX Agent에서 Cowork/Code를 동시에 실행할 때 메인 루프 번호와 라이브 진행 힌트가 서로 섞이던 문제를 정리했습니다. `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs`, `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`가 전역 단일 상태 대신 탭별 현재 run 상태, 진행 스텝, 라이브 힌트, 대기 UI 이벤트를 분리해 관리합니다. +- `src/AxCopilot/Services/AppStateService.cs`는 탭별 최신 run 상태 조회를 지원하도록 확장했고, 대화목록/작업요약/실행 메타 표시가 `Cowork`와 `Code` 각각의 run 정보를 읽도록 맞췄습니다. 동시에 실행해도 한 탭의 `메인 루프 1`이 다른 탭의 `메인 루프 14/15/16` 사이에 끼어드는 식의 혼선이 줄어듭니다. +- 테스트: + - `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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4539336..07443e0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1620,3 +1620,8 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - AX Agent 좌측 대화 목록 상태 심볼을 열린 링 대신 꽉 찬 원형 구체 스타일로 정리했습니다. `src/AxCopilot/Views/ChatWindow.xaml`의 `ConversationItemTemplate`에서 idle/running 표시를 모두 레이어드 `Ellipse` 배지로 교체해, 한쪽이 비어 보이던 시각 문제를 없앴습니다. - idle 상태는 중립색 구체와 하이라이트 조합으로, 실행 중 상태는 `AccentColor` 기반 구체로 보이게 바꿨고, unread completion 점도 같은 외곽/하이라이트 패턴으로 맞췄습니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_symbol_sphere\\ -p:IntermediateOutputPath=obj\\verify_conversation_symbol_sphere\\` 경고 0 / 오류 0 +업데이트: 2026-04-15 22:25 (KST) +- AX Agent 동시 실행 회귀를 수정했습니다. `src/AxCopilot/Views/ChatWindow.xaml.cs`, `src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs`, `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`가 전역 단일 진행 상태 대신 탭별 현재 run 상태, 라이브 힌트, 진행 스텝, 대기 UI 이벤트를 분리해 Cowork/Code 동시 실행 시 메인 루프 번호가 서로 섞이지 않도록 보정합니다. +- `src/AxCopilot/Services/AppStateService.cs`는 `GetAgentRunForTab(...)`과 탭 지정 `ApplyAgentEvent(...)`를 지원하도록 확장했고, `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`, `src/AxCopilot/Views/ChatWindow.TaskSummary.cs`가 현재 활성 탭의 run 메타를 읽도록 변경했습니다. +- 테스트: `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 diff --git a/src/AxCopilot.Tests/Services/AppStateServiceTests.cs b/src/AxCopilot.Tests/Services/AppStateServiceTests.cs index e361f23..ff64e36 100644 --- a/src/AxCopilot.Tests/Services/AppStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AppStateServiceTests.cs @@ -267,6 +267,37 @@ public class AppStateServiceTests state.FormatHookEventLine(state.GetRecentHookEvents()[0]).Should().Contain("hook"); } + [Fact] + public void ApplyAgentEvent_TracksLatestRunPerTabWithoutMixingIterations() + { + var state = new AppStateService(); + + state.ApplyAgentEvent(new AgentEvent + { + RunId = "cowork-run", + Type = AgentEventType.Thinking, + Summary = "코워크 진행", + Iteration = 14, + Timestamp = DateTime.Now.AddSeconds(-2), + }, "Cowork"); + + state.ApplyAgentEvent(new AgentEvent + { + RunId = "code-run", + Type = AgentEventType.Thinking, + Summary = "코드 진행", + Iteration = 1, + Timestamp = DateTime.Now, + }, "Code"); + + state.GetAgentRunForTab("Cowork").Should().NotBeNull(); + state.GetAgentRunForTab("Cowork")!.RunId.Should().Be("cowork-run"); + state.GetAgentRunForTab("Cowork")!.LastIteration.Should().Be(14); + state.GetAgentRunForTab("Code").Should().NotBeNull(); + state.GetAgentRunForTab("Code")!.RunId.Should().Be("code-run"); + state.GetAgentRunForTab("Code")!.LastIteration.Should().Be(1); + } + [Fact] public void ApplySubAgentStatus_StoresBackgroundJobHistory() { diff --git a/src/AxCopilot/Services/AppStateService.cs b/src/AxCopilot/Services/AppStateService.cs index dad5ace..3c97bc0 100644 --- a/src/AxCopilot/Services/AppStateService.cs +++ b/src/AxCopilot/Services/AppStateService.cs @@ -194,6 +194,7 @@ public sealed class AppStateService : IAppStateService public IReadOnlyList ActiveTasks => TaskRuns.ActiveTasks; public IReadOnlyList RecentTasks => TaskRuns.RecentTasks; public AgentRunState AgentRun { get; } = new(); + private readonly Dictionary _agentRunsByTab = new(StringComparer.OrdinalIgnoreCase); public List AgentRunHistory { get; } = new(); public List PermissionHistory { get; } = new(); public List 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? 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; } } diff --git a/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs b/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs index 7b72d7d..4f2ba41 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs @@ -308,10 +308,8 @@ public partial class ChatWindow private static string GetStatusMessageForTool(string toolName) => GetStatusInfoForTool(toolName).message + "..."; - private void TouchLiveAgentProgressHints() - { - _lastAgentProgressEventAt = DateTime.UtcNow; - } + private void TouchLiveAgentProgressHints(string? runTab = null) + => TouchLastAgentProgressEventAt(runTab); private void AgentProgressHintTimer_Tick(object? sender, EventArgs e) { @@ -326,14 +324,15 @@ public partial class ChatWindow if (!string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase) && !string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) { - UpdateLiveAgentProgressHint(null); + UpdateLiveAgentProgressHint(null, runTab: runTab); return; } - var idle = DateTime.UtcNow - _lastAgentProgressEventAt; + var idle = DateTime.UtcNow - GetLastAgentProgressEventAt(runTab); TryGetStreamingElapsed(out var elapsed); - var lastProgressEvent = _currentRunProgressSteps.Count > 0 - ? _currentRunProgressSteps[^1] + var progressSteps = GetRunProgressSteps(runTab); + var lastProgressEvent = progressSteps.Count > 0 + ? progressSteps[^1] : null; var idleNarrative = AgentStatusNarrativeCatalog.BuildIdle( lastProgressEvent, @@ -387,20 +386,22 @@ public partial class ChatWindow subItemCategory: idleNarrative.Category); } - UpdateLiveAgentProgressHint(summary, toolName); + UpdateLiveAgentProgressHint(summary, toolName, runTab); } - private void UpdateLiveAgentProgressHint(string? summary, string toolName = "") + private void UpdateLiveAgentProgressHint(string? summary, string toolName = "", string? runTab = null) { + var tabKey = NormalizeRunTabKey(runTab); var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim(); - var currentSummary = _liveAgentProgressHint?.Summary; - var currentToolName = _liveAgentProgressHint?.ToolName ?? ""; + var currentHint = GetLiveAgentProgressHintState(tabKey); + var currentSummary = currentHint?.Summary; + var currentToolName = currentHint?.ToolName ?? ""; var elapsedMs = _isStreaming ? GetStreamingElapsedMsOrZero() : 0L; - var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab)); - var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab)); - var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 3000; + var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(tabKey)); + var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(tabKey)); + var currentElapsedBucket = (currentHint?.ElapsedMs ?? 0) / 3000; var nextElapsedBucket = elapsedMs / 3000; - var currentTokenBucket = ((_liveAgentProgressHint?.InputTokens ?? 0) + (_liveAgentProgressHint?.OutputTokens ?? 0)) / 500; + var currentTokenBucket = ((currentHint?.InputTokens ?? 0) + (currentHint?.OutputTokens ?? 0)) / 500; var nextTokenBucket = (inputTokens + outputTokens) / 500; if (string.Equals(currentSummary, normalizedSummary, StringComparison.Ordinal) && string.Equals(currentToolName, toolName, StringComparison.Ordinal) @@ -408,51 +409,56 @@ public partial class ChatWindow && currentTokenBucket == nextTokenBucket) return; - _liveAgentProgressHint = normalizedSummary == null - ? null - : new AgentEvent - { - Timestamp = DateTime.Now, - RunId = _appState.AgentRun.RunId, - Type = AgentEventType.Thinking, - ToolName = toolName, - Summary = normalizedSummary, - ElapsedMs = elapsedMs, - InputTokens = (int)inputTokens, - OutputTokens = (int)outputTokens, - }; + var currentRun = GetCurrentAgentRunState(tabKey); + SetLiveAgentProgressHintState( + tabKey, + normalizedSummary == null + ? null + : new AgentEvent + { + Timestamp = DateTime.Now, + RunId = currentRun?.RunId ?? "", + Type = AgentEventType.Thinking, + ToolName = toolName, + Summary = normalizedSummary, + ElapsedMs = elapsedMs, + InputTokens = (int)inputTokens, + OutputTokens = (int)outputTokens, + Iteration = currentRun?.LastIteration ?? 0, + }); // 스트리밍 중 프로그레스 힌트 변경으로 인한 ScheduleExecutionHistoryRender 호출 차단 // 힌트 업데이트만으로 전체 트랜스크립트를 재렌더하면 UI 스레드 멈춤 발생 // 타이머가 자연스럽게 다음 렌더 사이클에서 힌트를 반영함 } - private AgentEvent? GetLiveAgentProgressHint() + private AgentEvent? GetLiveAgentProgressHint(string? runTab = null) { - if (_liveAgentProgressHint == null) + var tabKey = NormalizeRunTabKey(runTab); + var hint = GetLiveAgentProgressHintState(tabKey); + if (hint == null) return null; - var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; - if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(tabKey, _activeTab, StringComparison.OrdinalIgnoreCase)) return null; return new AgentEvent { - Timestamp = _liveAgentProgressHint.Timestamp, - RunId = _liveAgentProgressHint.RunId, - Type = _liveAgentProgressHint.Type, - ToolName = _liveAgentProgressHint.ToolName, - Summary = _liveAgentProgressHint.Summary, - FilePath = _liveAgentProgressHint.FilePath, - Success = _liveAgentProgressHint.Success, - StepCurrent = _liveAgentProgressHint.StepCurrent, - StepTotal = _liveAgentProgressHint.StepTotal, - Steps = _liveAgentProgressHint.Steps, - ElapsedMs = _liveAgentProgressHint.ElapsedMs, - InputTokens = _liveAgentProgressHint.InputTokens, - OutputTokens = _liveAgentProgressHint.OutputTokens, - ToolInput = _liveAgentProgressHint.ToolInput, - Iteration = _liveAgentProgressHint.Iteration, + Timestamp = hint.Timestamp, + RunId = hint.RunId, + Type = hint.Type, + ToolName = hint.ToolName, + Summary = hint.Summary, + FilePath = hint.FilePath, + Success = hint.Success, + StepCurrent = hint.StepCurrent, + StepTotal = hint.StepTotal, + Steps = hint.Steps, + ElapsedMs = hint.ElapsedMs, + InputTokens = hint.InputTokens, + OutputTokens = hint.OutputTokens, + ToolInput = hint.ToolInput, + Iteration = hint.Iteration, }; } diff --git a/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs index c0f612c..7d92a39 100644 --- a/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs @@ -361,6 +361,7 @@ public partial class ChatWindow lock (_convLock) currentConversationId = _currentConversation?.Id ?? ""; var streamingConversationId = GetStreamingConversation(_activeTab)?.Id; + var activeRun = GetCurrentAgentRunState(_activeTab); var items = metas.Select(c => { @@ -384,8 +385,8 @@ public partial class ChatWindow var isRunning = ShouldShowConversationRunningIndicator( c.Id, streamingConversationId, - _appState.AgentRun.RunId, - _appState.AgentRun.Status); + activeRun?.RunId, + activeRun?.Status); return new ConversationMeta { diff --git a/src/AxCopilot/Views/ChatWindow.TaskSummary.cs b/src/AxCopilot/Views/ChatWindow.TaskSummary.cs index 1bd5249..3e16e5c 100644 --- a/src/AxCopilot/Views/ChatWindow.TaskSummary.cs +++ b/src/AxCopilot/Views/ChatWindow.TaskSummary.cs @@ -49,7 +49,8 @@ public partial class ChatWindow lock (_convLock) currentConversation = _currentConversation; AddTaskSummaryObservabilitySections(panel, currentConversation); - if (!string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)) + var activeRun = GetCurrentAgentRunState(_activeTab); + if (!string.IsNullOrWhiteSpace(activeRun?.RunId)) { var currentRun = new Border { @@ -65,21 +66,21 @@ public partial class ChatWindow { new TextBlock { - Text = $"실행 run {ShortRunId(_appState.AgentRun.RunId)}", + Text = $"실행 run {ShortRunId(activeRun!.RunId)}", FontWeight = FontWeights.SemiBold, Foreground = primaryText, FontSize = 9.75, }, new TextBlock { - Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · step {_appState.AgentRun.LastIteration}", + Text = $"{GetRunStatusLabel(activeRun!.Status)} · step {activeRun.LastIteration}", Margin = new Thickness(0, 2, 0, 0), - Foreground = GetRunStatusBrush(_appState.AgentRun.Status), + Foreground = GetRunStatusBrush(activeRun.Status), FontSize = 9, }, new TextBlock { - Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary, + Text = string.IsNullOrWhiteSpace(activeRun!.Summary) ? "요약 없음" : activeRun.Summary, Margin = new Thickness(0, 3, 0, 0), TextWrapping = TextWrapping.Wrap, Foreground = Brushes.DimGray, diff --git a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs index f5d1836..d1e585b 100644 --- a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs @@ -154,7 +154,9 @@ public partial class ChatWindow // 현재 실행 중인 run의 process feed 이벤트를 통합 카드로 대체 (히스토리 접힘 모드) var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true; - var activeRunId = _isStreaming ? (_appState.AgentRun.RunId ?? "") : ""; + var activeRunId = _isStreaming + ? (GetCurrentAgentRunState(_activeTab)?.RunId ?? "") + : ""; var eventIndex = 0; string? prevToolCallName = null; @@ -224,9 +226,10 @@ public partial class ChatWindow FlushProcessFeedGroup(); // 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체) - if (!showFullHistory && _isStreaming && _currentRunProgressSteps.Count > 0) + var currentRunProgressSteps = GetRunProgressSteps(_activeTab); + if (!showFullHistory && _isStreaming && currentRunProgressSteps.Count > 0) { - var capturedSteps = _currentRunProgressSteps.ToList(); + var capturedSteps = currentRunProgressSteps.ToList(); var cardTimestamp = capturedSteps[^1].Timestamp; // 통합 진행 카드는 매번 내용이 바뀌므로 volatile 키 사용 timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps))); diff --git a/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs b/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs index aa3e2e6..5597b3f 100644 --- a/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs @@ -411,7 +411,7 @@ public partial class ChatWindow if (_v2LiveStatusText == null) return; - var hint = GetLiveAgentProgressHint(); + var hint = GetLiveAgentProgressHint(runTab); if (hint != null) { var narrative = AgentStatusNarrativeCatalog.BuildFromEvent(hint, runTab); diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index db01301..cf851a6 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -87,8 +87,8 @@ public partial class ChatWindow : Window private Border? _userAskCard; // transcript 내 질문 카드 private string? _pendingPlanSummary; private List _pendingPlanSteps = new(); - // 현재 실행 중인 에이전트 스텝 누적 (통합 진행 카드용) - private readonly List _currentRunProgressSteps = new(); + // 탭별 현재 실행 중인 에이전트 스텝 누적 (통합 진행 카드용) + private readonly Dictionary> _tabRunProgressSteps = new(StringComparer.OrdinalIgnoreCase); private bool _userScrolled; // 사용자가 위로 스크롤했는지 private long _lastScrollTick; // 스크롤 스로틀링용 (Environment.TickCount64) // 메시지 버블 캐시: messageId → 렌더링된 UIElement (재생성 방지) @@ -171,11 +171,157 @@ public partial class ChatWindow : Window private bool _pendingExecutionHistoryAutoScroll; private readonly Dictionary _pendingConversationPersists = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _expandedDraftQueueTabs = new(StringComparer.OrdinalIgnoreCase); - private AgentEvent? _pendingAgentUiEvent; - private AgentEvent? _liveAgentProgressHint; - private DateTime _lastAgentProgressEventAt = DateTime.UtcNow; + private readonly Dictionary _pendingAgentUiEvents = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tabLiveAgentProgressHints = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tabLastAgentProgressEventAt = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tabAgentRunStates = new(StringComparer.OrdinalIgnoreCase); private double _lastResponsiveComposerWidth; private double _lastResponsiveMessageWidth; + + private string NormalizeRunTabKey(string? tab) + => NormalizeTabName(string.IsNullOrWhiteSpace(tab) ? _activeTab : tab!); + + private List GetRunProgressSteps(string? tab, bool ensure = false) + { + var key = NormalizeRunTabKey(tab); + if (_tabRunProgressSteps.TryGetValue(key, out var existing)) + return existing; + + if (!ensure) + return []; + + var created = new List(); + _tabRunProgressSteps[key] = created; + return created; + } + + private void ClearRunProgressSteps(string? tab) + { + var key = NormalizeRunTabKey(tab); + _tabRunProgressSteps.Remove(key); + } + + private AgentEvent? GetPendingAgentUiEvent(string? tab) + { + var key = NormalizeRunTabKey(tab); + return _pendingAgentUiEvents.TryGetValue(key, out var evt) ? evt : null; + } + + private AgentEvent? ConsumePendingAgentUiEvent(string? tab) + { + var key = NormalizeRunTabKey(tab); + if (!_pendingAgentUiEvents.TryGetValue(key, out var evt)) + return null; + + _pendingAgentUiEvents.Remove(key); + return evt; + } + + private AgentEvent? GetLiveAgentProgressHintState(string? tab) + { + var key = NormalizeRunTabKey(tab); + return _tabLiveAgentProgressHints.TryGetValue(key, out var evt) ? evt : null; + } + + private void SetLiveAgentProgressHintState(string? tab, AgentEvent? evt) + { + var key = NormalizeRunTabKey(tab); + if (evt == null) + { + _tabLiveAgentProgressHints.Remove(key); + return; + } + + _tabLiveAgentProgressHints[key] = evt; + } + + private DateTime GetLastAgentProgressEventAt(string? tab) + { + var key = NormalizeRunTabKey(tab); + return _tabLastAgentProgressEventAt.TryGetValue(key, out var timestamp) + ? timestamp + : DateTime.UtcNow; + } + + private void TouchLastAgentProgressEventAt(string? tab) + { + var key = NormalizeRunTabKey(tab); + _tabLastAgentProgressEventAt[key] = DateTime.UtcNow; + } + + private void ClearLiveAgentProgressState(string? tab) + { + var key = NormalizeRunTabKey(tab); + _tabLastAgentProgressEventAt.Remove(key); + _tabLiveAgentProgressHints.Remove(key); + _pendingAgentUiEvents.Remove(key); + _tabRunProgressSteps.Remove(key); + _tabAgentRunStates.Remove(key); + } + + private AppStateService.AgentRunState? GetCurrentAgentRunState(string? tab) + { + var key = NormalizeRunTabKey(tab); + if (_tabAgentRunStates.TryGetValue(key, out var local)) + return local; + + return _appState.GetAgentRunForTab(key); + } + + private static bool IsTerminalAgentRunStatus(string? status) + => string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "failed", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "paused", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "canceled", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "cancelled", StringComparison.OrdinalIgnoreCase); + + private void ApplyCurrentAgentRunState(string runTab, AgentEvent evt) + { + var key = NormalizeRunTabKey(runTab); + if (!_tabAgentRunStates.TryGetValue(key, out var state)) + { + state = new AppStateService.AgentRunState(); + _tabAgentRunStates[key] = state; + } + + if (!string.IsNullOrWhiteSpace(evt.RunId) + && !string.Equals(state.RunId, evt.RunId, StringComparison.OrdinalIgnoreCase)) + { + state.RunId = evt.RunId; + state.StartedAt = evt.Timestamp == default ? DateTime.Now : evt.Timestamp; + } + + switch (evt.Type) + { + case AgentEventType.Thinking: + case AgentEventType.Planning: + case AgentEventType.Resumed: + state.Status = "running"; + state.Summary = string.IsNullOrWhiteSpace(evt.Summary) ? "응답을 준비하는 중" : evt.Summary; + state.LastIteration = evt.Iteration; + state.UpdatedAt = evt.Timestamp == default ? DateTime.Now : evt.Timestamp; + break; + case AgentEventType.Paused: + state.Status = "paused"; + state.Summary = string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 일시중지" : evt.Summary; + state.LastIteration = evt.Iteration; + state.UpdatedAt = evt.Timestamp == default ? DateTime.Now : evt.Timestamp; + break; + case AgentEventType.Complete: + state.Status = "completed"; + state.Summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary; + state.LastIteration = evt.Iteration; + state.UpdatedAt = evt.Timestamp == default ? DateTime.Now : evt.Timestamp; + break; + case AgentEventType.Error when string.IsNullOrWhiteSpace(evt.ToolName): + state.Status = "failed"; + state.Summary = string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary; + state.LastIteration = evt.Iteration; + state.UpdatedAt = evt.Timestamp == default ? DateTime.Now : evt.Timestamp; + break; + } + } + private bool IsLightweightLiveProgressMode(string? runTab = null) { var tab = string.IsNullOrWhiteSpace(runTab) ? (_streamRunTab ?? _activeTab) : runTab!; @@ -1564,15 +1710,19 @@ public partial class ChatWindow : Window if (!shouldShowTopLevelGuide) { + _agentProgressHintTimer.Stop(); RemoveAgentLiveCard(animated: false); HideStreamingStatusBar(); HideStickyProgress(); } else if (IsAgentLiveCardEligibleTab(_activeTab)) { + if (!_agentProgressHintTimer.IsEnabled) + _agentProgressHintTimer.Start(); EnsureAgentLiveCardVisible(_activeTab); - if (_pendingAgentUiEvent != null) - UpdateAgentLiveCardV2(_pendingAgentUiEvent); + var pendingUiEvent = GetPendingAgentUiEvent(_activeTab); + if (pendingUiEvent != null) + UpdateAgentLiveCardV2(pendingUiEvent); } } @@ -6127,6 +6277,7 @@ public partial class ChatWindow : Window doneCts.Dispose(); _streamRunTab = _streamingTabs.Count > 0 ? _streamingTabs.First() : null; ClearStreamingConversation(tab, finishedConversation); + ClearLiveAgentProgressState(tab); // Cowork/Code 탭 작업 완료 시, 앱이 포커스 없으면 Windows 알림 NotifyTaskCompletionIfBackground(tab, finishedConversation, previewContent); @@ -6136,7 +6287,7 @@ public partial class ChatWindow : Window { ViewModel.IsStreaming = false; // ViewModel 상태 동기화 (CanSend 등 파생 프로퍼티 갱신) FlushPendingConversationPersists(); - FlushPendingAgentUiEvent(); + FlushPendingAgentUiEvent(_activeTab); StopLiveAgentProgressHints(); _cursorTimer.Stop(); _elapsedTimer.Stop(); @@ -6168,6 +6319,11 @@ public partial class ChatWindow : Window UpdateResponsiveChatLayout(); } } + else if (string.Equals(tab, _activeTab, StringComparison.OrdinalIgnoreCase)) + { + FlushPendingAgentUiEvent(_activeTab); + StopLiveAgentProgressHints(tab); + } RefreshStreamingControlsForActiveTab(); @@ -6313,7 +6469,7 @@ public partial class ChatWindow : Window var streamToken = streamCts.Token; Services.LogService.Info($"[Exec] ExecutePreparedTurnAsync 진입: tab={runTab}, mode={preparedExecution.Mode}"); RefreshStreamingControlsForActiveTab(); - StartLiveAgentProgressHints(); + StartLiveAgentProgressHints(runTab); UpdateStreamMetricsLabel(); ForceScrollToEnd(); @@ -6398,7 +6554,7 @@ public partial class ChatWindow : Window } responseElapsedMs = GetStreamingElapsedMsOrZero(); - assistantMetaRunId = _appState.AgentRun.RunId; + assistantMetaRunId = GetCurrentAgentRunState(runTab)?.RunId; var usage = _llm.LastTokenUsage; if (usage != null) { @@ -6610,9 +6766,9 @@ public partial class ChatWindow : Window _conversationPersistTimer.Start(); } - private void ScheduleAgentUiEvent(AgentEvent evt) + private void ScheduleAgentUiEvent(string runTab, AgentEvent evt) { - _pendingAgentUiEvent = evt; + _pendingAgentUiEvents[NormalizeRunTabKey(runTab)] = evt; if (IsBackgroundUiThrottleActive()) { _pendingBackgroundAgentUiEventFlush = true; @@ -6637,10 +6793,9 @@ public partial class ChatWindow : Window return !IsActive; } - private void FlushPendingAgentUiEvent() + private void FlushPendingAgentUiEvent(string? runTab = null) { - var evt = _pendingAgentUiEvent; - _pendingAgentUiEvent = null; + var evt = ConsumePendingAgentUiEvent(runTab); if (evt == null) return; @@ -6816,9 +6971,10 @@ public partial class ChatWindow : Window private void OnAgentEvent(AgentEvent evt, string runTab) { - TouchLiveAgentProgressHints(); + TouchLiveAgentProgressHints(runTab); var eventTab = runTab; var normalizedRunTab = NormalizeTabName(runTab); + ApplyCurrentAgentRunState(normalizedRunTab, evt); var isOwningActiveTab = string.Equals(normalizedRunTab, _activeTab, StringComparison.OrdinalIgnoreCase) && _streamingTabs.Contains(normalizedRunTab); var guideVisibility = ChatStreamingUiPolicy.ResolveGuideVisibility( @@ -6854,11 +7010,12 @@ public partial class ChatWindow : Window } // 현재 실행 중인 스텝 추적 (통합 진행 카드용) - if (IsProcessFeedEvent(evt) && shouldShowTopLevelGuide) + if (IsProcessFeedEvent(evt) && _streamingTabs.Contains(normalizedRunTab)) { - _currentRunProgressSteps.Add(evt); - if (_currentRunProgressSteps.Count > 8) - _currentRunProgressSteps.RemoveAt(0); + var progressSteps = GetRunProgressSteps(normalizedRunTab, ensure: true); + progressSteps.Add(evt); + if (progressSteps.Count > 8) + progressSteps.RemoveAt(0); } // ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ────────────────────────── @@ -6877,7 +7034,7 @@ public partial class ChatWindow : Window EnqueueAgentEventWork(evt, eventTab, shouldRender); // ── 3단계: 경량 상태 추적 (UI 스레드) ─────────────────────────────── - _appState.ApplyAgentEvent(evt); + _appState.ApplyAgentEvent(evt, normalizedRunTab); // 탭별 토큰 누적 — 활성 탭 것만 하단 바에 표시 if (evt.InputTokens > 0 || evt.OutputTokens > 0) @@ -6889,7 +7046,7 @@ public partial class ChatWindow : Window } if (shouldShowTopLevelGuide) - ScheduleAgentUiEvent(evt); + ScheduleAgentUiEvent(normalizedRunTab, evt); if (!lightweightLiveMode || evt.Type is AgentEventType.Complete or AgentEventType.Error @@ -6901,33 +7058,43 @@ public partial class ChatWindow : Window } } - private void StartLiveAgentProgressHints() + private void StartLiveAgentProgressHints(string? runTab = null) { - _lastAgentProgressEventAt = DateTime.UtcNow; - _currentRunProgressSteps.Clear(); - var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; - if (IsAgentLiveCardEligibleTab(runTab)) + var normalizedRunTab = NormalizeRunTabKey(runTab ?? _streamRunTab ?? _activeTab); + TouchLastAgentProgressEventAt(normalizedRunTab); + ClearRunProgressSteps(normalizedRunTab); + SetLiveAgentProgressHintState(normalizedRunTab, null); + if (IsAgentLiveCardEligibleTab(normalizedRunTab)) { - EnsureAgentLiveCardVisible(runTab); - var initialStatus = AgentStatusNarrativeCatalog.BuildInitial(runTab); - UpdateLiveAgentProgressHint(initialStatus.Message, "agent_wait"); + EnsureAgentLiveCardVisible(normalizedRunTab); + var initialStatus = AgentStatusNarrativeCatalog.BuildInitial(normalizedRunTab); + UpdateLiveAgentProgressHint(initialStatus.Message, "agent_wait", normalizedRunTab); ShowStreamingStatusBar(initialStatus.Message, detail: initialStatus.Detail); } else { RemoveAgentLiveCard(animated: false); - UpdateLiveAgentProgressHint(null); + UpdateLiveAgentProgressHint(null, runTab: normalizedRunTab); ShowStreamingStatusBar("생각하는 중..."); } _agentProgressHintTimer.Stop(); _agentProgressHintTimer.Start(); } - private void StopLiveAgentProgressHints() + private void StopLiveAgentProgressHints(string? runTab = null) { _agentProgressHintTimer.Stop(); - UpdateLiveAgentProgressHint(null); - HideStreamingStatusBar(); + if (string.IsNullOrWhiteSpace(runTab)) + { + foreach (var key in _tabLiveAgentProgressHints.Keys.ToList()) + SetLiveAgentProgressHintState(key, null); + HideStreamingStatusBar(); + return; + } + + SetLiveAgentProgressHintState(runTab, null); + if (string.Equals(NormalizeRunTabKey(runTab), _activeTab, StringComparison.OrdinalIgnoreCase)) + HideStreamingStatusBar(); } // ─── 프롬프트 템플릿 팝업 ────────────────────────────────────────────