같은 탭 대화 전환 중에도 AX Agent 실행이 계속되도록 수정

- 실행 시작 대화를 탭별로 추적해 같은 탭에서 다른 대화로 이동하거나 새 대화를 시작해도 원래 대화에 이벤트와 완료 결과를 저장하도록 정리함
- 탭 복귀 시 진행 중인 대화를 다시 로드하고 백그라운드 실행 저장이 현재 선택 대화 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)
This commit is contained in:
2026-04-15 19:24:40 +09:00
parent 913b42b2f3
commit f173e2a63b
9 changed files with 353 additions and 60 deletions

View File

@@ -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`)은 계속 드래그 선택/복사할 수 있게 유지했습니다.

View File

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

View File

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

View File

@@ -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()
{

View File

@@ -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<string, ChatConversation> mutate)
ChatConversation? targetConversation,
Func<string, bool, ChatConversation> 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()

View File

@@ -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<ChatExecutionEvent>();
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<ChatAgentRunRecord>();
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<DraftQueueItem, bool> update, IChatStorageService? storage)

View File

@@ -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)
{

View File

@@ -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)

View File

@@ -34,6 +34,7 @@ public partial class ChatWindow : Window
private string? _runningDraftId;
private readonly HashSet<string> _streamingTabs = [];
private readonly Dictionary<string, CancellationTokenSource> _tabStreamCts = new();
private readonly Dictionary<string, ChatConversation> _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
}
/// <summary>앱이 포커스를 갖고 있지 않을 때 Cowork/Code 탭 작업 완료를 Windows 알림으로 표시합니다.</summary>
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