이번 변경은 claude-code 기준 구조 강건성을 높이기 위한 리팩터링입니다. 핵심 수정 사항: - AX Agent transcript를 TranscriptVisualItem/TranscriptVisualHost 기반 지연 materialization 구조로 전환해 MessageList 가상화 기반을 강화했습니다. - StreamingToolExecutionCoordinator를 IToolExecutionCoordinator 뒤로 분리해 AgentLoopService가 구체 구현에 직접 묶이지 않도록 정리했습니다. - 라이브 진행 카드 렌더를 ChatWindow.LiveProgressPresentation partial로 이동해 ChatWindow.xaml.cs의 책임을 더 줄였습니다. - 기존 메시지 bubble 조립 로직을 유지하면서 transcript host가 필요 시점에 bubble을 만들 수 있도록 helper 경로를 추가했습니다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\ - 경고 0 / 오류 0
209 lines
7.4 KiB
C#
209 lines
7.4 KiB
C#
using System;
|
||
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Media;
|
||
using System.Windows.Media.Animation;
|
||
using System.Windows.Threading;
|
||
|
||
namespace AxCopilot.Views;
|
||
|
||
public partial class ChatWindow
|
||
{
|
||
private void ShowAgentLiveCard(string runTab)
|
||
{
|
||
if (MessageList == null) return;
|
||
if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return;
|
||
|
||
RemoveAgentLiveCard(animated: false);
|
||
|
||
_agentLiveStartTime = DateTime.UtcNow;
|
||
_agentLiveSubItemTexts.Clear();
|
||
_agentLiveCurrentCategory = null;
|
||
|
||
var msgMaxWidth = GetMessageMaxWidth();
|
||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||
|
||
var container = new StackPanel
|
||
{
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
Width = msgMaxWidth,
|
||
MaxWidth = msgMaxWidth,
|
||
Margin = new Thickness(0, 4, 0, 6),
|
||
Opacity = IsLightweightLiveProgressMode(runTab) ? 1 : 0,
|
||
RenderTransform = IsLightweightLiveProgressMode(runTab)
|
||
? Transform.Identity
|
||
: new TranslateTransform(0, 8),
|
||
};
|
||
if (!IsLightweightLiveProgressMode(runTab))
|
||
{
|
||
container.BeginAnimation(UIElement.OpacityProperty,
|
||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(260)));
|
||
((TranslateTransform)container.RenderTransform).BeginAnimation(
|
||
TranslateTransform.YProperty,
|
||
new DoubleAnimation(8, 0, TimeSpan.FromMilliseconds(280))
|
||
{
|
||
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
|
||
});
|
||
}
|
||
|
||
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 3) };
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||
|
||
var liveIcon = CreateMiniLauncherIcon(pixelSize: 4.0);
|
||
if (!IsLightweightLiveProgressMode(runTab))
|
||
{
|
||
liveIcon.BeginAnimation(
|
||
UIElement.OpacityProperty,
|
||
new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(750))
|
||
{
|
||
AutoReverse = true,
|
||
RepeatBehavior = RepeatBehavior.Forever,
|
||
EasingFunction = new SineEase()
|
||
});
|
||
}
|
||
|
||
Grid.SetColumn(liveIcon, 0);
|
||
headerGrid.Children.Add(liveIcon);
|
||
|
||
var (agentName, _, _) = GetAgentIdentity();
|
||
var nameTb = new TextBlock
|
||
{
|
||
Text = agentName,
|
||
FontSize = 11,
|
||
FontWeight = FontWeights.SemiBold,
|
||
Foreground = secondaryText,
|
||
Margin = new Thickness(6, 0, 0, 0),
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(nameTb, 1);
|
||
headerGrid.Children.Add(nameTb);
|
||
|
||
_agentLiveElapsedText = new TextBlock
|
||
{
|
||
Text = "",
|
||
FontSize = 10,
|
||
Foreground = secondaryText,
|
||
Opacity = 0.50,
|
||
HorizontalAlignment = HorizontalAlignment.Right,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
};
|
||
Grid.SetColumn(_agentLiveElapsedText, 2);
|
||
headerGrid.Children.Add(_agentLiveElapsedText);
|
||
container.Children.Add(headerGrid);
|
||
|
||
var card = new Border
|
||
{
|
||
BorderBrush = borderBrush,
|
||
BorderThickness = new Thickness(1),
|
||
CornerRadius = new CornerRadius(12),
|
||
Padding = new Thickness(13, 10, 13, 10),
|
||
};
|
||
card.SetResourceReference(Border.BackgroundProperty, "ItemBackground");
|
||
|
||
var cardStack = new StackPanel();
|
||
_agentLiveStatusText = new TextBlock
|
||
{
|
||
Text = "준비 중...",
|
||
FontSize = 12,
|
||
FontFamily = new FontFamily("Segoe UI, Malgun Gothic"),
|
||
Foreground = secondaryText,
|
||
TextWrapping = TextWrapping.Wrap,
|
||
};
|
||
cardStack.Children.Add(_agentLiveStatusText);
|
||
|
||
_agentLiveSubItems = new StackPanel { Margin = new Thickness(0, 6, 0, 0) };
|
||
cardStack.Children.Add(_agentLiveSubItems);
|
||
|
||
card.Child = cardStack;
|
||
container.Children.Add(card);
|
||
|
||
_agentLiveContainer = container;
|
||
AddTranscriptElement(container);
|
||
ForceScrollToEnd();
|
||
|
||
_agentLiveElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||
_agentLiveElapsedTimer.Tick += (_, _) =>
|
||
{
|
||
if (_agentLiveElapsedText == null)
|
||
return;
|
||
|
||
var sec = (int)(DateTime.UtcNow - _agentLiveStartTime).TotalSeconds;
|
||
_agentLiveElapsedText.Text = sec > 0 ? $"{sec}초 경과" : "";
|
||
};
|
||
_agentLiveElapsedTimer.Start();
|
||
}
|
||
|
||
private void UpdateAgentLiveCard(string message, string? subItem = null,
|
||
string? category = null, bool clearSubItems = false)
|
||
{
|
||
if (_agentLiveContainer == null || _agentLiveStatusText == null) return;
|
||
|
||
_agentLiveStatusText.Text = message;
|
||
|
||
if (clearSubItems || (category != null && category != _agentLiveCurrentCategory))
|
||
{
|
||
_agentLiveSubItemTexts.Clear();
|
||
_agentLiveSubItems?.Children.Clear();
|
||
if (category != null)
|
||
_agentLiveCurrentCategory = category;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(subItem) || _agentLiveSubItemTexts.Contains(subItem))
|
||
return;
|
||
|
||
_agentLiveSubItemTexts.Add(subItem);
|
||
const int maxLiveSubItems = 8;
|
||
if (_agentLiveSubItemTexts.Count > maxLiveSubItems)
|
||
{
|
||
_agentLiveSubItemTexts.RemoveAt(0);
|
||
if (_agentLiveSubItems?.Children.Count > 0)
|
||
_agentLiveSubItems.Children.RemoveAt(0);
|
||
}
|
||
|
||
var secondary = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||
var tb = new TextBlock
|
||
{
|
||
Text = $"› {subItem}",
|
||
FontSize = 10.5,
|
||
FontFamily = new FontFamily("Segoe UI, Malgun Gothic"),
|
||
Foreground = secondary,
|
||
Opacity = 0.62,
|
||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||
MaxWidth = 520,
|
||
Margin = new Thickness(0, 1, 0, 0),
|
||
};
|
||
_agentLiveSubItems?.Children.Add(tb);
|
||
ForceScrollToEnd();
|
||
}
|
||
|
||
private void RemoveAgentLiveCard(bool animated = true)
|
||
{
|
||
_agentLiveElapsedTimer?.Stop();
|
||
_agentLiveElapsedTimer = null;
|
||
|
||
if (_agentLiveContainer == null)
|
||
return;
|
||
|
||
var toRemove = _agentLiveContainer;
|
||
_agentLiveContainer = null;
|
||
_agentLiveStatusText = null;
|
||
_agentLiveSubItems = null;
|
||
_agentLiveElapsedText = null;
|
||
_agentLiveSubItemTexts.Clear();
|
||
_agentLiveCurrentCategory = null;
|
||
|
||
if (animated && ContainsTranscriptElement(toRemove) && !IsLightweightLiveProgressMode())
|
||
{
|
||
var anim = new DoubleAnimation(toRemove.Opacity, 0, TimeSpan.FromMilliseconds(160));
|
||
anim.Completed += (_, _) => RemoveTranscriptElement(toRemove);
|
||
toRemove.BeginAnimation(UIElement.OpacityProperty, anim);
|
||
return;
|
||
}
|
||
|
||
RemoveTranscriptElement(toRemove);
|
||
}
|
||
}
|