From 74d43e701c177dc5c3fc96546a60e90d6dd61f3a Mon Sep 17 00:00:00 2001 From: lacvet Date: Thu, 9 Apr 2026 00:00:37 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20transcript=20=ED=98=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=ED=95=B4=20=EC=BD=94=EC=9B=8C=ED=81=AC=C2=B7=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EC=A4=91=20UI=20?= =?UTF-8?q?=EB=B6=80=ED=95=98=EB=A5=BC=20=EC=A4=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메시지 영역을 ScrollViewer+StackPanel 직접 조작 구조에서 ListBox+ObservableCollection 기반 transcript 호스트로 전환 - ChatWindow.TranscriptHost 도입으로 transcript 요소 추가·교체·삭제·스크롤 접근을 공용 helper로 정리 - 코워크/코드 실행 중 RenderMessages와 라이브 카드가 MessagePanel.Children 직접 조작에 덜 의존하도록 정리 - MessageBubble, AgentEventRendering, Timeline, UserAsk, ConversationManagement 등 transcript 관련 partial을 새 호스트 구조에 맞게 전환 - 향후 claw-code의 VirtualMessageList 수준 가상화를 적용할 수 있는 기반을 마련 - README와 DEVELOPMENT 문서를 2026-04-08 12:52 (KST) 기준으로 갱신 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0) --- README.md | 5 + docs/DEVELOPMENT.md | 25 +++++ .../Views/ChatWindow.AgentEventRendering.cs | 6 +- ...ndow.ConversationManagementPresentation.cs | 2 +- .../ChatWindow.MessageBubblePresentation.cs | 6 +- .../Views/ChatWindow.MessageInteractions.cs | 12 +- .../Views/ChatWindow.TimelinePresentation.cs | 2 +- .../Views/ChatWindow.TranscriptHost.cs | 101 +++++++++++++++++ .../Views/ChatWindow.UserAskPresentation.cs | 4 +- src/AxCopilot/Views/ChatWindow.xaml | 54 ++++++--- src/AxCopilot/Views/ChatWindow.xaml.cs | 104 +++++++++--------- 11 files changed, 235 insertions(+), 86 deletions(-) create mode 100644 src/AxCopilot/Views/ChatWindow.TranscriptHost.cs diff --git a/README.md b/README.md index fd580df..05f8078 100644 --- a/README.md +++ b/README.md @@ -1516,3 +1516,8 @@ MIT License - [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs)의 메시지 엔트리 애니메이션은 실행 중 경량 모드에서 즉시 표시로 바뀌어, 새 UI 요소가 들어올 때마다 opacity/translate 애니메이션이 누적되지 않게 했습니다. - [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs)의 진행 마커 펄스는 경량 모드에서 정적 점 상태로 간소화했습니다. - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 AX Agent 라이브 카드도 경량 모드에서는 등장/퇴장 애니메이션과 아이콘 opacity 펄스를 줄여, 실행 중 레이아웃/애니메이션 비용을 더 낮췄습니다. +- 업데이트: 2026-04-08 12:52 (KST) + - AX Agent transcript 호스트를 `ScrollViewer + StackPanel + Children.Add` 구조에서 `ListBox + ObservableCollection` 기반으로 옮겼습니다. + - [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs)를 추가해 transcript 요소 추가/교체/삭제와 스크롤 접근을 공용 helper로 정리했고, 내부 ScrollViewer도 한 번만 찾아 재사용하도록 바꿨습니다. + - [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 메시지 영역은 `VirtualizingStackPanel`을 쓰는 `ListBox` 기반 호스트로 교체해, 이후 `claw-code`의 `VirtualMessageList`에 더 가까운 가상화 구조로 밀어갈 수 있는 기반을 만들었습니다. + - 관련 렌더 코드([ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs), [ChatWindow.MessageBubblePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs), [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs), [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs), [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs))도 모두 컬렉션 기반 조작으로 맞춰, 실행 중 `Children` 직접 조작에 따른 레이아웃 churn을 줄였습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a235713..ca3e8e4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5478,3 +5478,28 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 코워크/코드 실행 중 누적 애니메이션 수 감소 - 긴 세션에서 opacity/translate/scale animation이 UI 스레드에 주는 부담 완화 - 진행 카드는 유지하되 시각 효과 비용은 더 낮춘 상태로 동작 + +## 2026-04-08 12:52 (KST) + +- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) + - transcript 호스트를 `ScrollViewer + StackPanel`에서 `ListBox + VirtualizingStackPanel` 구조로 교체했다. + - `ListBoxItem` 기본 chrome를 제거해 기존 수제 버블/카드 레이아웃을 유지하면서, 이후 실제 가상화 적용을 더 밀어 넣을 수 있는 기반 형태로 맞췄다. +- [ChatWindow.TranscriptHost.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs) + - `ObservableCollection` 기반 transcript 컬렉션을 추가했다. + - transcript 요소 추가/교체/삭제/인덱스 조회/Contains/scroll access를 helper로 통합했다. + - `ListBox` 내부 `ScrollViewer`를 visual tree에서 한 번 찾아 재사용하고, 스크롤 이벤트도 그 경로를 통해 연결하도록 정리했다. +- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) + - `RenderMessages()`와 스크롤/증분 렌더 경로를 모두 transcript helper 위로 옮겼다. + - 이전처럼 `MessagePanel.Children`와 `MessageScroll`에 직접 의존하지 않게 바꿔, transcript 조작이 한 컬렉션/래퍼를 통해서만 이뤄지도록 정리했다. + - 메시지 폭 계산과 반응형 레이아웃도 `MessageList` 기준으로 다시 계산하도록 수정했다. +- [ChatWindow.MessageBubblePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs) +- [ChatWindow.AgentEventRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs) +- [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs) +- [ChatWindow.TimelinePresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs) +- [ChatWindow.UserAskPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs) +- [ChatWindow.ConversationManagementPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs) + - transcript 요소 추가/제거/치환 로직을 모두 새 helper 기반으로 전환했다. +- 구조 효과 + - 실행 중 `Children.Add/Remove`에 직접 매달리던 경로를 줄여 레이아웃 churn을 완화했다. + - `claw-code`의 `VirtualMessageList`처럼 완전한 전면 가상화는 아니지만, 그 단계로 가기 위한 transcript host 분리를 마쳤다. + - 이후 단계에서는 이 컬렉션 호스트 위에 실제 item virtualization/placeholder/windowing을 더 강하게 적용할 수 있다. diff --git a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs index 8c60a3f..9d2fe09 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs @@ -241,7 +241,7 @@ public partial class ChatWindow stack.Children.Add(compactPathRow); } - MessagePanel.Children.Add(stack); + AddTranscriptElement(stack); } private static void ApplyLiveWaitingPulse(Border summaryRow) @@ -1130,7 +1130,7 @@ public partial class ChatWindow Child = outerStack, }; - MessagePanel.Children.Add(card); + AddTranscriptElement(card); } private void AddAgentEventBanner(AgentEvent evt) @@ -1478,7 +1478,7 @@ public partial class ChatWindow }; } - MessagePanel.Children.Add(banner); + AddTranscriptElement(banner); } private static (string icon, string label, string bgHex, string fgHex) GetDecisionBadgeMeta(string? summary) diff --git a/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs b/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs index 0eea7cd..b4e37a3 100644 --- a/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ConversationManagementPresentation.cs @@ -418,7 +418,7 @@ public partial class ChatWindow if (_currentConversation?.Id == conversationId) { _currentConversation = null; - MessagePanel.Children.Clear(); + ClearTranscriptElements(); EmptyState.Visibility = Visibility.Visible; UpdateChatTitle(); } diff --git a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs index b166252..0a4bd97 100644 --- a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs @@ -122,7 +122,7 @@ public partial class ChatWindow if (animate) ApplyMessageEntryAnimation(wrapper); if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = wrapper; - MessagePanel.Children.Add(wrapper); + AddTranscriptElement(wrapper); return; } @@ -132,7 +132,7 @@ public partial class ChatWindow if (animate) ApplyMessageEntryAnimation(compactCard); if (message.MsgId != null) _elementCache[$"m_{message.MsgId}"] = compactCard; - MessagePanel.Children.Add(compactCard); + AddTranscriptElement(compactCard); return; } @@ -335,6 +335,6 @@ public partial class ChatWindow }; if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = container; - MessagePanel.Children.Add(container); + AddTranscriptElement(container); } } diff --git a/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs b/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs index e89bf7f..2d829a0 100644 --- a/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs +++ b/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs @@ -306,7 +306,7 @@ public partial class ChatWindow } _isEditing = true; - var idx = MessagePanel.Children.IndexOf(wrapper); + var idx = GetTranscriptElementIndex(wrapper); if (idx < 0) { _isEditing = false; @@ -365,9 +365,9 @@ public partial class ChatWindow cancelBtn.Click += (_, _) => { _isEditing = false; - if (idx >= 0 && idx < MessagePanel.Children.Count) + if (idx >= 0 && idx < GetTranscriptElementCount()) { - MessagePanel.Children[idx] = wrapper; + ReplaceTranscriptElement(idx, wrapper); } }; btnBar.Children.Add(cancelBtn); @@ -393,7 +393,7 @@ public partial class ChatWindow btnBar.Children.Add(sendBtn); editPanel.Children.Add(btnBar); - MessagePanel.Children[idx] = editPanel; + ReplaceTranscriptElement(idx, editPanel); editBox.KeyDown += (_, ke) => { @@ -411,9 +411,9 @@ public partial class ChatWindow { ke.Handled = true; _isEditing = false; - if (idx >= 0 && idx < MessagePanel.Children.Count) + if (idx >= 0 && idx < GetTranscriptElementCount()) { - MessagePanel.Children[idx] = wrapper; + ReplaceTranscriptElement(idx, wrapper); } } }; diff --git a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs index a1baef3..cd828d4 100644 --- a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs @@ -118,7 +118,7 @@ public partial class ChatWindow { // 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성) if (_elementCache.TryGetValue(cacheKey, out var cached)) - MessagePanel.Children.Add(cached); + AddTranscriptElement(cached); else AddMessageBubble(capturedMsg.Role, capturedMsg.Content, animate: false, message: capturedMsg); })); diff --git a/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs b/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs new file mode 100644 index 0000000..6bde5aa --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.TranscriptHost.cs @@ -0,0 +1,101 @@ +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private readonly ObservableCollection _transcriptElements = []; + private ScrollViewer? _transcriptScrollViewer; + private bool _transcriptScrollHooked; + + private void InitializeTranscriptHost() + { + MessageList.ItemsSource = _transcriptElements; + AttachTranscriptScrollChanged(); + MessageList.Loaded += (_, _) => AttachTranscriptScrollChanged(); + } + + private void AttachTranscriptScrollChanged() + { + var scrollViewer = FindVisualChild(MessageList); + if (scrollViewer == null || ReferenceEquals(scrollViewer, _transcriptScrollViewer)) + return; + + if (_transcriptScrollViewer != null && _transcriptScrollHooked) + { + _transcriptScrollViewer.ScrollChanged -= MessageScroll_ScrollChanged; + } + + _transcriptScrollViewer = scrollViewer; + _transcriptScrollViewer.ScrollChanged += MessageScroll_ScrollChanged; + _transcriptScrollHooked = true; + } + + private static T? FindVisualChild(DependencyObject? parent) + where T : DependencyObject + { + if (parent == null) + return null; + + var childCount = VisualTreeHelper.GetChildrenCount(parent); + for (var i = 0; i < childCount; i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T match) + return match; + + var descendant = FindVisualChild(child); + if (descendant != null) + return descendant; + } + + return null; + } + + private int GetTranscriptElementCount() => _transcriptElements.Count; + + private UIElement? GetTranscriptElementAt(int index) + { + if (index < 0 || index >= _transcriptElements.Count) + return null; + + return _transcriptElements[index]; + } + + private int GetTranscriptElementIndex(UIElement element) => _transcriptElements.IndexOf(element); + + private bool ContainsTranscriptElement(UIElement element) => _transcriptElements.Contains(element); + + private void AddTranscriptElement(UIElement element) => _transcriptElements.Add(element); + + private void ClearTranscriptElements() => _transcriptElements.Clear(); + + private void RemoveTranscriptElement(UIElement element) => _transcriptElements.Remove(element); + + private void RemoveTranscriptElementAt(int index) + { + if (index < 0 || index >= _transcriptElements.Count) + return; + + _transcriptElements.RemoveAt(index); + } + + private void ReplaceTranscriptElement(int index, UIElement element) + { + if (index < 0 || index >= _transcriptElements.Count) + return; + + _transcriptElements[index] = element; + } + + private double GetTranscriptScrollableHeight() => _transcriptScrollViewer?.ScrollableHeight ?? 0; + + private double GetTranscriptVerticalOffset() => _transcriptScrollViewer?.VerticalOffset ?? 0; + + private void ScrollTranscriptToEnd() => _transcriptScrollViewer?.ScrollToEnd(); + + private void ScrollTranscriptToVerticalOffset(double offset) => _transcriptScrollViewer?.ScrollToVerticalOffset(offset); +} diff --git a/src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs b/src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs index 521c194..c5c82dd 100644 --- a/src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.UserAskPresentation.cs @@ -33,7 +33,7 @@ public partial class ChatWindow if (_userAskCard == null) return; - MessagePanel.Children.Remove(_userAskCard); + RemoveTranscriptElement(_userAskCard); _userAskCard = null; } @@ -244,7 +244,7 @@ public partial class ChatWindow container.Opacity = 0; container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); - MessagePanel.Children.Add(container); + AddTranscriptElement(container); ForceScrollToEnd(); inputBox.Focus(); inputBox.CaretIndex = inputBox.Text.Length; diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index a36018f..2154217 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -1358,22 +1358,43 @@ - - - - - - - + + + + + + + + + + - diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 5f78d51..f8ab625 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -218,6 +218,7 @@ public partial class ChatWindow : Window public ChatWindow(SettingsService settings) { InitializeComponent(); + InitializeTranscriptHost(); _settings = settings; _settings.SettingsChanged += Settings_SettingsChanged; _storage = new ChatStorageService(); @@ -377,8 +378,6 @@ public partial class ChatWindow : Window UpdateResponsiveChatLayout(); UpdateInputBoxHeight(); InputBox.Focus(); - MessageScroll.ScrollChanged += MessageScroll_ScrollChanged; - // ── 무거운 작업은 유휴 시점에 비동기 실행 ── Dispatcher.BeginInvoke(() => { @@ -637,7 +636,7 @@ public partial class ChatWindow : Window private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e) { // 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥 - if (MessageScroll.ScrollableHeight <= 1) + if (GetTranscriptScrollableHeight() <= 1) { _userScrolled = false; return; @@ -647,7 +646,7 @@ public partial class ChatWindow : Window if (Math.Abs(e.ExtentHeightChange) > 0.5) return; - var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40; + var atBottom = GetTranscriptVerticalOffset() >= GetTranscriptScrollableHeight() - 40; _userScrolled = !atBottom; } @@ -667,14 +666,14 @@ public partial class ChatWindow : Window /// 부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다. private void SmoothScrollToEnd() { - var targetOffset = MessageScroll.ScrollableHeight; - var currentOffset = MessageScroll.VerticalOffset; + var targetOffset = GetTranscriptScrollableHeight(); + var currentOffset = GetTranscriptVerticalOffset(); var diff = targetOffset - currentOffset; // 스트리밍 중이거나 차이가 작으면 즉시 이동 (UI 부하 최소화) if (diff <= 60 || _isStreaming) { - MessageScroll.ScrollToEnd(); + ScrollTranscriptToEnd(); return; } @@ -686,7 +685,7 @@ public partial class ChatWindow : Window Duration = TimeSpan.FromMilliseconds(200), EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, }; - animation.Completed += (_, _) => MessageScroll.ScrollToVerticalOffset(targetOffset); + animation.Completed += (_, _) => ScrollTranscriptToVerticalOffset(targetOffset); // ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간 var startTime = DateTime.UtcNow; @@ -698,7 +697,7 @@ public partial class ChatWindow : Window var progress = Math.Min(elapsed / 200.0, 1.0); var eased = 1.0 - Math.Pow(1.0 - progress, 3); var offset = currentOffset + diff * eased; - MessageScroll.ScrollToVerticalOffset(offset); + ScrollTranscriptToVerticalOffset(offset); if (progress >= 1.0) { @@ -1505,7 +1504,7 @@ public partial class ChatWindow : Window lock (_convLock) _currentConversation = conv; SyncTabConversationIdsFromSession(); SaveLastConversations(); - MessagePanel.Children.Clear(); + ClearTranscriptElements(); RenderMessages(); EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; UpdateChatTitle(); @@ -1520,7 +1519,7 @@ public partial class ChatWindow : Window _currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings) ?? new ChatConversation { Tab = _activeTab }; } - MessagePanel.Children.Clear(); + ClearTranscriptElements(); EmptyState.Visibility = Visibility.Visible; _attachedFiles.Clear(); RefreshAttachedFilesUI(); @@ -2298,8 +2297,8 @@ public partial class ChatWindow : Window private void RenderMessages(bool preserveViewport = false) { - var previousScrollableHeight = MessageScroll?.ScrollableHeight ?? 0; - var previousVerticalOffset = MessageScroll?.VerticalOffset ?? 0; + var previousScrollableHeight = GetTranscriptScrollableHeight(); + var previousVerticalOffset = GetTranscriptVerticalOffset(); ChatConversation? conv; lock (_convLock) conv = _currentConversation; @@ -2318,7 +2317,7 @@ public partial class ChatWindow : Window if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0)) { - MessagePanel.Children.Clear(); + ClearTranscriptElements(); _runBannerAnchors.Clear(); _lastRenderedTimelineKeys.Clear(); _lastRenderedMessageCount = 0; @@ -2355,13 +2354,13 @@ public partial class ChatWindow : Window // ── 증분 렌더링: 이전 키 목록과 비교하여 변경 최소화 ── // agent live card가 존재하면 Children과 키 불일치 가능 → 전체 재빌드 - var hasExternalChildren = _agentLiveContainer != null && MessagePanel.Children.Contains(_agentLiveContainer); + var hasExternalChildren = _agentLiveContainer != null && ContainsTranscriptElement(_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; + && GetTranscriptElementCount() == expectedChildCount; if (canIncremental) { @@ -2392,8 +2391,8 @@ public partial class ChatWindow : Window try { // 이전 live 요소를 Children 끝에서 제거 - for (int r = 0; r < prevLiveCount && MessagePanel.Children.Count > 0; r++) - MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1); + for (int r = 0; r < prevLiveCount && GetTranscriptElementCount() > 0; r++) + RemoveTranscriptElementAt(GetTranscriptElementCount() - 1); // 안정 접두사 이후의 새 아이템 렌더링 (새 stable + 새 live 포함) for (int i = prevStableCount; i < visibleTimeline.Count; i++) @@ -2418,18 +2417,18 @@ public partial class ChatWindow : Window if (!incremented) { // ── 전체 재빌드 (대화 전환, 접기/펴기, 중간 변경 등) ── - MessagePanel.Children.Clear(); + ClearTranscriptElements(); _runBannerAnchors.Clear(); if (hiddenCount > 0) - MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount)); + AddTranscriptElement(CreateTimelineLoadMoreCard(hiddenCount)); foreach (var item in visibleTimeline) item.Render(); // 전체 재빌드로 Children이 초기화되었으므로 agent live card 복원 - if (_agentLiveContainer != null && !MessagePanel.Children.Contains(_agentLiveContainer)) - MessagePanel.Children.Add(_agentLiveContainer); + if (_agentLiveContainer != null && !ContainsTranscriptElement(_agentLiveContainer)) + AddTranscriptElement(_agentLiveContainer); _lastRenderedTimelineKeys = newKeys; _lastRenderedHiddenCount = hiddenCount; @@ -2443,21 +2442,20 @@ public partial class ChatWindow : Window { _ = Dispatcher.InvokeAsync(() => { - if (MessageScroll != null) - MessageScroll.ScrollToEnd(); + ScrollTranscriptToEnd(); }, DispatcherPriority.Background); return; } _ = Dispatcher.InvokeAsync(() => { - if (MessageScroll == null) + if (_transcriptScrollViewer == null) return; - var newScrollableHeight = MessageScroll.ScrollableHeight; + var newScrollableHeight = GetTranscriptScrollableHeight(); var delta = newScrollableHeight - previousScrollableHeight; var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta)); - MessageScroll.ScrollToVerticalOffset(targetOffset); + ScrollTranscriptToVerticalOffset(targetOffset); }, DispatcherPriority.Background); } @@ -2598,7 +2596,7 @@ public partial class ChatWindow : Window if (now - _lastScrollTick >= 150) { _lastScrollTick = now; - MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight); + ScrollTranscriptToVerticalOffset(GetTranscriptScrollableHeight()); } } } @@ -2636,7 +2634,7 @@ public partial class ChatWindow : Window // 탭 기억 초기화 (새 대화이므로) _tabConversationId[_activeTab] = null; SyncTabConversationIdsToSession(); - MessagePanel.Children.Clear(); + ClearTranscriptElements(); _attachedFiles.Clear(); RefreshAttachedFilesUI(); LoadConversationSettings(); @@ -2688,7 +2686,7 @@ public partial class ChatWindow : Window { var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings); lock (_convLock) _currentConversation = conv; - MessagePanel.Children.Clear(); + ClearTranscriptElements(); RenderMessages(); EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible; UpdateChatTitle(); @@ -5910,7 +5908,7 @@ public partial class ChatWindow : Window _cachedStreamContent = ""; _displayedLength = 0; _cursorVisible = true; - MessagePanel.Children.Add(streamingContainer); + AddTranscriptElement(streamingContainer); ForceScrollToEnd(); _cursorTimer.Start(); _typingTimer.Start(); @@ -6028,7 +6026,7 @@ public partial class ChatWindow : Window private async Task ShowTypedAssistantPreviewAsync(string finalContent, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(finalContent) || MessagePanel == null) + if (string.IsNullOrWhiteSpace(finalContent)) return; // 라이브 카드 제거 (최종 메시지 버블 표시 전) @@ -6043,7 +6041,7 @@ public partial class ChatWindow : Window // 모든 상태를 로컬 변수로 관리하고 await Task.Delay로 UI 스레드 양보 var displayedLength = 0; var cursorVisible = true; - MessagePanel.Children.Add(container); + AddTranscriptElement(container); ForceScrollToEnd(); streamText.Text = "\u258c"; @@ -7019,7 +7017,7 @@ public partial class ChatWindow : Window private void ShowAgentLiveCard(string runTab) { - if (MessagePanel == null) return; + if (MessageList == null) return; if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return; RemoveAgentLiveCard(animated: false); // 기존 카드 즉시 제거 @@ -7127,7 +7125,7 @@ public partial class ChatWindow : Window container.Children.Add(card); _agentLiveContainer = container; - MessagePanel.Children.Add(container); + AddTranscriptElement(container); ForceScrollToEnd(); // 경과 시간 타이머 @@ -7199,15 +7197,15 @@ public partial class ChatWindow : Window _agentLiveSubItemTexts.Clear(); _agentLiveCurrentCategory = null; - if (animated && MessagePanel != null && MessagePanel.Children.Contains(toRemove) && !IsLightweightLiveProgressMode()) + if (animated && ContainsTranscriptElement(toRemove) && !IsLightweightLiveProgressMode()) { var anim = new DoubleAnimation(toRemove.Opacity, 0, TimeSpan.FromMilliseconds(160)); - anim.Completed += (_, _) => MessagePanel?.Children.Remove(toRemove); + anim.Completed += (_, _) => RemoveTranscriptElement(toRemove); toRemove.BeginAnimation(UIElement.OpacityProperty, anim); } else { - MessagePanel?.Children.Remove(toRemove); + RemoveTranscriptElement(toRemove); } } @@ -7505,7 +7503,7 @@ public partial class ChatWindow : Window }; var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; - if (MessagePanel != null + if (MessageList != null && string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase) && !IsLightweightLiveProgressMode(runTab)) ScheduleExecutionHistoryRender(autoScroll: false); @@ -7882,7 +7880,7 @@ public partial class ChatWindow : Window card.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); - MessagePanel.Children.Add(card); + AddTranscriptElement(card); } /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. @@ -8138,7 +8136,7 @@ public partial class ChatWindow : Window // 슬라이드 + 페이드 등장 애니메이션 ApplyMessageEntryAnimation(container); - MessagePanel.Children.Add(container); + AddTranscriptElement(container); ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동 // PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기 @@ -8726,7 +8724,7 @@ public partial class ChatWindow : Window { var feedback = textBox.Text.Trim(); if (string.IsNullOrEmpty(feedback)) return; - MessagePanel.Children.Remove(container); + RemoveTranscriptElement(container); _ = RetryWithFeedbackAsync(feedback); }; @@ -8738,7 +8736,7 @@ public partial class ChatWindow : Window Cursor = Cursors.Hand, }; cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText }; - cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container); + cancelBtn.MouseLeftButtonUp += (_, _) => RemoveTranscriptElement(container); btnRow.Children.Add(cancelBtn); btnRow.Children.Add(sendBtn); @@ -8746,7 +8744,7 @@ public partial class ChatWindow : Window container.Child = stack; ApplyMessageEntryAnimation(container); - MessagePanel.Children.Add(container); + AddTranscriptElement(container); ForceScrollToEnd(); textBox.Focus(); } @@ -8827,7 +8825,7 @@ public partial class ChatWindow : Window if (hostWidth < 100) hostWidth = ComposerShell?.ActualWidth ?? 0; if (hostWidth < 100) - hostWidth = MessageScroll?.ActualWidth ?? 0; + hostWidth = MessageList?.ActualWidth ?? 0; if (hostWidth < 100) hostWidth = 1120; @@ -8837,7 +8835,7 @@ public partial class ChatWindow : Window private bool UpdateResponsiveChatLayout() { - var viewportWidth = MessageScroll?.ActualWidth ?? 0; + var viewportWidth = MessageList?.ActualWidth ?? 0; if (viewportWidth < 200) viewportWidth = ActualWidth; if (viewportWidth < 200) @@ -8859,8 +8857,8 @@ public partial class ChatWindow : Window if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 1) { _lastResponsiveMessageWidth = messageWidth; - if (MessagePanel != null) - MessagePanel.MaxWidth = messageWidth; + if (MessageList != null) + MessageList.MaxWidth = messageWidth + 48; if (EmptyState != null) EmptyState.MaxWidth = messageWidth; changed = true; @@ -9646,15 +9644,15 @@ public partial class ChatWindow : Window // MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤 // 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로 // (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동 - if (msgIndex < MessagePanel.Children.Count) + if (msgIndex < GetTranscriptElementCount()) { - var element = MessagePanel.Children[msgIndex] as FrameworkElement; + var element = GetTranscriptElementAt(msgIndex) as FrameworkElement; element?.BringIntoView(); } - else if (MessagePanel.Children.Count > 0) + else if (GetTranscriptElementCount() > 0) { // 범위 밖이면 마지막 자식으로 이동 - (MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView(); + (GetTranscriptElementAt(GetTranscriptElementCount() - 1) as FrameworkElement)?.BringIntoView(); } } @@ -14604,7 +14602,7 @@ public partial class ChatWindow : Window _currentConversation = null; SyncTabConversationIdsFromSession(); } - MessagePanel.Children.Clear(); + ClearTranscriptElements(); EmptyState.Visibility = Visibility.Visible; UpdateChatTitle(); RefreshConversationList();