AX Agent transcript 호스트를 컬렉션 기반으로 재구성해 코워크·코드 스트리밍 중 UI 부하를 줄임
Some checks failed
Release Gate / gate (push) Has been cancelled
Some checks failed
Release Gate / gate (push) Has been cancelled
- 메시지 영역을 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)
This commit is contained in:
@@ -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<UIElement>` 기반으로 옮겼습니다.
|
||||
- [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을 줄였습니다.
|
||||
|
||||
@@ -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<UIElement>` 기반 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을 더 강하게 적용할 수 있다.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -418,7 +418,7 @@ public partial class ChatWindow
|
||||
if (_currentConversation?.Id == conversationId)
|
||||
{
|
||||
_currentConversation = null;
|
||||
MessagePanel.Children.Clear();
|
||||
ClearTranscriptElements();
|
||||
EmptyState.Visibility = Visibility.Visible;
|
||||
UpdateChatTitle();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
101
src/AxCopilot/Views/ChatWindow.TranscriptHost.cs
Normal file
101
src/AxCopilot/Views/ChatWindow.TranscriptHost.cs
Normal file
@@ -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<UIElement> _transcriptElements = [];
|
||||
private ScrollViewer? _transcriptScrollViewer;
|
||||
private bool _transcriptScrollHooked;
|
||||
|
||||
private void InitializeTranscriptHost()
|
||||
{
|
||||
MessageList.ItemsSource = _transcriptElements;
|
||||
AttachTranscriptScrollChanged();
|
||||
MessageList.Loaded += (_, _) => AttachTranscriptScrollChanged();
|
||||
}
|
||||
|
||||
private void AttachTranscriptScrollChanged()
|
||||
{
|
||||
var scrollViewer = FindVisualChild<ScrollViewer>(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<T>(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<T>(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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1358,22 +1358,43 @@
|
||||
</Border>
|
||||
|
||||
<!-- ── 메시지 스크롤 ── -->
|
||||
<ScrollViewer x:Name="MessageScroll" Grid.Row="3"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
Background="{DynamicResource LauncherBackground}"
|
||||
Padding="24,12,24,8"
|
||||
UseLayoutRounding="True">
|
||||
<StackPanel x:Name="MessagePanel"
|
||||
Margin="0,0,0,8"
|
||||
MaxWidth="960"
|
||||
HorizontalAlignment="Center"
|
||||
UseLayoutRounding="True">
|
||||
<StackPanel.RenderTransform>
|
||||
<TranslateTransform/>
|
||||
</StackPanel.RenderTransform>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<ListBox x:Name="MessageList" Grid.Row="3"
|
||||
Background="{DynamicResource LauncherBackground}"
|
||||
BorderThickness="0"
|
||||
Padding="24,12,24,8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.CanContentScroll="True"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||
VirtualizingPanel.ScrollUnit="Pixel"
|
||||
UseLayoutRounding="True">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Focusable" Value="False"/>
|
||||
<Setter Property="IsTabStop" Value="False"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Margin" Value="0"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<ContentPresenter/>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
</ListBox>
|
||||
|
||||
<!-- ── 스트리밍 상태 바 (Claude 스타일 하단 플로팅) ── -->
|
||||
<Border x:Name="StreamingStatusBar" Grid.Row="3"
|
||||
@@ -5838,4 +5859,3 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
|
||||
|
||||
@@ -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
|
||||
/// <summary>부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user