using System.Diagnostics; using System.Linq; using System.Windows.Threading; using AxCopilot.Models; using AxCopilot.Services; namespace AxCopilot.Views; public partial class ChatWindow { // ─── 렌더링 쓰로틀: 스트리밍 중 최소 간격 보장 ─────────────────────── private long _lastRenderTicks; private const long MinStreamingRenderIntervalMs = 1500; // 스트리밍 중 최소 1.5초 간격 private const long MinIdleRenderIntervalMs = 300; // 비스트리밍(유휴) 시 최소 300ms 간격 private int GetActiveTimelineRenderLimit() { if (!_isStreaming) return _timelineRenderLimit; var streamingLimit = IsLightweightLiveProgressMode() ? TimelineLightweightStreamingRenderLimit : TimelineStreamingRenderLimit; return Math.Min(_timelineRenderLimit, streamingLimit); } private void RenderMessages(bool preserveViewport = false, [System.Runtime.CompilerServices.CallerMemberName] string? caller = null) { // B-4: 비가시 상태일 때 렌더링 차단 — 최소화/숨김 시 불필요한 UI 재구축 방지 if (this.WindowState == System.Windows.WindowState.Minimized || !IsVisible) return; var now = Environment.TickCount64; // B-5: 스트리밍 중 쓰로틀 — preserveViewport=true (타이머 기반) 호출만 제한 // preserveViewport=false는 사용자 메시지 전송 등 중요 렌더이므로 항상 허용 if (_isStreaming && preserveViewport) { if (now - _lastRenderTicks < MinStreamingRenderIntervalMs) return; } // B-7: 유휴 상태 렌더 쓰로틀 — 빈 대화에서 반복 호출 방지 (UI 프리징 원인) // 대화 내용이 바뀌지 않았는데 짧은 간격으로 반복 호출되면 무시 if (!_isStreaming && now - _lastRenderTicks < MinIdleRenderIntervalMs) { ChatConversation? quickConv; lock (_convLock) quickConv = _currentConversation; var quickMsgCount = quickConv?.Messages?.Count ?? 0; var quickEvtCount = quickConv?.ExecutionEvents?.Count ?? 0; // 대화 내용이 마지막 렌더와 같으면 스킵 (빈 대화 반복 렌더 차단) if (quickMsgCount == _lastRenderedMessageCount && quickEvtCount == _lastRenderedEventCount && string.Equals(_lastRenderedConversationId, quickConv?.Id, StringComparison.OrdinalIgnoreCase)) { return; } // B-7b: 빈 대화 간 convId 플래핑 방지 — 둘 다 메시지 0개면 // convId가 달라도 빈 화면 렌더를 반복할 이유 없음 (SwitchToTabConversation 스팸 차단) // 단, preserveViewport=false(탭 전환 등 명시적 렌더)는 차단하지 않음 // — 탭 전환 시 EmptyState/마스코트 표시에 필요 if (preserveViewport && quickMsgCount == 0 && quickEvtCount == 0 && _lastRenderedMessageCount == 0 && _lastRenderedEventCount == 0) { return; } } var renderStopwatch = Stopwatch.StartNew(); var previousScrollableHeight = GetTranscriptScrollableHeight(); var previousVerticalOffset = GetTranscriptVerticalOffset(); ChatConversation? conv; lock (_convLock) conv = _currentConversation; _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); var visibleMessages = GetVisibleTimelineMessages(conv); var visibleEvents = GetVisibleTimelineEvents(conv); // 진단 로그: 렌더링 호출 시점의 상태 추적 Services.LogService.Info($"[Render] caller={caller}, preserveViewport={preserveViewport}, streaming={_isStreaming}, " + $"convId={conv?.Id?[..Math.Min(8, conv?.Id?.Length ?? 0)]}, " + $"rawMsgCount={conv?.Messages?.Count ?? 0}, visibleMsg={visibleMessages.Count}, " + $"visibleEvt={visibleEvents.Count}, emptyState={EmptyState.Visibility}, " + $"transcriptElements={GetTranscriptElementCount()}"); 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)) { // 스트리밍 중이거나 대화에 원본 메시지가 있으면 EmptyState를 표시하지 않음 // (GetVisibleTimelineMessages의 필터링으로 visibleMessages가 0이 되어도 원본은 존재) bool hasRawMessages = (conv?.Messages?.Count ?? 0) > 0; if (!_isStreaming && !hasRawMessages) { ClearTranscriptElements(); _runBannerAnchors.Clear(); _lastRenderedTimelineKeys.Clear(); _lastRenderedMessageCount = 0; _lastRenderedEventCount = 0; EmptyState.Visibility = System.Windows.Visibility.Visible; StartMascotAnimation(); } else { // 메시지가 있거나 스트리밍 중 → EmptyState 강제 숨김 EmptyState.Visibility = System.Windows.Visibility.Collapsed; StopMascotAnimation(); } return; } if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase)) { _lastRenderedConversationId = conv.Id; _timelineRenderLimit = TimelineRenderPageSize; _elementCache.Clear(); _lastRenderedTimelineKeys.Clear(); _lastRenderedMessageCount = 0; _lastRenderedEventCount = 0; InvalidateTimelineCache(); } EmptyState.Visibility = System.Windows.Visibility.Collapsed; StopMascotAnimation(); // V2 렌더링 분기 — 설정 토글로 Claude Code 스타일 상세 이력 UI 활성화 if (_settings.Settings.Llm.EnableNewChatRendering) { RenderMessagesV2(conv, visibleMessages, visibleEvents, preserveViewport, previousScrollableHeight, previousVerticalOffset, renderStopwatch, caller); return; } try { var renderPlan = BuildTranscriptRenderPlan(conv, visibleMessages, visibleEvents); Services.LogService.Info($"[Render] plan: items={renderPlan.VisibleTimeline.Count}, hidden={renderPlan.HiddenCount}, " + $"canIncremental={renderPlan.CanIncremental}, keys={renderPlan.NewKeys.Count}"); // B-3: 스트리밍 전용 빠른 경로 → 일반 인크리멘탈 → Diff(Virtual DOM) → 전체 재빌드 if (!TryApplyStreamingAppendRender(renderPlan) && !TryApplyIncrementalTranscriptRender(renderPlan) && !TryApplyDiffRender(renderPlan)) ApplyFullTranscriptRender(renderPlan); PruneTranscriptElementCache(renderPlan.NewKeys); _lastRenderedMessageCount = visibleMessages.Count; _lastRenderedEventCount = visibleEvents.Count; _lastRenderedShowHistory = renderPlan.ShowHistory; } catch (Exception renderEx) { Services.LogService.Error($"[Render] 렌더링 파이프라인 예외: {renderEx.GetType().Name}: {renderEx.Message}\n{renderEx.StackTrace}"); } _lastRenderTicks = Environment.TickCount64; // 쓰로틀 타임스탬프 갱신 renderStopwatch.Stop(); // B-6: 스트리밍 중 로깅 빈도 축소 — 100ms 미만 렌더는 기록하지 않음 (UI 부하 감소) if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24)) { AgentPerformanceLogService.LogMetric( "transcript", "render_messages", conv.Id, _activeTab ?? "", renderStopwatch.ElapsedMilliseconds, new { preserveViewport, streaming = _isStreaming, lightweight = IsLightweightLiveProgressMode(), visibleMessages = visibleMessages.Count, visibleEvents = visibleEvents.Count, transcriptElements = GetTranscriptElementCount(), }); } if (!preserveViewport) { _ = Dispatcher.InvokeAsync(ScrollTranscriptToEnd, DispatcherPriority.Background); return; } _ = Dispatcher.InvokeAsync(() => { if (_transcriptScrollViewer == null) return; var newScrollableHeight = GetTranscriptScrollableHeight(); var delta = newScrollableHeight - previousScrollableHeight; var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta)); ScrollTranscriptToVerticalOffset(targetOffset); }, DispatcherPriority.Background); } }