코워크·코드 실행 중 UI 멈춤 체감 완화

에이전트 이벤트를 UI 스레드에서 과하게 즉시 처리하던 경로를 정리해 코워크·코드 실행 중 입력과 렌더가 먼저 흐르도록 조정했다.

- ChatWindow agent dispatcher 우선순위를 Background로 낮춰 이벤트 폭주 시 UI 응답성을 확보

- OnAgentEvent 즉시 갱신을 완료/오류 중심으로 축소하고 나머지는 배치 타이머 경로로 이관

- ChatWindow.AgentEventProcessor에서 execution event 중복 append를 제거해 대화 히스토리 반영을 백그라운드 1회 처리로 단순화

- README.md, docs/DEVELOPMENT.md에 2026-04-10 09:03 (KST) 기준 작업 이력 반영

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0, 오류 0
This commit is contained in:
2026-04-10 09:08:14 +09:00
parent 5511c620de
commit 9175dfe657
4 changed files with 204 additions and 8638 deletions

View File

@@ -1608,3 +1608,8 @@ MIT License
- [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다. - [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다.
- [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다. - [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다.
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다. - [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다.
- 업데이트: 2026-04-10 09:03 (KST)
- Cowork/Code 실행 중 앱 화면이 끝날 때까지 멈춰 보이던 현상을 줄이기 위해, 에이전트 이벤트의 UI 전달 우선순위와 처리 경로를 다시 정리했습니다.
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 agent dispatcher는 `DispatcherPriority.Normal` 대신 `Background`를 사용해 입력/렌더가 먼저 흐르도록 조정했습니다.
- 같은 파일의 `OnAgentEvent()`는 이벤트마다 라이브 카드와 상태 서브아이템을 즉시 갱신하지 않고, 완료/오류 같은 종료 신호만 즉시 처리한 뒤 나머지는 기존 배치 타이머로 넘기도록 단순화했습니다.
- [ChatWindow.AgentEventProcessor.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs)는 execution event를 UI 스레드와 백그라운드에서 두 번 append하던 구조를 제거하고, 백그라운드 단일 리더에서 한 번만 대화 히스토리를 반영하도록 바꿨습니다.

View File

@@ -555,3 +555,16 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript
- `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs` - `src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs`
- performance log detail??`processFeedAppends`, `processFeedMerges`瑜?異붽???grouped activity row???④낵瑜??ㅼ궗??濡쒓렇?먯꽌 ?뺤씤?????덇쾶 ?덉뒿?덈떎. - performance log detail??`processFeedAppends`, `processFeedMerges`瑜?異붽???grouped activity row???④낵瑜??ㅼ궗??濡쒓렇?먯꽌 ?뺤씤?????덇쾶 ?덉뒿?덈떎.
## Cowork/Code 실행 중 UI 멈춤 완화 (2026-04-10 09:03 KST)
- `claude-code`처럼 실행 이벤트를 얇게 소비하도록, AX Agent의 Cowork/Code UI 이벤트 경로를 다시 줄였습니다.
- `src/AxCopilot/Views/ChatWindow.xaml.cs`
- agent loop dispatcher 우선순위를 `DispatcherPriority.Normal`에서 `DispatcherPriority.Background`로 낮춰, 대량의 에이전트 이벤트가 들어와도 사용자 입력과 렌더가 먼저 처리되도록 바꿨습니다.
- `OnAgentEvent()`는 이제 완료/오류 같은 종료 이벤트만 즉시 처리하고, ToolCall/ToolResult/Thinking의 라이브 카드 갱신은 기존 `_agentUiEventTimer` 기반 배치 경로로 넘깁니다.
- `src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs`
- execution event를 UI 스레드에서 즉시 append한 뒤 백그라운드에서 다시 append하던 중복 경로를 제거했습니다.
- 대화 히스토리 반영, agent run 기록, 저장은 백그라운드 단일 리더에서 한 번만 처리하도록 정리해 Cowork/Code 실행 중 UI 점유를 줄였습니다.
- 기대 효과
- 긴 tool/thinking 이벤트가 연속으로 들어와도 창 전체가 끝날 때까지 얼어붙는 느낌이 줄어듭니다.
- transcript 저장과 실행 이력 반영이 한 번만 일어나, 실행 길이가 길수록 커지던 UI 스레드 부담이 완화됩니다.

View File

@@ -8,12 +8,12 @@ using AxCopilot.Services.Agent;
namespace AxCopilot.Views; namespace AxCopilot.Views;
/// <summary> /// <summary>
/// 에이전트 이벤트의 무거운 작업(대화 변이·저장)을 백그라운드 스레드에서 처리합니다. /// 에이전트 이벤트의 무거운 작업(대화 반영, 저장, 실행 이력 기록)을
/// UI 스레드는 시각적 피드백만 담당하여 채팅창 멈춤을 방지합니다. /// 백그라운드 단일 리더에서 배치 처리해 UI 스레드 점유를 줄입니다.
/// </summary> /// </summary>
public partial class ChatWindow public partial class ChatWindow
{ {
/// <summary>백그라운드 처리 대상 이벤트 큐 아이템.</summary> /// <summary>백그라운드 처리 대상 에이전트 이벤트 단위입니다.</summary>
private readonly record struct AgentEventWorkItem( private readonly record struct AgentEventWorkItem(
AgentEvent Event, AgentEvent Event,
string EventTab, string EventTab,
@@ -26,50 +26,31 @@ public partial class ChatWindow
private Task? _agentEventProcessorTask; private Task? _agentEventProcessorTask;
/// <summary>백그라운드 이벤트 프로세서를 시작합니다. 창 초기화 시 한 번 호출됩니다.</summary> /// <summary>백그라운드 이벤트 프로세서를 시작합니다.</summary>
private void StartAgentEventProcessor() private void StartAgentEventProcessor()
{ {
_agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync); _agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync);
} }
/// <summary>백그라운드 이벤트 프로세서를 종료합니다. 창 닫기 시 호출됩니다.</summary> /// <summary>백그라운드 이벤트 프로세서를 종료합니다.</summary>
private void StopAgentEventProcessor() private void StopAgentEventProcessor()
{ {
_agentEventChannel.Writer.TryComplete(); _agentEventChannel.Writer.TryComplete();
// 프로세서 완료를 동기 대기하지 않음 — 데드락 방지 // 종료 대기는 생략한다. 남은 정리는 GC와 종료 루틴에 맡긴다.
// GC가 나머지를 정리합니다.
} }
/// <summary> /// <summary>
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다. /// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
/// ExecutionEvents는 UI 스레드에서 즉시 추가 (타임라인 렌더링 누락 방지). /// 대화 히스토리 반영과 저장은 프로세서에서 한 번만 수행합니다.
/// 디스크 저장과 AgentRun 기록은 백그라운드에서 처리합니다.
/// </summary> /// </summary>
private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender) private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender)
{ {
// ── 즉시 추가: ExecutionEvents에 동기적으로 반영 (RenderMessages 누락 방지) ──
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var result = _chatEngine.AppendExecutionEvent(
session, null!, _currentConversation, _activeTab, eventTab, evt);
_currentConversation = result.CurrentConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"UI 스레드 이벤트 즉시 추가 실패: {ex.Message}");
}
_agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender)); _agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender));
} }
/// <summary>백그라운드 전용: 채널에서 이벤트를 배치로 읽어 대화 변이 + 저장을 수행합니다.</summary> /// <summary>
/// 백그라운드 전용: 채널에 쌓인 이벤트를 배치로 읽어 대화 반영과 저장을 수행합니다.
/// </summary>
private async Task ProcessAgentEventsAsync() private async Task ProcessAgentEventsAsync()
{ {
var reader = _agentEventChannel.Reader; var reader = _agentEventChannel.Reader;
@@ -97,7 +78,6 @@ public partial class ChatWindow
var eventTab = work.EventTab; var eventTab = work.EventTab;
var activeTab = work.ActiveTab; var activeTab = work.ActiveTab;
// ── 대화 변이: execution event 추가 ──
try try
{ {
lock (_convLock) lock (_convLock)
@@ -117,7 +97,6 @@ public partial class ChatWindow
LogService.Debug($"백그라운드 이벤트 처리 오류 (execution): {ex.Message}"); LogService.Debug($"백그라운드 이벤트 처리 오류 (execution): {ex.Message}");
} }
// ── 대화 변이: agent run 추가 (Complete/Error) ──
if (evt.Type == AgentEventType.Complete) if (evt.Type == AgentEventType.Complete)
{ {
try try
@@ -139,6 +118,7 @@ public partial class ChatWindow
{ {
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run complete): {ex.Message}"); LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run complete): {ex.Message}");
} }
hasTerminalEvent = true; hasTerminalEvent = true;
} }
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName)) else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
@@ -162,6 +142,7 @@ public partial class ChatWindow
{ {
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run error): {ex.Message}"); LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run error): {ex.Message}");
} }
hasTerminalEvent = true; hasTerminalEvent = true;
} }
@@ -169,7 +150,6 @@ public partial class ChatWindow
anyNeedsRender = true; anyNeedsRender = true;
} }
// ── 디바운스 저장: 2초마다 또는 종료 이벤트 시 즉시 ──
if (pendingPersist != null && (hasTerminalEvent || persistStopwatch.ElapsedMilliseconds > 2000)) if (pendingPersist != null && (hasTerminalEvent || persistStopwatch.ElapsedMilliseconds > 2000))
{ {
try try
@@ -182,11 +162,11 @@ public partial class ChatWindow
{ {
LogService.Debug($"백그라운드 대화 저장 실패: {ex.Message}"); LogService.Debug($"백그라운드 대화 저장 실패: {ex.Message}");
} }
pendingPersist = null; pendingPersist = null;
persistStopwatch.Restart(); persistStopwatch.Restart();
} }
// ── UI 새로고침 신호: 배치 전체에 대해 단 1회 ──
if (anyNeedsRender) if (anyNeedsRender)
{ {
try try
@@ -195,7 +175,10 @@ public partial class ChatWindow
() => ScheduleExecutionHistoryRender(autoScroll: true), () => ScheduleExecutionHistoryRender(autoScroll: true),
DispatcherPriority.Background); DispatcherPriority.Background);
} }
catch { /* 앱 종료 중 무시 */ } catch
{
// 창 종료 중에는 무시한다.
}
} }
} }
} }
@@ -206,10 +189,16 @@ public partial class ChatWindow
LogService.Debug($"에이전트 이벤트 프로세서 종료: {ex.Message}"); LogService.Debug($"에이전트 이벤트 프로세서 종료: {ex.Message}");
} }
// ── 종료 시 미저장 대화 플러시 ──
if (pendingPersist != null) if (pendingPersist != null)
{ {
try { _storage.Save(pendingPersist); } catch { } try
{
_storage.Save(pendingPersist);
}
catch
{
// 종료 중 마지막 저장 실패는 무시한다.
}
} }
} }
} }

File diff suppressed because it is too large Load Diff