From f173e2a63bf18636091c404b0d6d6a245100acae Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 19:24:40 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=83=AD=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EC=A0=84=ED=99=98=20=EC=A4=91=EC=97=90=EB=8F=84=20?= =?UTF-8?q?AX=20Agent=20=EC=8B=A4=ED=96=89=EC=9D=B4=20=EA=B3=84=EC=86=8D?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실행 시작 대화를 탭별로 추적해 같은 탭에서 다른 대화로 이동하거나 새 대화를 시작해도 원래 대화에 이벤트와 완료 결과를 저장하도록 정리함 - 탭 복귀 시 진행 중인 대화를 다시 로드하고 백그라운드 실행 저장이 현재 선택 대화 ID를 덮어쓰지 않도록 세션/저장 경로를 보강함 - ChatSessionStateService와 AxAgentExecutionEngine 회귀 테스트를 추가하고 README.md, docs/DEVELOPMENT.md 이력을 갱신함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_conversation_background_resume\ -p:IntermediateOutputPath=obj\verify_conversation_background_resume\ (경고 0, 오류 0) - 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ChatSessionStateServiceTests|AxAgentExecutionEngineTests -p:OutputPath=bin\verify_conversation_background_resume_tests\ -p:IntermediateOutputPath=obj\verify_conversation_background_resume_tests\ (통과 39) --- README.md | 7 + docs/DEVELOPMENT.md | 7 + .../Services/AxAgentExecutionEngineTests.cs | 59 ++++++++ .../Services/ChatSessionStateServiceTests.cs | 42 ++++++ .../Services/Agent/AxAgentExecutionEngine.cs | 65 ++++++-- .../Services/ChatSessionStateService.cs | 70 +++++++-- .../Views/ChatWindow.AgentEventProcessor.cs | 16 +- ...ChatWindow.ConversationListPresentation.cs | 6 +- src/AxCopilot/Views/ChatWindow.xaml.cs | 141 ++++++++++++++---- 9 files changed, 353 insertions(+), 60 deletions(-) create mode 100644 src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs diff --git a/README.md b/README.md index a3b9961..77b7fa4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # AX Commander +- 업데이트: 2026-04-15 19:21 (KST) +- AX Agent 실행이 이제 같은 탭 안에서 다른 대화로 이동하거나 새 대화를 시작해도 원래 대화에 계속 귀속됩니다. `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`에서 대화 전환 시 즉시 취소하던 흐름을 제거했고, `src/AxCopilot/Views/ChatWindow.xaml.cs`는 실행 중인 대화를 탭별로 추적해 탭 복귀 시 진행 중이던 대화를 다시 보여주도록 정리했습니다. +- `src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs`, `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/ChatSessionStateService.cs`는 에이전트 이벤트와 완료 기록을 현재 화면의 `_currentConversation`이 아니라 실행이 시작된 원래 대화에 누적하도록 보강했습니다. 같은 탭에서 다른 대화를 보고 있어도 실행 로그와 완료 요약이 새 대화에 섞이지 않고, 선택 중인 대화 ID도 백그라운드 실행 때문에 되돌아가지 않습니다. +- `src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs`, `src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs`에 같은 탭 백그라운드 실행 귀속 회귀 테스트를 추가했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_background_resume\\ -p:IntermediateOutputPath=obj\\verify_conversation_background_resume\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatSessionStateServiceTests|AxAgentExecutionEngineTests" -p:OutputPath=bin\\verify_conversation_background_resume_tests\\ -p:IntermediateOutputPath=obj\\verify_conversation_background_resume_tests\\` 통과 39 + - 업데이트: 2026-04-15 18:54 (KST) - AX Agent 표시 회귀를 정리했습니다. `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`를 이전 라이브 진행 카드 구조로 되돌려 상단 라이브 카드가 다시 단계형으로 보이도록 복구했고, 스트리밍 중인 현재 실행 이벤트는 본문 타임라인에 중복으로 쌓이지 않도록 컷오프를 다시 적용했습니다. - 본문 드래그 선택은 유지하되 사용자 말풍선 회귀는 되돌렸습니다. `src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`에서 사용자 버블만 일반 마크다운 렌더로 복원해 세로로 깨지던 표시를 막고, 어시스턴트 본문과 스트리밍 완료 본문(`src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`)은 계속 드래그 선택/복사할 수 있게 유지했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4a7d813..3e0e48b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1504,3 +1504,10 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 ### 검증 - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_chat_storage_fix\\ -p:IntermediateOutputPath=obj\\verify_chat_storage_fix\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatStorageServiceTests" -p:OutputPath=bin\\verify_chat_storage_fix_tests\\ -p:IntermediateOutputPath=obj\\verify_chat_storage_fix_tests\\` 통과 4 +업데이트: 2026-04-15 19:21 (KST) +- AX Agent 실행 대화를 탭별로 추적하도록 정리했습니다. `src/AxCopilot/Views/ChatWindow.xaml.cs`는 실행 시작 시 원래 대화를 따로 보관하고, 같은 탭에서 다른 대화로 이동하거나 새 대화를 시작해도 진행 중인 실행이 끊기지 않도록 탭 복귀 시 해당 대화를 다시 로드합니다. +- `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`는 대화 선택 시 `StopStreamingIfActive()`로 전체 실행을 취소하던 흐름을 제거했습니다. 대신 `src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs`, `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/ChatSessionStateService.cs`가 에이전트 이벤트·실행 요약·완료 메시지를 현재 선택 대화가 아니라 실행이 시작된 원래 대화에 누적하도록 역할을 분리했습니다. +- 숨겨진 백그라운드 실행이 현재 선택 상태를 덮어쓰지 않도록 저장 경로도 보강했습니다. 같은 탭에서 다른 대화를 보고 있는 동안에는 실행 로그를 현재 본문에 렌더하지 않고, 배치 저장 시에도 `RememberConversation(...)`를 현재 선택 대화와 일치할 때만 갱신하도록 조정했습니다. +- 테스트는 `src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs`, `src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs`에 같은 탭 백그라운드 실행 귀속 회귀 케이스를 추가했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_conversation_background_resume\\ -p:IntermediateOutputPath=obj\\verify_conversation_background_resume\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatSessionStateServiceTests|AxAgentExecutionEngineTests" -p:OutputPath=bin\\verify_conversation_background_resume_tests\\ -p:IntermediateOutputPath=obj\\verify_conversation_background_resume_tests\\` 통과 39 diff --git a/src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs b/src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs new file mode 100644 index 0000000..3c727fd --- /dev/null +++ b/src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs @@ -0,0 +1,59 @@ +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Services; + +public class AxAgentExecutionEngineTests +{ + [Fact] + public void AppendExecutionEvent_PreservesVisibleConversation_WhenSameTabRunContinuesInBackground() + { + var engine = new AxAgentExecutionEngine(); + var session = new ChatSessionStateService(); + var storage = new ChatStorageService(); + var visibleConversation = new ChatConversation + { + Id = $"visible-{Guid.NewGuid():N}", + Tab = "Code", + Title = "visible", + Messages = [new ChatMessage { Role = "user", Content = "keep me selected" }] + }; + var runningConversation = new ChatConversation + { + Id = $"running-{Guid.NewGuid():N}", + Tab = "Code", + Title = "running", + Messages = [new ChatMessage { Role = "user", Content = "background run" }] + }; + + session.SetCurrentConversation("Code", visibleConversation, storage); + session.RememberConversation("Code", visibleConversation.Id); + + var result = engine.AppendExecutionEvent( + session, + storage, + visibleConversation, + "Code", + "Code", + new AgentEvent + { + RunId = "run-background", + Type = AgentEventType.ToolResult, + ToolName = "file_read", + Summary = "background step", + Timestamp = DateTime.Now, + Success = true, + }, + runningConversation); + + result.CurrentConversation.Should().BeSameAs(visibleConversation); + result.UpdatedConversation.Should().BeSameAs(runningConversation); + session.CurrentConversation.Should().BeSameAs(visibleConversation); + session.GetConversationId("Code").Should().Be(visibleConversation.Id); + runningConversation.ExecutionEvents.Should().ContainSingle(); + runningConversation.ExecutionEvents[0].RunId.Should().Be("run-background"); + } +} diff --git a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs index 26ae433..2bfd228 100644 --- a/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs @@ -129,6 +129,48 @@ public class ChatSessionStateServiceTests session.CurrentConversation.AgentRunHistory[0].LastIteration.Should().Be(2); } + [Fact] + public void AppendExecutionEvent_WithExplicitConversation_DoesNotReplaceVisibleConversation_WhenRememberDisabled() + { + var session = new ChatSessionStateService(); + var visibleConversation = new ChatConversation + { + Id = $"visible-{Guid.NewGuid():N}", + Tab = "Code", + Title = "visible", + Messages = [new ChatMessage { Role = "user", Content = "keep me selected" }] + }; + var runningConversation = new ChatConversation + { + Id = $"running-{Guid.NewGuid():N}", + Tab = "Code", + Title = "running", + Messages = [new ChatMessage { Role = "user", Content = "background run" }] + }; + + session.SetCurrentConversation("Code", visibleConversation); + session.RememberConversation("Code", visibleConversation.Id); + + session.AppendExecutionEvent( + "Code", + runningConversation, + new AgentEvent + { + RunId = "run-background", + Type = AgentEventType.ToolResult, + ToolName = "file_read", + Summary = "background step", + Timestamp = DateTime.Now, + Success = true, + }, + rememberConversation: false); + + session.CurrentConversation.Should().BeSameAs(visibleConversation); + session.GetConversationId("Code").Should().Be(visibleConversation.Id); + runningConversation.ExecutionEvents.Should().ContainSingle(); + runningConversation.ExecutionEvents[0].RunId.Should().Be("run-background"); + } + [Fact] public void EnqueueDraft_AndToggleExecutionHistory_UpdateConversationState() { diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs index 48527f9..a00656b 100644 --- a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs +++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs @@ -238,7 +238,8 @@ public sealed class AxAgentExecutionEngine ChatConversation? activeConversation, string activeTab, string targetTab, - AgentEvent evt) + AgentEvent evt, + ChatConversation? targetConversation = null) { return ApplyConversationMutation( session, @@ -246,7 +247,10 @@ public sealed class AxAgentExecutionEngine activeConversation, activeTab, targetTab, - normalizedTarget => session.AppendExecutionEvent(normalizedTarget, evt, null)); + targetConversation, + (normalizedTarget, rememberConversation) => targetConversation == null + ? session.AppendExecutionEvent(normalizedTarget, evt, null) + : session.AppendExecutionEvent(normalizedTarget, targetConversation, evt, null, rememberConversation)); } public SessionMutationResult AppendAgentRun( @@ -257,7 +261,8 @@ public sealed class AxAgentExecutionEngine string targetTab, AgentEvent evt, string status, - string summary) + string summary, + ChatConversation? targetConversation = null) { return ApplyConversationMutation( session, @@ -265,7 +270,10 @@ public sealed class AxAgentExecutionEngine activeConversation, activeTab, targetTab, - normalizedTarget => session.AppendAgentRun(normalizedTarget, evt, status, summary, null)); + targetConversation, + (normalizedTarget, rememberConversation) => targetConversation == null + ? session.AppendAgentRun(normalizedTarget, evt, status, summary, null) + : session.AppendAgentRun(normalizedTarget, targetConversation, evt, status, summary, null, rememberConversation)); } public string NormalizeAssistantContent( @@ -448,34 +456,55 @@ public sealed class AxAgentExecutionEngine ChatConversation? activeConversation, string activeTab, string targetTab, - Func mutate) + ChatConversation? targetConversation, + Func mutate) { var normalizedTarget = NormalizeTabName(targetTab); var normalizedActive = NormalizeTabName(activeTab); ChatConversation updatedConversation; - if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase)) + if (targetConversation == null + && string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase)) { - session.CurrentConversation = updatedConversation = mutate(normalizedTarget); + session.CurrentConversation = updatedConversation = mutate(normalizedTarget, true); return new SessionMutationResult(updatedConversation, updatedConversation); } - var activeSnapshot = activeConversation; - var previousSessionConversation = session.CurrentConversation; - updatedConversation = mutate(normalizedTarget); + var targetMatchesActive = ConversationMatches(targetConversation, activeConversation) + || ConversationMatches(targetConversation, session.CurrentConversation); + updatedConversation = mutate(normalizedTarget, targetMatchesActive); + + if (targetMatchesActive) + { + session.CurrentConversation = updatedConversation; + return new SessionMutationResult(updatedConversation, updatedConversation); + } + + var restoredConversation = RestoreActiveConversation(session, storage, activeConversation, normalizedActive); + return new SessionMutationResult(restoredConversation, updatedConversation); + } + + private static ChatConversation RestoreActiveConversation( + ChatSessionStateService session, + IChatStorageService storage, + ChatConversation? activeConversation, + string normalizedActive) + { + var activeSnapshot = activeConversation; if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) { session.CurrentConversation = activeSnapshot; - return new SessionMutationResult(activeSnapshot, updatedConversation); + return activeSnapshot; } + var previousSessionConversation = session.CurrentConversation; if (previousSessionConversation != null && string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase)) { session.CurrentConversation = previousSessionConversation; - return new SessionMutationResult(previousSessionConversation, updatedConversation); + return previousSessionConversation; } var activeId = session.GetConversationId(normalizedActive); @@ -486,12 +515,20 @@ public sealed class AxAgentExecutionEngine if (restoredConversation != null) { session.CurrentConversation = restoredConversation; - return new SessionMutationResult(restoredConversation, updatedConversation); + return restoredConversation; } var fallbackConversation = session.LoadOrCreateConversation(normalizedActive, storage, GetFallbackSettings()); session.CurrentConversation = fallbackConversation; - return new SessionMutationResult(fallbackConversation, updatedConversation); + return fallbackConversation; + } + + private static bool ConversationMatches(ChatConversation? left, ChatConversation? right) + { + return left != null + && right != null + && !string.IsNullOrWhiteSpace(left.Id) + && string.Equals(left.Id, right.Id, StringComparison.Ordinal); } private static SettingsService GetFallbackSettings() diff --git a/src/AxCopilot/Services/ChatSessionStateService.cs b/src/AxCopilot/Services/ChatSessionStateService.cs index 528d63c..3492861 100644 --- a/src/AxCopilot/Services/ChatSessionStateService.cs +++ b/src/AxCopilot/Services/ChatSessionStateService.cs @@ -419,6 +419,53 @@ public sealed class ChatSessionStateService public ChatConversation AppendExecutionEvent(string tab, Agent.AgentEvent evt, IChatStorageService? storage = null) { var conv = EnsureCurrentConversation(tab); + return AppendExecutionEvent(tab, conv, evt, storage); + } + + public ChatConversation AppendExecutionEvent( + string tab, + ChatConversation conversation, + Agent.AgentEvent evt, + IChatStorageService? storage = null, + bool rememberConversation = true) + { + var conv = PrepareConversationForMutation(conversation, tab); + ApplyExecutionEvent(conv, evt); + TouchConversation(conv, storage, tab, rememberConversation); + return conv; + } + + public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, IChatStorageService? storage = null) + { + var conv = EnsureCurrentConversation(tab); + return AppendAgentRun(tab, conv, evt, status, summary, storage); + } + + public ChatConversation AppendAgentRun( + string tab, + ChatConversation conversation, + Agent.AgentEvent evt, + string status, + string summary, + IChatStorageService? storage = null, + bool rememberConversation = true) + { + var conv = PrepareConversationForMutation(conversation, tab); + ApplyAgentRun(conv, evt, status, summary); + TouchConversation(conv, storage, tab, rememberConversation); + return conv; + } + + private static ChatConversation PrepareConversationForMutation(ChatConversation conversation, string tab) + { + var normalizedTab = NormalizeTab(tab); + conversation.Tab = normalizedTab; + NormalizeLoadedConversation(conversation); + return conversation; + } + + private static void ApplyExecutionEvent(ChatConversation conv, Agent.AgentEvent evt) + { conv.ExecutionEvents ??= new List(); var nextEvent = new ChatExecutionEvent { @@ -461,13 +508,10 @@ public sealed class ChatSessionStateService if (conv.ExecutionEvents.Count > MaxExecutionEventHistory) conv.ExecutionEvents.RemoveRange(0, conv.ExecutionEvents.Count - MaxExecutionEventHistory); - TouchConversation(storage, tab); - return conv; } - public ChatConversation AppendAgentRun(string tab, Agent.AgentEvent evt, string status, string summary, IChatStorageService? storage = null) + private static void ApplyAgentRun(ChatConversation conv, Agent.AgentEvent evt, string status, string summary) { - var conv = EnsureCurrentConversation(tab); conv.AgentRunHistory ??= new List(); var newRecord = new ChatAgentRunRecord { @@ -492,8 +536,6 @@ public sealed class ChatSessionStateService if (conv.AgentRunHistory.Count > MaxAgentRunHistory) conv.AgentRunHistory.RemoveRange(MaxAgentRunHistory, conv.AgentRunHistory.Count - MaxAgentRunHistory); - TouchConversation(storage, tab); - return conv; } public DraftQueueItem? EnqueueDraft(string tab, string text, string priority = "next", IChatStorageService? storage = null, string kind = "message") @@ -812,11 +854,17 @@ public sealed class ChatSessionStateService private void TouchConversation(IChatStorageService? storage, string tab) { var conv = EnsureCurrentConversation(tab); - conv.UpdatedAt = DateTime.Now; - var conversationTab = NormalizeTab(conv.Tab); - if (!string.IsNullOrWhiteSpace(conv.Id)) - RememberConversation(conversationTab, conv.Id); - try { storage?.Save(conv); } catch { } + TouchConversation(conv, storage, tab, rememberConversation: true); + } + + private void TouchConversation(ChatConversation conversation, IChatStorageService? storage, string tab, bool rememberConversation) + { + PrepareConversationForMutation(conversation, tab); + conversation.UpdatedAt = DateTime.Now; + var conversationTab = NormalizeTab(conversation.Tab); + if (rememberConversation && !string.IsNullOrWhiteSpace(conversation.Id)) + RememberConversation(conversationTab, conversation.Id); + try { storage?.Save(conversation); } catch { } } private bool UpdateDraftItem(string tab, string draftId, Func update, IChatStorageService? storage) diff --git a/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs b/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs index 9e97213..e836bfb 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs @@ -85,8 +85,9 @@ public partial class ChatWindow var session = _appState.ChatSession; if (session != null) { + var targetConversation = GetStreamingConversation(eventTab); var result = _chatEngine.AppendExecutionEvent( - session, _storage, _currentConversation, activeTab, eventTab, evt); + session, _storage, _currentConversation, activeTab, eventTab, evt, targetConversation); // 방어: 결과 대화가 빈 대화로 교체되는 것을 방지 // EnsureCurrentConversation이 기존 대화 대신 새 빈 대화를 생성하는 경우 발생 var resultConv = result.CurrentConversation; @@ -126,8 +127,9 @@ public partial class ChatWindow if (session != null) { var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary; + var targetConversation = GetStreamingConversation(eventTab); var result = _chatEngine.AppendAgentRun( - session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary); + session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary, targetConversation); // 방어: Complete 이벤트에서도 대화 교체 보호 var completeMsgCount = result.CurrentConversation?.Messages?.Count ?? 0; var existingMsgCount = _currentConversation?.Messages?.Count ?? 0; @@ -160,8 +162,9 @@ public partial class ChatWindow if (session != null) { var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary; + var targetConversation = GetStreamingConversation(eventTab); var result = _chatEngine.AppendAgentRun( - session, _storage, _currentConversation, activeTab, eventTab, evt, "failed", summary); + session, _storage, _currentConversation, activeTab, eventTab, evt, "failed", summary, targetConversation); _currentConversation = result.CurrentConversation; pendingPersist = result.UpdatedConversation; } @@ -185,7 +188,12 @@ public partial class ChatWindow { _storage.Save(pendingPersist); var rememberTab = pendingPersist.Tab ?? "Cowork"; - _appState.ChatSession?.RememberConversation(rememberTab, pendingPersist.Id); + var session = _appState.ChatSession; + if (session?.CurrentConversation != null + && string.Equals(session.CurrentConversation.Id, pendingPersist.Id, StringComparison.Ordinal)) + { + session.RememberConversation(rememberTab, pendingPersist.Id); + } } catch (Exception ex) { diff --git a/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs index 5f39598..8cfc2c5 100644 --- a/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs @@ -118,8 +118,6 @@ public partial class ChatWindow } // 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋) - StopStreamingIfActive(); - var conv = await _storage.LoadAsync(id); if (conv == null) { @@ -143,6 +141,7 @@ public partial class ChatWindow RenderMessages(); EnsureEmptyStateConsistency(); // EmptyState 일관성 강제 검사 RefreshConversationList(); + RefreshStreamingControlsForActiveTab(); RefreshDraftQueueUi(); } catch (Exception ex) @@ -238,8 +237,6 @@ public partial class ChatWindow } // 스트리밍 중이면 포괄적 정리 (ViewModel.IsStreaming, 글로우, 타이머 등 모두 리셋) - StopStreamingIfActive(); - var conv = _storage.Load(tag.Id); if (conv == null) { @@ -261,6 +258,7 @@ public partial class ChatWindow UpdateChatTitle(); RenderMessages(); RefreshConversationList(); + RefreshStreamingControlsForActiveTab(); RefreshDraftQueueUi(); } catch (Exception ex) diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 713ce3e..4d6e356 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -34,6 +34,7 @@ public partial class ChatWindow : Window private string? _runningDraftId; private readonly HashSet _streamingTabs = []; private readonly Dictionary _tabStreamCts = new(); + private readonly Dictionary _streamingConversations = new(StringComparer.OrdinalIgnoreCase); private bool _isStreaming => _streamingTabs.Count > 0; private bool _sidebarVisible = true; private double _sidebarExpandedWidth = 262; @@ -1535,6 +1536,7 @@ public partial class ChatWindow : Window private void RefreshStreamingControlsForActiveTab() { var isOwningTab = _streamingTabs.Contains(_activeTab); + var hasVisibleStreamingConversation = isOwningTab && IsStreamingConversationVisible(_activeTab); // 실행 중에도 Send 버튼 표시 — 메시지가 큐에 추가됨 BtnSend.IsEnabled = true; @@ -1549,11 +1551,75 @@ public partial class ChatWindow : Window // 스트리밍 탭으로 복귀 시 자동 복원 if (PulseDotBar != null) { - if (!isOwningTab) + if (!hasVisibleStreamingConversation) PulseDotBar.Visibility = Visibility.Collapsed; else if (_streamingTabs.Count > 0) PulseDotBar.Visibility = Visibility.Visible; } + + if (isOwningTab && !hasVisibleStreamingConversation) + { + RemoveAgentLiveCard(animated: false); + HideStreamingStatusBar(); + HideStickyProgress(); + } + else if (hasVisibleStreamingConversation && IsAgentLiveCardEligibleTab(_activeTab)) + { + EnsureAgentLiveCardVisible(_activeTab); + if (_pendingAgentUiEvent != null) + UpdateAgentLiveCardV2(_pendingAgentUiEvent); + } + } + + private void TrackStreamingConversation(string tab, ChatConversation conversation) + { + if (conversation == null) + return; + + var normalizedTab = NormalizeTabName(tab); + lock (_convLock) + _streamingConversations[normalizedTab] = conversation; + } + + private ChatConversation? GetStreamingConversation(string tab) + { + var normalizedTab = NormalizeTabName(tab); + lock (_convLock) + return _streamingConversations.GetValueOrDefault(normalizedTab); + } + + private bool IsStreamingConversationVisible(string tab) + { + var normalizedTab = NormalizeTabName(tab); + if (!string.Equals(normalizedTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + return false; + + lock (_convLock) + { + if (!_streamingConversations.TryGetValue(normalizedTab, out var streamingConversation)) + return false; + + return _currentConversation != null + && string.Equals(_currentConversation.Id, streamingConversation.Id, StringComparison.Ordinal); + } + } + + private void ClearStreamingConversation(string tab, ChatConversation? expectedConversation = null) + { + var normalizedTab = NormalizeTabName(tab); + lock (_convLock) + { + if (!_streamingConversations.TryGetValue(normalizedTab, out var trackedConversation)) + return; + + if (expectedConversation != null + && !string.Equals(trackedConversation.Id, expectedConversation.Id, StringComparison.Ordinal)) + { + return; + } + + _streamingConversations.Remove(normalizedTab); + } } private bool _isUpdatingTab; // UpdateTabUI 재진입 방지 플래그 @@ -1829,11 +1895,23 @@ public partial class ChatWindow : Window Services.LogService.Info($"[SwitchTab] START tab={_activeTab}, emptyState={EmptyState.Visibility}, streaming={_isStreaming}"); - // 현재 활성 탭이 스트리밍 중일 때만 전환 차단 — - // 다른 탭이 백그라운드 스트리밍 중이어도 활성 탭 대화는 정상 로드 - if (_streamingTabs.Contains(_activeTab)) + // 현재 탭에 실행 중인 대화가 있으면 차단하지 않고 해당 대화를 그대로 보여줍니다. + var streamingConversation = GetStreamingConversation(_activeTab); + if (_streamingTabs.Contains(_activeTab) && streamingConversation != null) { - Services.LogService.Info($"[SwitchTab] BLOCKED — 활성 탭 스트리밍 중: activeTab={_activeTab}"); + Services.LogService.Info($"[SwitchTab] STREAMING_CONV path: tab={_activeTab}, convId={streamingConversation.Id[..Math.Min(8, streamingConversation.Id.Length)]}"); + lock (_convLock) + _currentConversation = ChatSession?.SetCurrentConversation(_activeTab, streamingConversation, _storage) ?? streamingConversation; + SyncTabConversationIdsFromSession(); + SaveLastConversations(); + ClearTranscriptElements(); + InvalidateTimelineCache(); + RenderMessages(); + RefreshStreamingControlsForActiveTab(); + UpdateChatTitle(); + RefreshConversationList(); + UpdateFolderBar(); + RefreshDraftQueueUi(); return; } @@ -1855,6 +1933,7 @@ public partial class ChatWindow : Window UpdateChatTitle(); UpdateFolderBar(); RenderMessages(); + RefreshStreamingControlsForActiveTab(); Services.LogService.Info($"[SwitchTab] SAME_CONV done, emptyState={EmptyState.Visibility}"); return; } @@ -1866,6 +1945,7 @@ public partial class ChatWindow : Window ClearTranscriptElements(); InvalidateTimelineCache(); RenderMessages(); + RefreshStreamingControlsForActiveTab(); Services.LogService.Info($"[SwitchTab] DIFF_CONV done, emptyState={EmptyState.Visibility}"); UpdateChatTitle(); RefreshConversationList(); @@ -1886,6 +1966,7 @@ public partial class ChatWindow : Window _attachedFiles.Clear(); RefreshAttachedFilesUI(); UpdateChatTitle(); + RefreshStreamingControlsForActiveTab(); RefreshConversationList(); UpdateFolderBar(); UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true); @@ -3251,6 +3332,7 @@ public partial class ChatWindow : Window UpdateConditionalSkillActivation(reset: true, reloadSkillSources: true); RenderMessages(); RefreshConversationList(); + RefreshStreamingControlsForActiveTab(); BuildTopicButtons(); RefreshDraftQueueUi(); if (_activeTab == "Cowork") BuildBottomBar(); @@ -5704,6 +5786,7 @@ public partial class ChatWindow : Window // 스트리밍 상태를 미리 설정 — PrepareExecution/CondenseAsync 비동기 대기 중 // EmptyState가 다시 Visible로 되돌려지는 것을 방지합니다. // catch 블록에서 ResetStreamingUiState가 호출되므로 예외 시에도 정리됩니다. + TrackStreamingConversation(runTab, conv); _streamingTabs.Add(runTab); ViewModel.IsStreaming = true; Services.LogService.Info($"[SendMsg] 스트리밍 준비 시작: tab={runTab}"); @@ -6014,16 +6097,20 @@ public partial class ChatWindow : Window images); } - private void ResetStreamingUiState(string? finishedTab = null) + private void ResetStreamingUiState( + string? finishedTab = null, + ChatConversation? finishedConversation = null, + string? previewContent = null) { var tab = finishedTab ?? _streamRunTab ?? ""; _streamingTabs.Remove(tab); if (_tabStreamCts.Remove(tab, out var doneCts)) doneCts.Dispose(); _streamRunTab = _streamingTabs.Count > 0 ? _streamingTabs.First() : null; + ClearStreamingConversation(tab, finishedConversation); // Cowork/Code 탭 작업 완료 시, 앱이 포커스 없으면 Windows 알림 - NotifyTaskCompletionIfBackground(tab); + NotifyTaskCompletionIfBackground(tab, finishedConversation, previewContent); // 탭별로 타이머·글로우 등 공유 상태는 마지막 탭 완료 시에만 정리 if (_streamingTabs.Count == 0) @@ -6070,7 +6157,10 @@ public partial class ChatWindow : Window } /// 앱이 포커스를 갖고 있지 않을 때 Cowork/Code 탭 작업 완료를 Windows 알림으로 표시합니다. - private void NotifyTaskCompletionIfBackground(string finishedTab) + private void NotifyTaskCompletionIfBackground( + string finishedTab, + ChatConversation? finishedConversation = null, + string? previewContent = null) { // Chat 탭은 단순 대화이므로 알림 불필요 if (!string.Equals(finishedTab, "Cowork", StringComparison.OrdinalIgnoreCase) @@ -6082,22 +6172,20 @@ public partial class ChatWindow : Window // 대화 제목/미리보기를 알림 본문으로 사용 ChatConversation? conv; - lock (_convLock) conv = _currentConversation; + lock (_convLock) conv = finishedConversation ?? _currentConversation; var title = conv?.Title?.Trim(); if (string.IsNullOrWhiteSpace(title) || string.Equals(title, "새 대화", StringComparison.OrdinalIgnoreCase)) title = finishedTab; - var preview = ""; - if (conv?.Messages?.Count > 0) + var preview = previewContent?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(preview) && conv?.Messages?.Count > 0) { var lastAssistant = conv.Messages.LastOrDefault(m => string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase)); if (lastAssistant != null) - { preview = lastAssistant.Content?.Trim() ?? ""; - if (preview.Length > 120) preview = preview[..120] + "…"; - } } + if (preview.Length > 120) preview = preview[..120] + "…"; var body = string.IsNullOrWhiteSpace(preview) ? $"{finishedTab} 작업이 완료되었습니다." @@ -6197,6 +6285,7 @@ public partial class ChatWindow : Window AxAgentExecutionEngine.PreparedExecution preparedExecution, string busyStatus) { + TrackStreamingConversation(runTab, conversation); _streamingTabs.Add(runTab); ViewModel.IsStreaming = true; _streamRunTab = runTab; @@ -6349,7 +6438,7 @@ public partial class ChatWindow : Window } finally { - ResetStreamingUiState(runTab); + ResetStreamingUiState(runTab, conversation, assistantContent); } lock (_convLock) @@ -6365,8 +6454,6 @@ public partial class ChatWindow : Window responseElapsedMs, assistantMetaRunId, _storage); - _currentConversation = session?.CurrentConversation ?? conversation; - conversation = _currentConversation!; } // Bug fix: 스트리밍 컨테이너가 남아있으면 RenderMessages가 동일 메시지를 다시 렌더링하여 @@ -6712,17 +6799,17 @@ public partial class ChatWindow : Window { TouchLiveAgentProgressHints(); var eventTab = runTab; + var isVisibleRunConversation = IsStreamingConversationVisible(runTab); // V2 라이브 카드 실시간 업데이트 - if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase) - && IsAgentLiveCardEligibleTab(runTab)) + if (isVisibleRunConversation && IsAgentLiveCardEligibleTab(runTab)) { EnsureAgentLiveCardVisible(runTab); UpdateAgentLiveCardV2(evt); } // ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ─────────── - if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + if (isVisibleRunConversation) { if (evt.Type is AgentEventType.Complete or AgentEventType.Error) { @@ -6740,7 +6827,7 @@ public partial class ChatWindow : Window } // 현재 실행 중인 스텝 추적 (통합 진행 카드용) - if (IsProcessFeedEvent(evt) && string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + if (IsProcessFeedEvent(evt) && isVisibleRunConversation) { _currentRunProgressSteps.Add(evt); if (_currentRunProgressSteps.Count > 8) @@ -6750,12 +6837,11 @@ public partial class ChatWindow : Window // ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ────────────────────────── // AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은 // 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다. - var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false; - var isActiveRunTab = string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase); - var lightweightLiveMode = isActiveRunTab + var shouldShowExecutionHistory = isVisibleRunConversation && (_currentConversation?.ShowExecutionHistory ?? false); + var lightweightLiveMode = isVisibleRunConversation && !shouldShowExecutionHistory && IsLightweightLiveProgressMode(eventTab); - var shouldRender = isActiveRunTab && ( + var shouldRender = isVisibleRunConversation && ( shouldShowExecutionHistory || (!lightweightLiveMode && ShouldRenderProgressEventWhenHistoryCollapsed(evt)) || evt.Type == AgentEventType.Complete @@ -6771,11 +6857,12 @@ public partial class ChatWindow : Window { _tabCumulativeInputTokens[runTab] = _tabCumulativeInputTokens.GetValueOrDefault(runTab) + evt.InputTokens; _tabCumulativeOutputTokens[runTab] = _tabCumulativeOutputTokens.GetValueOrDefault(runTab) + evt.OutputTokens; - if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + if (isVisibleRunConversation) UpdateStatusTokens((int)_tabCumulativeInputTokens[runTab], (int)_tabCumulativeOutputTokens[runTab]); } - ScheduleAgentUiEvent(evt); + if (isVisibleRunConversation) + ScheduleAgentUiEvent(evt); if (!lightweightLiveMode || evt.Type is AgentEventType.Complete or AgentEventType.Error