AX Agent transcript 호스트를 컬렉션 기반으로 재구성해 코워크·코드 스트리밍 중 UI 부하를 줄임
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:
2026-04-09 00:00:37 +09:00
parent 8db1a1ffb0
commit 74d43e701c
11 changed files with 235 additions and 86 deletions

View File

@@ -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();