같은 탭 대화 전환 중에도 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:
@@ -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`)은 계속 드래그 선택/복사할 수 있게 유지했습니다.
|
||||
|
||||
@@ -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
|
||||
|
||||
59
src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs
Normal file
59
src/AxCopilot.Tests/Services/AxAgentExecutionEngineTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user