diff --git a/README.md b/README.md index 77b7fa4..2fb254f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # AX Commander +- 업데이트: 2026-04-15 19:31 (KST) +- AX Agent 상단 라이브 안내 카드가 실행 중인데도 사라지던 회귀를 수정했습니다. `src/AxCopilot/Views/ChatWindow.xaml.cs`는 이제 같은 탭에 실행이 살아 있는 동안 상단 안내 카드와 상태 바를 유지하고, 현재 대화가 실행 대화와 다를 때는 본문 실행 이력만 숨긴 채 상단 진행 안내는 계속 보여주도록 분리합니다. +- `src/AxCopilot/Views/ChatStreamingUiPolicy.cs`를 추가해 `숨김 / 현재 대화 / 같은 탭 백그라운드 대화` 상태를 명시적으로 분류하고, `src/AxCopilot.Tests/Views/ChatStreamingUiPolicyTests.cs`로 상단 가이드 유지 정책 회귀 테스트를 고정했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_guide_persistence\\ -p:IntermediateOutputPath=obj\\verify_live_guide_persistence\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatStreamingUiPolicyTests|ChatWindowSlashPolicyTests|ChatSessionStateServiceTests|AxAgentExecutionEngineTests" -p:OutputPath=bin\\verify_live_guide_persistence_tests\\ -p:IntermediateOutputPath=obj\\verify_live_guide_persistence_tests\\` 통과 98 + - 업데이트: 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도 백그라운드 실행 때문에 되돌아가지 않습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3e0e48b..381728e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1511,3 +1511,9 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 테스트는 `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 19:31 (KST) +- AX Agent 상단 라이브 안내 카드 회귀를 수정했습니다. `src/AxCopilot/Views/ChatWindow.xaml.cs`의 `RefreshStreamingControlsForActiveTab()`와 `OnAgentEvent(...)`가 더 이상 `현재 대화가 실행 대화와 정확히 일치하지 않는다`는 이유만으로 상단 라이브 카드와 상태 바를 제거하지 않고, 같은 탭에 실행이 살아 있는 동안에는 상단 안내를 유지하도록 분기했습니다. +- 본문 실행 이력과 상단 진행 안내를 분리했습니다. 같은 탭의 다른 대화를 보고 있을 때는 conversation-bound timeline 렌더만 멈추고, 상단 라이브 카드/펄스 상태/토큰 갱신은 계속 유지되도록 바꿨습니다. +- `src/AxCopilot/Views/ChatStreamingUiPolicy.cs`를 추가해 `Hidden`, `ActiveConversation`, `BackgroundConversation` 세 상태를 명시적으로 분류하고, `src/AxCopilot.Tests/Views/ChatStreamingUiPolicyTests.cs`에 상단 가이드 유지 및 본문 렌더 분리 회귀 테스트를 추가했습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_guide_persistence\\ -p:IntermediateOutputPath=obj\\verify_live_guide_persistence\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatStreamingUiPolicyTests|ChatWindowSlashPolicyTests|ChatSessionStateServiceTests|AxAgentExecutionEngineTests" -p:OutputPath=bin\\verify_live_guide_persistence_tests\\ -p:IntermediateOutputPath=obj\\verify_live_guide_persistence_tests\\` 통과 98 diff --git a/src/AxCopilot.Tests/Views/ChatStreamingUiPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatStreamingUiPolicyTests.cs new file mode 100644 index 0000000..8975e1b --- /dev/null +++ b/src/AxCopilot.Tests/Views/ChatStreamingUiPolicyTests.cs @@ -0,0 +1,54 @@ +using AxCopilot.Views; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Views; + +public class ChatStreamingUiPolicyTests +{ + [Theory] + [InlineData(false, false, nameof(StreamingGuideVisibility.Hidden))] + [InlineData(false, true, nameof(StreamingGuideVisibility.Hidden))] + [InlineData(true, true, nameof(StreamingGuideVisibility.ActiveConversation))] + [InlineData(true, false, nameof(StreamingGuideVisibility.BackgroundConversation))] + public void ResolveGuideVisibility_ShouldClassifyStreamingState( + bool isOwningActiveTab, + bool isVisibleStreamingConversation, + string expectedName) + { + var result = ChatStreamingUiPolicy.ResolveGuideVisibility( + isOwningActiveTab, + isVisibleStreamingConversation); + var expected = Enum.Parse(expectedName); + + result.Should().Be(expected); + } + + [Theory] + [InlineData(nameof(StreamingGuideVisibility.Hidden), false)] + [InlineData(nameof(StreamingGuideVisibility.ActiveConversation), true)] + [InlineData(nameof(StreamingGuideVisibility.BackgroundConversation), true)] + public void ShouldShowTopLevelGuide_ShouldKeepGuideVisibleForBackgroundConversation( + string visibilityName, + bool expected) + { + var visibility = Enum.Parse(visibilityName); + var result = ChatStreamingUiPolicy.ShouldShowTopLevelGuide(visibility); + + result.Should().Be(expected); + } + + [Theory] + [InlineData(nameof(StreamingGuideVisibility.Hidden), false)] + [InlineData(nameof(StreamingGuideVisibility.ActiveConversation), true)] + [InlineData(nameof(StreamingGuideVisibility.BackgroundConversation), false)] + public void ShouldRenderConversationBoundContent_ShouldOnlyRenderVisibleConversationTimeline( + string visibilityName, + bool expected) + { + var visibility = Enum.Parse(visibilityName); + var result = ChatStreamingUiPolicy.ShouldRenderConversationBoundContent(visibility); + + result.Should().Be(expected); + } +} diff --git a/src/AxCopilot/Views/ChatStreamingUiPolicy.cs b/src/AxCopilot/Views/ChatStreamingUiPolicy.cs new file mode 100644 index 0000000..e2f7d72 --- /dev/null +++ b/src/AxCopilot/Views/ChatStreamingUiPolicy.cs @@ -0,0 +1,29 @@ +namespace AxCopilot.Views; + +internal enum StreamingGuideVisibility +{ + Hidden, + ActiveConversation, + BackgroundConversation, +} + +internal static class ChatStreamingUiPolicy +{ + internal static StreamingGuideVisibility ResolveGuideVisibility( + bool isOwningActiveTab, + bool isVisibleStreamingConversation) + { + if (!isOwningActiveTab) + return StreamingGuideVisibility.Hidden; + + return isVisibleStreamingConversation + ? StreamingGuideVisibility.ActiveConversation + : StreamingGuideVisibility.BackgroundConversation; + } + + internal static bool ShouldShowTopLevelGuide(StreamingGuideVisibility visibility) + => visibility != StreamingGuideVisibility.Hidden; + + internal static bool ShouldRenderConversationBoundContent(StreamingGuideVisibility visibility) + => visibility == StreamingGuideVisibility.ActiveConversation; +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 4d6e356..1c4b2b0 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -1536,7 +1536,10 @@ public partial class ChatWindow : Window private void RefreshStreamingControlsForActiveTab() { var isOwningTab = _streamingTabs.Contains(_activeTab); - var hasVisibleStreamingConversation = isOwningTab && IsStreamingConversationVisible(_activeTab); + var guideVisibility = ChatStreamingUiPolicy.ResolveGuideVisibility( + isOwningTab, + isOwningTab && IsStreamingConversationVisible(_activeTab)); + var shouldShowTopLevelGuide = ChatStreamingUiPolicy.ShouldShowTopLevelGuide(guideVisibility); // 실행 중에도 Send 버튼 표시 — 메시지가 큐에 추가됨 BtnSend.IsEnabled = true; @@ -1551,19 +1554,19 @@ public partial class ChatWindow : Window // 스트리밍 탭으로 복귀 시 자동 복원 if (PulseDotBar != null) { - if (!hasVisibleStreamingConversation) + if (!shouldShowTopLevelGuide) PulseDotBar.Visibility = Visibility.Collapsed; else if (_streamingTabs.Count > 0) PulseDotBar.Visibility = Visibility.Visible; } - if (isOwningTab && !hasVisibleStreamingConversation) + if (!shouldShowTopLevelGuide) { RemoveAgentLiveCard(animated: false); HideStreamingStatusBar(); HideStickyProgress(); } - else if (hasVisibleStreamingConversation && IsAgentLiveCardEligibleTab(_activeTab)) + else if (IsAgentLiveCardEligibleTab(_activeTab)) { EnsureAgentLiveCardVisible(_activeTab); if (_pendingAgentUiEvent != null) @@ -6799,24 +6802,32 @@ public partial class ChatWindow : Window { TouchLiveAgentProgressHints(); var eventTab = runTab; - var isVisibleRunConversation = IsStreamingConversationVisible(runTab); + var normalizedRunTab = NormalizeTabName(runTab); + var isOwningActiveTab = string.Equals(normalizedRunTab, _activeTab, StringComparison.OrdinalIgnoreCase) + && _streamingTabs.Contains(normalizedRunTab); + var guideVisibility = ChatStreamingUiPolicy.ResolveGuideVisibility( + isOwningActiveTab, + isOwningActiveTab && IsStreamingConversationVisible(runTab)); + var shouldShowTopLevelGuide = ChatStreamingUiPolicy.ShouldShowTopLevelGuide(guideVisibility); + var shouldRenderConversationBoundContent = + ChatStreamingUiPolicy.ShouldRenderConversationBoundContent(guideVisibility); // V2 라이브 카드 실시간 업데이트 - if (isVisibleRunConversation && IsAgentLiveCardEligibleTab(runTab)) + if (shouldShowTopLevelGuide && IsAgentLiveCardEligibleTab(runTab)) { EnsureAgentLiveCardVisible(runTab); UpdateAgentLiveCardV2(evt); } // ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ─────────── - if (isVisibleRunConversation) + if (shouldShowTopLevelGuide) { if (evt.Type is AgentEventType.Complete or AgentEventType.Error) { HideStreamingStatusBar(); FlushPendingAgentUiEvent(); } - else if (PulseDotBar?.Visibility == Visibility.Visible) + else if (PulseDotBar != null) { var liveStatus = AgentStatusNarrativeCatalog.BuildFromEvent(evt, runTab); UpdateStreamingStatusBar( @@ -6827,7 +6838,7 @@ public partial class ChatWindow : Window } // 현재 실행 중인 스텝 추적 (통합 진행 카드용) - if (IsProcessFeedEvent(evt) && isVisibleRunConversation) + if (IsProcessFeedEvent(evt) && shouldShowTopLevelGuide) { _currentRunProgressSteps.Add(evt); if (_currentRunProgressSteps.Count > 8) @@ -6837,11 +6848,11 @@ public partial class ChatWindow : Window // ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ────────────────────────── // AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은 // 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다. - var shouldShowExecutionHistory = isVisibleRunConversation && (_currentConversation?.ShowExecutionHistory ?? false); - var lightweightLiveMode = isVisibleRunConversation + var shouldShowExecutionHistory = shouldRenderConversationBoundContent && (_currentConversation?.ShowExecutionHistory ?? false); + var lightweightLiveMode = shouldRenderConversationBoundContent && !shouldShowExecutionHistory && IsLightweightLiveProgressMode(eventTab); - var shouldRender = isVisibleRunConversation && ( + var shouldRender = shouldRenderConversationBoundContent && ( shouldShowExecutionHistory || (!lightweightLiveMode && ShouldRenderProgressEventWhenHistoryCollapsed(evt)) || evt.Type == AgentEventType.Complete @@ -6857,11 +6868,11 @@ public partial class ChatWindow : Window { _tabCumulativeInputTokens[runTab] = _tabCumulativeInputTokens.GetValueOrDefault(runTab) + evt.InputTokens; _tabCumulativeOutputTokens[runTab] = _tabCumulativeOutputTokens.GetValueOrDefault(runTab) + evt.OutputTokens; - if (isVisibleRunConversation) + if (shouldShowTopLevelGuide) UpdateStatusTokens((int)_tabCumulativeInputTokens[runTab], (int)_tabCumulativeOutputTokens[runTab]); } - if (isVisibleRunConversation) + if (shouldShowTopLevelGuide) ScheduleAgentUiEvent(evt); if (!lightweightLiveMode || evt.Type is AgentEventType.Complete