AX Agent 상단 라이브 안내 카드 유지 회귀를 수정한다

- 같은 탭에 실행이 살아 있는 동안에는 현재 대화와 실행 대화가 잠깐 어긋나도 상단 라이브 카드와 상태 바를 숨기지 않도록 ChatWindow 스트리밍 분기를 조정함

- ChatStreamingUiPolicy를 추가해 Hidden/ActiveConversation/BackgroundConversation 상태를 분리하고 본문 실행 이력 렌더와 상단 진행 안내 표시를 분리함

- ChatStreamingUiPolicyTests를 추가하고 README.md, docs/DEVELOPMENT.md 이력을 갱신함

- 검증: 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)
This commit is contained in:
2026-04-15 19:35:24 +09:00
parent f173e2a63b
commit 8721a0d8c7
5 changed files with 120 additions and 14 deletions

View File

@@ -1,5 +1,11 @@
# AX Commander # 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) - 업데이트: 2026-04-15 19:21 (KST)
- AX Agent 실행이 이제 같은 탭 안에서 다른 대화로 이동하거나 새 대화를 시작해도 원래 대화에 계속 귀속됩니다. `src/AxCopilot/Views/ChatWindow.ConversationListPresentation.cs`에서 대화 전환 시 즉시 취소하던 흐름을 제거했고, `src/AxCopilot/Views/ChatWindow.xaml.cs`는 실행 중인 대화를 탭별로 추적해 탭 복귀 시 진행 중이던 대화를 다시 보여주도록 정리했습니다. - 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/Views/ChatWindow.AgentEventProcessor.cs`, `src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs`, `src/AxCopilot/Services/ChatSessionStateService.cs`는 에이전트 이벤트와 완료 기록을 현재 화면의 `_currentConversation`이 아니라 실행이 시작된 원래 대화에 누적하도록 보강했습니다. 같은 탭에서 다른 대화를 보고 있어도 실행 로그와 완료 요약이 새 대화에 섞이지 않고, 선택 중인 대화 ID도 백그라운드 실행 때문에 되돌아가지 않습니다.

View File

@@ -1511,3 +1511,9 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
- 테스트는 `src/AxCopilot.Tests/Services/ChatSessionStateServiceTests.cs`, `src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs`에 같은 탭 백그라운드 실행 귀속 회귀 케이스를 추가했습니다. - 테스트는 `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 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 - 검증: `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

View File

@@ -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<StreamingGuideVisibility>(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<StreamingGuideVisibility>(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<StreamingGuideVisibility>(visibilityName);
var result = ChatStreamingUiPolicy.ShouldRenderConversationBoundContent(visibility);
result.Should().Be(expected);
}
}

View File

@@ -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;
}

View File

@@ -1536,7 +1536,10 @@ public partial class ChatWindow : Window
private void RefreshStreamingControlsForActiveTab() private void RefreshStreamingControlsForActiveTab()
{ {
var isOwningTab = _streamingTabs.Contains(_activeTab); var isOwningTab = _streamingTabs.Contains(_activeTab);
var hasVisibleStreamingConversation = isOwningTab && IsStreamingConversationVisible(_activeTab); var guideVisibility = ChatStreamingUiPolicy.ResolveGuideVisibility(
isOwningTab,
isOwningTab && IsStreamingConversationVisible(_activeTab));
var shouldShowTopLevelGuide = ChatStreamingUiPolicy.ShouldShowTopLevelGuide(guideVisibility);
// 실행 중에도 Send 버튼 표시 — 메시지가 큐에 추가됨 // 실행 중에도 Send 버튼 표시 — 메시지가 큐에 추가됨
BtnSend.IsEnabled = true; BtnSend.IsEnabled = true;
@@ -1551,19 +1554,19 @@ public partial class ChatWindow : Window
// 스트리밍 탭으로 복귀 시 자동 복원 // 스트리밍 탭으로 복귀 시 자동 복원
if (PulseDotBar != null) if (PulseDotBar != null)
{ {
if (!hasVisibleStreamingConversation) if (!shouldShowTopLevelGuide)
PulseDotBar.Visibility = Visibility.Collapsed; PulseDotBar.Visibility = Visibility.Collapsed;
else if (_streamingTabs.Count > 0) else if (_streamingTabs.Count > 0)
PulseDotBar.Visibility = Visibility.Visible; PulseDotBar.Visibility = Visibility.Visible;
} }
if (isOwningTab && !hasVisibleStreamingConversation) if (!shouldShowTopLevelGuide)
{ {
RemoveAgentLiveCard(animated: false); RemoveAgentLiveCard(animated: false);
HideStreamingStatusBar(); HideStreamingStatusBar();
HideStickyProgress(); HideStickyProgress();
} }
else if (hasVisibleStreamingConversation && IsAgentLiveCardEligibleTab(_activeTab)) else if (IsAgentLiveCardEligibleTab(_activeTab))
{ {
EnsureAgentLiveCardVisible(_activeTab); EnsureAgentLiveCardVisible(_activeTab);
if (_pendingAgentUiEvent != null) if (_pendingAgentUiEvent != null)
@@ -6799,24 +6802,32 @@ public partial class ChatWindow : Window
{ {
TouchLiveAgentProgressHints(); TouchLiveAgentProgressHints();
var eventTab = runTab; 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 라이브 카드 실시간 업데이트 // V2 라이브 카드 실시간 업데이트
if (isVisibleRunConversation && IsAgentLiveCardEligibleTab(runTab)) if (shouldShowTopLevelGuide && IsAgentLiveCardEligibleTab(runTab))
{ {
EnsureAgentLiveCardVisible(runTab); EnsureAgentLiveCardVisible(runTab);
UpdateAgentLiveCardV2(evt); UpdateAgentLiveCardV2(evt);
} }
// ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ─────────── // ── 1단계: 경량 UI 피드백 (PulseDotBar 상태 텍스트만 갱신) ───────────
if (isVisibleRunConversation) if (shouldShowTopLevelGuide)
{ {
if (evt.Type is AgentEventType.Complete or AgentEventType.Error) if (evt.Type is AgentEventType.Complete or AgentEventType.Error)
{ {
HideStreamingStatusBar(); HideStreamingStatusBar();
FlushPendingAgentUiEvent(); FlushPendingAgentUiEvent();
} }
else if (PulseDotBar?.Visibility == Visibility.Visible) else if (PulseDotBar != null)
{ {
var liveStatus = AgentStatusNarrativeCatalog.BuildFromEvent(evt, runTab); var liveStatus = AgentStatusNarrativeCatalog.BuildFromEvent(evt, runTab);
UpdateStreamingStatusBar( UpdateStreamingStatusBar(
@@ -6827,7 +6838,7 @@ public partial class ChatWindow : Window
} }
// 현재 실행 중인 스텝 추적 (통합 진행 카드용) // 현재 실행 중인 스텝 추적 (통합 진행 카드용)
if (IsProcessFeedEvent(evt) && isVisibleRunConversation) if (IsProcessFeedEvent(evt) && shouldShowTopLevelGuide)
{ {
_currentRunProgressSteps.Add(evt); _currentRunProgressSteps.Add(evt);
if (_currentRunProgressSteps.Count > 8) if (_currentRunProgressSteps.Count > 8)
@@ -6837,11 +6848,11 @@ public partial class ChatWindow : Window
// ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ────────────────────────── // ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ──────────────────────────
// AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은 // AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은
// 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다. // 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다.
var shouldShowExecutionHistory = isVisibleRunConversation && (_currentConversation?.ShowExecutionHistory ?? false); var shouldShowExecutionHistory = shouldRenderConversationBoundContent && (_currentConversation?.ShowExecutionHistory ?? false);
var lightweightLiveMode = isVisibleRunConversation var lightweightLiveMode = shouldRenderConversationBoundContent
&& !shouldShowExecutionHistory && !shouldShowExecutionHistory
&& IsLightweightLiveProgressMode(eventTab); && IsLightweightLiveProgressMode(eventTab);
var shouldRender = isVisibleRunConversation && ( var shouldRender = shouldRenderConversationBoundContent && (
shouldShowExecutionHistory shouldShowExecutionHistory
|| (!lightweightLiveMode && ShouldRenderProgressEventWhenHistoryCollapsed(evt)) || (!lightweightLiveMode && ShouldRenderProgressEventWhenHistoryCollapsed(evt))
|| evt.Type == AgentEventType.Complete || evt.Type == AgentEventType.Complete
@@ -6857,11 +6868,11 @@ public partial class ChatWindow : Window
{ {
_tabCumulativeInputTokens[runTab] = _tabCumulativeInputTokens.GetValueOrDefault(runTab) + evt.InputTokens; _tabCumulativeInputTokens[runTab] = _tabCumulativeInputTokens.GetValueOrDefault(runTab) + evt.InputTokens;
_tabCumulativeOutputTokens[runTab] = _tabCumulativeOutputTokens.GetValueOrDefault(runTab) + evt.OutputTokens; _tabCumulativeOutputTokens[runTab] = _tabCumulativeOutputTokens.GetValueOrDefault(runTab) + evt.OutputTokens;
if (isVisibleRunConversation) if (shouldShowTopLevelGuide)
UpdateStatusTokens((int)_tabCumulativeInputTokens[runTab], (int)_tabCumulativeOutputTokens[runTab]); UpdateStatusTokens((int)_tabCumulativeInputTokens[runTab], (int)_tabCumulativeOutputTokens[runTab]);
} }
if (isVisibleRunConversation) if (shouldShowTopLevelGuide)
ScheduleAgentUiEvent(evt); ScheduleAgentUiEvent(evt);
if (!lightweightLiveMode if (!lightweightLiveMode
|| evt.Type is AgentEventType.Complete || evt.Type is AgentEventType.Complete