AX Agent 진행 시간·글로우 경로 정리 및 최근 로컬 변경 일괄 반영

- AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함

- 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함

- README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함
This commit is contained in:
2026-04-08 23:20:53 +09:00
parent 6e99837a4c
commit 1b4a2bfb1c
24 changed files with 1103 additions and 173 deletions

View File

@@ -72,6 +72,8 @@ public partial class ChatWindow : Window
private bool _cursorVisible = true;
private TextBlock? _activeStreamText;
private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지
private readonly char[] _streamDisplayBuffer = new char[256 * 1024]; // 256KB 재사용 버퍼 (타이핑 표시용)
private int _streamDisplayBufferLen; // 버퍼에 기록된 실제 길이
private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
private bool _aiIconPulseStopped; // 펄스 1회만 중지
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
@@ -85,6 +87,13 @@ public partial class ChatWindow : Window
private long _lastScrollTick; // 스크롤 스로틀링용 (Environment.TickCount64)
// 메시지 버블 캐시: messageId → 렌더링된 UIElement (재생성 방지)
private readonly Dictionary<string, UIElement> _elementCache = new(StringComparer.Ordinal);
// 증분 렌더링: 이전 렌더링의 타임라인 키 목록 (변경 감지용)
private List<string> _lastRenderedTimelineKeys = new();
private int _lastRenderedHiddenCount;
// 스트리밍 중 불필요한 재렌더링 방지용 카운터
private int _lastRenderedMessageCount;
private int _lastRenderedEventCount;
private bool _lastRenderedShowHistory;
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
@@ -206,6 +215,8 @@ public partial class ChatWindow : Window
foreach (var tab in new[] { "Chat", "Cowork", "Code" })
_agentLoops[tab] = CreateAgentLoopForTab(tab, settings);
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
// 에이전트 이벤트 백그라운드 프로세서 시작 (대화 변이·저장을 UI 스레드에서 분리)
StartAgentEventProcessor();
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
@@ -217,7 +228,7 @@ public partial class ChatWindow : Window
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_elapsedTimer.Tick += ElapsedTimer_Tick;
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) };
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80) };
_typingTimer.Tick += TypingTimer_Tick;
_gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) };
_gitRefreshTimer.Tick += async (_, _) =>
@@ -242,6 +253,10 @@ public partial class ChatWindow : Window
_executionHistoryRenderTimer.Tick += (_, _) =>
{
_executionHistoryRenderTimer.Stop();
// 스트리밍 중에는 전체 재렌더링 빈도를 줄여 UI 부하 감소
_executionHistoryRenderTimer.Interval = _isStreaming
? TimeSpan.FromMilliseconds(1500)
: TimeSpan.FromMilliseconds(350);
RenderMessages(preserveViewport: true);
if (_pendingExecutionHistoryAutoScroll)
AutoScrollIfNeeded();
@@ -251,6 +266,9 @@ public partial class ChatWindow : Window
_taskSummaryRefreshTimer.Tick += (_, _) =>
{
_taskSummaryRefreshTimer.Stop();
_taskSummaryRefreshTimer.Interval = _isStreaming
? TimeSpan.FromMilliseconds(800)
: TimeSpan.FromMilliseconds(120);
UpdateTaskSummaryIndicators();
};
_conversationPersistTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) };
@@ -263,6 +281,9 @@ public partial class ChatWindow : Window
_agentUiEventTimer.Tick += (_, _) =>
{
_agentUiEventTimer.Stop();
_agentUiEventTimer.Interval = _isStreaming
? TimeSpan.FromMilliseconds(300)
: TimeSpan.FromMilliseconds(140);
FlushPendingAgentUiEvent();
};
_agentProgressHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
@@ -277,6 +298,12 @@ public partial class ChatWindow : Window
_responsiveLayoutTimer.Tick += (_, _) =>
{
_responsiveLayoutTimer.Stop();
// 스트리밍 중 전체 메시지 재렌더링은 UI 부하가 크므로 연기
if (_isStreaming)
{
_pendingResponsiveLayoutRefresh = true;
return;
}
UpdateTopicPresetScrollMode();
if (UpdateResponsiveChatLayout())
RenderMessages(preserveViewport: true);
@@ -541,6 +568,9 @@ public partial class ChatWindow : Window
/// <summary>앱 종료 시 창을 실제로 닫습니다.</summary>
public void ForceClose()
{
// 백그라운드 이벤트 프로세서 종료 (미저장 대화 플러시됨)
StopAgentEventProcessor();
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
lock (_convLock)
{
@@ -616,8 +646,8 @@ public partial class ChatWindow : Window
var currentOffset = MessageScroll.VerticalOffset;
var diff = targetOffset - currentOffset;
// 차이가 작으면 즉시 이동 (깜빡임 방지)
if (diff <= 60)
// 스트리밍 중이거나 차이가 작으면 즉시 이동 (UI 부하 최소화)
if (diff <= 60 || _isStreaming)
{
MessageScroll.ScrollToEnd();
return;
@@ -1122,6 +1152,16 @@ public partial class ChatWindow : Window
return IntPtr.Zero;
}
// 드래그/리사이즈 중 일시 정지할 타이머 목록
private DispatcherTimer[] GetSuspendableTimers() => new[]
{
_cursorTimer, _elapsedTimer, _typingTimer, _gitRefreshTimer,
_conversationSearchTimer, _inputUiRefreshTimer, _executionHistoryRenderTimer,
_taskSummaryRefreshTimer, _conversationPersistTimer, _agentUiEventTimer,
_agentProgressHintTimer, _tokenUsagePopupCloseTimer, _responsiveLayoutTimer,
};
private readonly List<DispatcherTimer> _timersRunningBeforeMove = new();
private void BeginWindowMoveSizeLoop()
{
if (_isInWindowMoveSizeLoop)
@@ -1130,6 +1170,21 @@ public partial class ChatWindow : Window
_isInWindowMoveSizeLoop = true;
_pendingResponsiveLayoutRefresh = false;
// 비필수 타이머 일시 정지 → 드래그 중 UI 부하 최소화
_timersRunningBeforeMove.Clear();
foreach (var t in GetSuspendableTimers())
{
if (t.IsEnabled)
{
_timersRunningBeforeMove.Add(t);
t.Stop();
}
}
// Storyboard 일시 정지
_pulseDotStoryboard?.Pause();
_statusDiamondStoryboard?.Pause();
if (Content is UIElement rootElement)
{
_cachedRootCacheModeBeforeMove = rootElement.CacheMode;
@@ -1149,6 +1204,15 @@ public partial class ChatWindow : Window
_cachedRootCacheModeBeforeMove = null;
// 타이머 복원
foreach (var t in _timersRunningBeforeMove)
t.Start();
_timersRunningBeforeMove.Clear();
// Storyboard 복원
_pulseDotStoryboard?.Resume();
_statusDiamondStoryboard?.Resume();
if (_pendingResponsiveLayoutRefresh)
{
_pendingResponsiveLayoutRefresh = false;
@@ -1201,6 +1265,7 @@ public partial class ChatWindow : Window
_cachedStreamContent = "";
_streamingTabs.Clear();
_streamRunTab = null;
_streamStartTime = default;
BtnSend.IsEnabled = true;
BtnStop.Visibility = Visibility.Collapsed;
BtnPause.Visibility = Visibility.Collapsed;
@@ -2198,9 +2263,6 @@ public partial class ChatWindow : Window
var previousScrollableHeight = MessageScroll?.ScrollableHeight ?? 0;
var previousVerticalOffset = MessageScroll?.VerticalOffset ?? 0;
MessagePanel.Children.Clear();
_runBannerAnchors.Clear();
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
@@ -2208,8 +2270,21 @@ public partial class ChatWindow : Window
var visibleMessages = GetVisibleTimelineMessages(conv);
var visibleEvents = GetVisibleTimelineEvents(conv);
// 스트리밍 중 메시지·이벤트 수가 동일하면 불필요한 재렌더링 스킵
if (_isStreaming && preserveViewport
&& visibleMessages.Count == _lastRenderedMessageCount
&& visibleEvents.Count == _lastRenderedEventCount
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
return;
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
{
MessagePanel.Children.Clear();
_runBannerAnchors.Clear();
_lastRenderedTimelineKeys.Clear();
_lastRenderedMessageCount = 0;
_lastRenderedEventCount = 0;
EmptyState.Visibility = Visibility.Visible;
return;
}
@@ -2218,19 +2293,113 @@ public partial class ChatWindow : Window
{
_lastRenderedConversationId = conv.Id;
_timelineRenderLimit = TimelineRenderPageSize;
_elementCache.Clear(); // 대화 전환 시 버블 캐시 초기화
_elementCache.Clear();
_lastRenderedTimelineKeys.Clear();
_lastRenderedMessageCount = 0;
_lastRenderedEventCount = 0;
InvalidateTimelineCache();
}
var showHistory = conv.ShowExecutionHistory;
EmptyState.Visibility = Visibility.Collapsed;
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
var hiddenCount = Math.Max(0, orderedTimeline.Count - _timelineRenderLimit);
if (hiddenCount > 0)
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
var visibleTimeline = hiddenCount > 0
? orderedTimeline.GetRange(hiddenCount, orderedTimeline.Count - hiddenCount)
: orderedTimeline;
var newKeys = new List<string>(visibleTimeline.Count);
foreach (var t in visibleTimeline) newKeys.Add(t.Key);
foreach (var item in orderedTimeline.Skip(hiddenCount))
item.Render();
var incremented = false;
// ── 증분 렌더링: 이전 키 목록과 비교하여 변경 최소화 ──
// agent live card가 존재하면 Children과 키 불일치 가능 → 전체 재빌드
var hasExternalChildren = _agentLiveContainer != null && MessagePanel.Children.Contains(_agentLiveContainer);
var expectedChildCount = _lastRenderedTimelineKeys.Count + (_lastRenderedHiddenCount > 0 ? 1 : 0);
var canIncremental = !hasExternalChildren
&& _lastRenderedTimelineKeys.Count > 0
&& newKeys.Count >= _lastRenderedTimelineKeys.Count
&& _lastRenderedHiddenCount == hiddenCount
&& MessagePanel.Children.Count == expectedChildCount;
if (canIncremental)
{
// _live_ 키 개수를 한 번만 계산 (이전 키 목록에서)
var prevLiveCount = 0;
for (int i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--)
{
if (_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal))
prevLiveCount++;
else
break; // live 키는 항상 끝에 연속으로 위치
}
var prevStableCount = _lastRenderedTimelineKeys.Count - prevLiveCount;
// 안정 키(non-live) 접두사가 일치하는지 확인
var prefixMatch = true;
for (int i = 0; i < prevStableCount; i++)
{
if (i >= newKeys.Count || !string.Equals(_lastRenderedTimelineKeys[i], newKeys[i], StringComparison.Ordinal))
{
prefixMatch = false;
break;
}
}
if (prefixMatch)
{
try
{
// 이전 live 요소를 Children 끝에서 제거
for (int r = 0; r < prevLiveCount && MessagePanel.Children.Count > 0; r++)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 안정 접두사 이후의 새 아이템 렌더링 (새 stable + 새 live 포함)
for (int i = prevStableCount; i < visibleTimeline.Count; i++)
visibleTimeline[i].Render();
_lastRenderedTimelineKeys = newKeys;
_lastRenderedHiddenCount = hiddenCount;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = showHistory;
incremented = true;
}
catch (Exception ex)
{
LogService.Warn($"증분 렌더링 실패, 전체 재빌드로 전환: {ex.Message}");
_lastRenderedTimelineKeys.Clear();
incremented = false;
}
}
}
if (!incremented)
{
// ── 전체 재빌드 (대화 전환, 접기/펴기, 중간 변경 등) ──
MessagePanel.Children.Clear();
_runBannerAnchors.Clear();
if (hiddenCount > 0)
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
foreach (var item in visibleTimeline)
item.Render();
// 전체 재빌드로 Children이 초기화되었으므로 agent live card 복원
if (_agentLiveContainer != null && !MessagePanel.Children.Contains(_agentLiveContainer))
MessagePanel.Children.Add(_agentLiveContainer);
_lastRenderedTimelineKeys = newKeys;
_lastRenderedHiddenCount = hiddenCount;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = showHistory;
}
// ── 스크롤 처리 ──
if (!preserveViewport)
{
_ = Dispatcher.InvokeAsync(() =>
@@ -2323,20 +2492,20 @@ public partial class ChatWindow : Window
private void CursorTimer_Tick(object? sender, EventArgs e)
{
_cursorVisible = !_cursorVisible;
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
if (_activeStreamText != null && _displayedLength > 0)
// 커서 상태만 토글 — 버퍼에 이미 기록된 텍스트의 마지막 커서 문자만 교체
if (_activeStreamText != null && _displayedLength > 0 && _streamDisplayBufferLen > 0)
{
var displayed = _cachedStreamContent.Length > 0
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
: "";
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
var cursorChar = _cursorVisible ? '\u258c' : ' ';
_streamDisplayBuffer[_streamDisplayBufferLen - 1] = cursorChar;
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, _streamDisplayBufferLen);
}
}
private void ElapsedTimer_Tick(object? sender, EventArgs e)
{
var elapsed = DateTime.UtcNow - _streamStartTime;
var sec = (int)elapsed.TotalSeconds;
var sec = TryGetStreamingElapsed(out var elapsed)
? Math.Max(0, (int)elapsed.TotalSeconds)
: 0;
if (_elapsedLabel != null)
_elapsedLabel.Text = $"{sec}s";
@@ -2356,25 +2525,38 @@ public partial class ChatWindow : Window
if (_displayedLength >= targetLen) return;
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
// IBM/DeepSeek은 대용량 청크를 한번에 보내므로 빠르게 따라잡을 수 있도록 스텝 증가
var pending = targetLen - _displayedLength;
int step;
if (pending > 1000) step = pending / 8; // 대량 버퍼: 빠르게 따라잡기
else if (pending > 300) step = Math.Min(Math.Max(15, pending / 8), 60); // 중-대량: 가속
else if (pending > 120) step = Math.Min(Math.Max(8, pending / 10), 20); // 중간 버퍼
else if (pending > 24) step = Math.Min(6, pending); // 소량
else step = Math.Min(2, pending); // 마무리
if (pending > 1000) step = pending / 4;
else if (pending > 300) step = Math.Min(Math.Max(30, pending / 4), 120);
else if (pending > 120) step = Math.Min(Math.Max(15, pending / 6), 40);
else if (pending > 24) step = Math.Min(12, pending);
else step = Math.Min(4, pending);
_displayedLength += step;
var displayed = _cachedStreamContent[.._displayedLength];
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
// 재사용 버퍼에 표시할 텍스트 + 커서를 직접 기록 (string.Concat 할당 제거)
var displayLen = _displayedLength;
var cursorChar = _cursorVisible ? '\u258c' : ' ';
var needed = displayLen + 1;
if (needed <= _streamDisplayBuffer.Length)
{
_cachedStreamContent.CopyTo(0, _streamDisplayBuffer, 0, displayLen);
_streamDisplayBuffer[displayLen] = cursorChar;
_streamDisplayBufferLen = needed;
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, needed);
}
else
{
// 버퍼 초과 시 fallback (256KB 이상 응답)
_activeStreamText.Text = string.Concat(_cachedStreamContent.AsSpan(0, displayLen), cursorChar.ToString());
}
// 스크롤은 80ms마다 한 번만 (매 20ms 레이아웃 재계산 방지)
// 스크롤은 150ms마다 한 번만 (레이아웃 재계산 빈도 감소)
if (!_userScrolled)
{
var now = Environment.TickCount64;
if (now - _lastScrollTick >= 80)
if (now - _lastScrollTick >= 150)
{
_lastScrollTick = now;
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
@@ -5568,6 +5750,7 @@ public partial class ChatWindow : Window
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_streamStartTime = default;
SetStatusIdle();
}
@@ -5691,26 +5874,27 @@ public partial class ChatWindow : Window
_typingTimer.Start();
ShowStreamingStatusBar("생각하는 중...");
var streamSb = new System.Text.StringBuilder();
var streamSb = new System.Text.StringBuilder(4096);
var lastSyncTick = Environment.TickCount64;
await foreach (var chunk in _llm.StreamAsync(preparedExecution.Messages.ToList(), streamToken))
{
if (string.IsNullOrEmpty(chunk))
continue;
streamSb.Append(chunk);
// 타이핑 타이머가 현재 버퍼를 다 소화했을 때만 ToString() 호출 — GC 압박 최소화
if (_displayedLength >= _cachedStreamContent.Length)
// ToString() 호출 조건: 타이머가 소화 완료 + 최소 30ms 경과
var now = Environment.TickCount64;
if (_displayedLength >= _cachedStreamContent.Length && now - lastSyncTick >= 30)
{
_cachedStreamContent = streamSb.ToString();
// Dispatcher 타이머 틱이 실행될 기회를 보장
// (IBM처럼 응답이 버퍼로 한 번에 오면 타이머가 굶을 수 있음)
lastSyncTick = now;
await Task.Delay(1, streamToken).ConfigureAwait(true);
}
if (_activeStreamText != null && _displayedLength == 0)
_activeStreamText.Text = _cursorVisible ? "\u258c" : " ";
}
assistantContent = streamSb.ToString();
_cachedStreamContent = assistantContent; // 최종 동기화
_cachedStreamContent = assistantContent;
}
else
{
@@ -5722,7 +5906,7 @@ public partial class ChatWindow : Window
assistantContent = response ?? string.Empty;
}
responseElapsedMs = Math.Max(0, (long)(DateTime.UtcNow - _streamStartTime).TotalMilliseconds);
responseElapsedMs = GetStreamingElapsedMsOrZero();
assistantMetaRunId = _appState.AgentRun.RunId;
var usage = _llm.LastTokenUsage;
if (usage != null)
@@ -6001,12 +6185,25 @@ public partial class ChatWindow : Window
if (_pendingConversationPersists.Count == 0)
return;
foreach (var conversation in _pendingConversationPersists.Values.ToList())
{
PersistConversationSnapshot(conversation.Tab ?? _activeTab, conversation, "대화 지연 저장 실패");
}
// 대화 저장(디스크 I/O)을 백그라운드로 이동하여 UI 스레드 블로킹 방지
var snapshot = _pendingConversationPersists.Values.ToList();
_pendingConversationPersists.Clear();
Task.Run(() =>
{
foreach (var conversation in snapshot)
{
try
{
_storage.Save(conversation);
_appState.ChatSession?.RememberConversation(conversation.Tab ?? "Chat", conversation.Id);
}
catch (Exception ex)
{
Services.LogService.Debug($"대화 지연 저장 실패: {ex.Message}");
}
}
});
}
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
@@ -6536,10 +6733,9 @@ public partial class ChatWindow : Window
private void OnAgentEvent(AgentEvent evt, string runTab)
{
TouchLiveAgentProgressHints();
// runTab은 클로저로 캡처된 실행 탭 — 다중 탭 동시 실행 시에도 올바른 탭에 귀속
var eventTab = runTab;
// Claude 스타일 펄스 닷 실시간 단계 업데이트
// ── 1단계: 경량 UI 피드백만 (UI 스레드) ──────────────────────────────
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
{
switch (evt.Type)
@@ -6547,14 +6743,12 @@ public partial class ChatWindow : Window
case AgentEventType.ToolCall when !string.IsNullOrWhiteSpace(evt.ToolName):
{
var (msg, icon, category) = GetStatusInfoForTool(evt.ToolName);
// 카테고리 변경 시 주 텍스트만 업데이트하고 서브 아이템 초기화
bool categoryChanged = category != _currentSubItemCategory;
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
PulseDotStatusText.Text = msg + "...";
if (categoryChanged) ClearStatusSubItems();
_currentSubItemCategory = category;
// 파일명 서브 아이템 추가
string? subItemText = null;
if (!string.IsNullOrEmpty(evt.FilePath))
{
@@ -6568,13 +6762,11 @@ public partial class ChatWindow : Window
subItemText = evt.Summary;
AddStatusSubItem(subItemText, category);
}
// 라이브 카드 업데이트
UpdateAgentLiveCard(msg + "...", subItemText, category, categoryChanged);
break;
}
case AgentEventType.ToolResult when !string.IsNullOrWhiteSpace(evt.ToolName):
{
// 결과 수신 시 기존 서브 아이템을 유지하며 주 텍스트만 변경
var resultMsg = GetToolResultMessage(evt.ToolName) + "...";
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
PulseDotStatusText.Text = resultMsg;
@@ -6623,19 +6815,16 @@ public partial class ChatWindow : Window
_currentRunProgressSteps.RemoveAt(0);
}
// 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다.
// 그래야 중간 배너 잔상과 최종 재렌더 중복이 줄어듭니다.
// ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ──────────────────────────
// AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은
// 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다.
var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false;
AppendConversationExecutionEvent(evt, eventTab);
if ((shouldShowExecutionHistory || ShouldRenderProgressEventWhenHistoryCollapsed(evt))
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
ScheduleExecutionHistoryRender(autoScroll: true);
var shouldRender = (shouldShowExecutionHistory || ShouldRenderProgressEventWhenHistoryCollapsed(evt))
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase);
EnqueueAgentEventWork(evt, eventTab, shouldRender);
// ── 3단계: 경량 상태 추적 (UI 스레드) ───────────────────────────────
_appState.ApplyAgentEvent(evt);
if (evt.Type == AgentEventType.Complete)
AppendConversationAgentRun(evt, "completed", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary, eventTab);
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
AppendConversationAgentRun(evt, "failed", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary, eventTab);
// 탭별 토큰 누적 — 활성 탭 것만 하단 바에 표시
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
@@ -6647,7 +6836,6 @@ public partial class ChatWindow : Window
}
ScheduleAgentUiEvent(evt);
ScheduleTaskSummaryRefresh();
}
@@ -7171,7 +7359,7 @@ public partial class ChatWindow : Window
}
var idle = DateTime.UtcNow - _lastAgentProgressEventAt;
var elapsed = DateTime.UtcNow - _streamStartTime.ToUniversalTime();
TryGetStreamingElapsed(out var elapsed);
string? summary = null;
var toolName = "agent_wait";
@@ -7214,10 +7402,7 @@ public partial class ChatWindow : Window
var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim();
var currentSummary = _liveAgentProgressHint?.Summary;
var currentToolName = _liveAgentProgressHint?.ToolName ?? "";
var hasValidStreamStart = _streamStartTime.Year >= 2000 && _streamStartTime <= DateTime.UtcNow.AddSeconds(1);
var elapsedMs = _isStreaming && hasValidStreamStart
? Math.Max(0L, (long)(DateTime.UtcNow - _streamStartTime.ToUniversalTime()).TotalMilliseconds)
: 0L;
var elapsedMs = _isStreaming ? GetStreamingElapsedMsOrZero() : 0L;
var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab));
var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab));
var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 3000;
@@ -7298,10 +7483,17 @@ public partial class ChatWindow : Window
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
{
Dispatcher.Invoke(() =>
Dispatcher.BeginInvoke(() =>
{
_appState.ApplySubAgentStatus(evt);
ScheduleTaskSummaryRefresh();
try
{
_appState.ApplySubAgentStatus(evt);
ScheduleTaskSummaryRefresh();
}
catch (Exception ex)
{
LogService.Warn($"OnSubAgentStatusChanged 처리 실패: {ex.Message}");
}
});
}
@@ -8756,10 +8948,11 @@ public partial class ChatWindow : Window
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard);
// 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
var elapsed = DateTime.UtcNow - _streamStartTime;
var elapsedText = elapsed.TotalSeconds < 60
var elapsedText = TryGetStreamingElapsed(out var elapsed)
? (elapsed.TotalSeconds < 60
? $"{elapsed.TotalSeconds:0.#}s"
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s")
: "0s";
var usage = _llm.LastTokenUsage;
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
@@ -9593,6 +9786,31 @@ public partial class ChatWindow : Window
private DispatcherTimer? _rainbowTimer;
private DateTime _rainbowStartTime;
private bool TryGetStreamingElapsed(out TimeSpan elapsed)
{
elapsed = TimeSpan.Zero;
if (_streamStartTime.Year < 2000)
return false;
var now = DateTime.UtcNow;
if (_streamStartTime > now.AddSeconds(1))
return false;
elapsed = now - _streamStartTime;
if (elapsed < TimeSpan.Zero || elapsed > TimeSpan.FromHours(6))
{
elapsed = TimeSpan.Zero;
return false;
}
return true;
}
private long GetStreamingElapsedMsOrZero()
=> TryGetStreamingElapsed(out var elapsed)
? Math.Max(0L, (long)elapsed.TotalMilliseconds)
: 0L;
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
private void PlayRainbowGlow()
{
@@ -9600,12 +9818,12 @@ public partial class ChatWindow : Window
if (_rainbowTimer != null) return; // 이미 실행 중이면 opacity 리셋 없이 그냥 유지
_rainbowStartTime = DateTime.UtcNow;
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 6 };
InputGlowBorder.Visibility = Visibility.Visible;
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 4 };
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 0.62, TimeSpan.FromMilliseconds(180)));
new System.Windows.Media.Animation.DoubleAnimation(0, 0.92, TimeSpan.FromMilliseconds(180)));
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_rainbowTimer.Tick += (_, _) =>
{
var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds;
@@ -9625,13 +9843,21 @@ public partial class ChatWindow : Window
{
_rainbowTimer?.Stop();
_rainbowTimer = null;
if (InputGlowBorder.Opacity > 0)
if (InputGlowBorder.Opacity > 0 || InputGlowBorder.Visibility == Visibility.Visible)
{
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(
InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600));
fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0;
fadeOut.Completed += (_, _) =>
{
InputGlowBorder.Opacity = 0;
InputGlowBorder.Visibility = Visibility.Collapsed;
};
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
else
{
InputGlowBorder.Visibility = Visibility.Collapsed;
}
}
// ─── 토스트 알림 ──────────────────────────────────────────────────────
@@ -11346,6 +11572,10 @@ public partial class ChatWindow : Window
ChkOverlayShowTotalCallStats.IsChecked = llm.ShowTotalCallStats;
if (ChkOverlayEnableAuditLog != null)
ChkOverlayEnableAuditLog.IsChecked = llm.EnableAuditLog;
if (ChkOverlayEnableDetailedLog != null)
ChkOverlayEnableDetailedLog.IsChecked = llm.EnableDetailedLog;
if (ChkOverlayEnableRawLlmLog != null)
ChkOverlayEnableRawLlmLog.IsChecked = llm.EnableRawLlmLog;
if (ChkOverlayEnableChatRainbowGlow != null)
ChkOverlayEnableChatRainbowGlow.IsChecked = llm.EnableChatRainbowGlow;
}
@@ -12005,6 +12235,28 @@ public partial class ChatWindow : Window
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayEnableDetailedLog_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayEnableDetailedLog == null)
return;
var enabled = ChkOverlayEnableDetailedLog.IsChecked == true;
_settings.Settings.Llm.EnableDetailedLog = enabled;
WorkflowLogService.IsEnabled = enabled;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayEnableRawLlmLog_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayEnableRawLlmLog == null)
return;
var enabled = ChkOverlayEnableRawLlmLog.IsChecked == true;
_settings.Settings.Llm.EnableRawLlmLog = enabled;
WorkflowLogService.IsRawLogEnabled = enabled;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayFeatureToggle_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
@@ -12158,8 +12410,12 @@ public partial class ChatWindow : Window
private void RefreshOverlaySettingsPanel()
{
// 기본 컨트롤 상태만 동기적으로 설정 (빠름)
RefreshOverlayVisualState(loadDeferredInputs: true);
RefreshOverlayEtcPanels();
// 무거운 패널 빌드(스킬/MCP/도구 목록 등)는 UI 프레임 렌더링 후 비동기 지연 실행
// → 스트리밍 중 설정 열기 시 UI 프리즈 방지
Dispatcher.BeginInvoke(RefreshOverlayEtcPanels, System.Windows.Threading.DispatcherPriority.Background);
}
private void RefreshOverlayRetentionButtons()
@@ -16202,9 +16458,16 @@ public partial class ChatWindow : Window
AddTaskSummaryBackgroundSection(panel);
}
private static readonly Dictionary<string, System.Windows.Media.SolidColorBrush> _brushCache = new(StringComparer.OrdinalIgnoreCase);
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
{
if (_brushCache.TryGetValue(hex, out var cached))
return cached;
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!;
return new System.Windows.Media.SolidColorBrush(c);
var brush = new System.Windows.Media.SolidColorBrush(c);
brush.Freeze(); // Frozen brush는 스레드간 공유 가능 + 렌더링 최적화
_brushCache[hex] = brush;
return brush;
}
}