구조 개선: transcript 지연 가상화와 tool executor 분리 적용

이번 변경은 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
This commit is contained in:
2026-04-09 01:37:08 +09:00
parent 4f3c19c15c
commit 7931566212
15 changed files with 417 additions and 218 deletions

View File

@@ -0,0 +1,208 @@
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);
}
}

View File

@@ -10,6 +10,16 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
private UIElement CreateMessageBubbleElement(string role, string content, bool animate = true, ChatMessage? message = null)
{
var beforeCount = GetTranscriptElementCount();
AddMessageBubble(role, content, animate, message);
var element = GetTranscriptElementAt(beforeCount)
?? throw new InvalidOperationException("메시지 버블을 생성하지 못했습니다.");
RemoveTranscriptElementAt(beforeCount);
return element;
}
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
{
var isUser = role == "user";

View File

@@ -116,11 +116,21 @@ public partial class ChatWindow
var cacheKey = $"m_{msg.MsgId}";
timeline.Add((cacheKey, msg.Timestamp, 0, () =>
{
// 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성)
if (_elementCache.TryGetValue(cacheKey, out var cached))
AddTranscriptElement(cached);
else
AddMessageBubble(capturedMsg.Role, capturedMsg.Content, animate: false, message: capturedMsg);
AddDeferredTranscriptElement(
cacheKey,
() =>
{
if (_elementCache.TryGetValue(cacheKey, out var cached))
return cached;
var element = CreateMessageBubbleElement(
capturedMsg.Role,
capturedMsg.Content,
animate: false,
message: capturedMsg);
_elementCache[cacheKey] = element;
return element;
});
}));
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
@@ -7,7 +9,8 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
private readonly ObservableCollection<UIElement> _transcriptElements = [];
private readonly ObservableCollection<TranscriptVisualItem> _transcriptElements = [];
private readonly Dictionary<UIElement, TranscriptVisualItem> _transcriptElementMap = new();
private ScrollViewer? _transcriptScrollViewer;
private bool _transcriptScrollHooked;
@@ -55,6 +58,8 @@ public partial class ChatWindow
return null;
}
private static string CreateTranscriptElementKey() => $"ui_{Guid.NewGuid():N}";
private int GetTranscriptElementCount() => _transcriptElements.Count;
private UIElement? GetTranscriptElementAt(int index)
@@ -62,24 +67,73 @@ public partial class ChatWindow
if (index < 0 || index >= _transcriptElements.Count)
return null;
return _transcriptElements[index];
return _transcriptElements[index].GetOrCreateElement();
}
private int GetTranscriptElementIndex(UIElement element) => _transcriptElements.IndexOf(element);
private int GetTranscriptElementIndex(UIElement element)
{
if (_transcriptElementMap.TryGetValue(element, out var item))
return _transcriptElements.IndexOf(item);
private bool ContainsTranscriptElement(UIElement element) => _transcriptElements.Contains(element);
for (var i = 0; i < _transcriptElements.Count; i++)
{
var candidate = _transcriptElements[i];
if (!candidate.IsMaterialized)
continue;
private void AddTranscriptElement(UIElement element) => _transcriptElements.Add(element);
if (ReferenceEquals(candidate.Element, element))
{
_transcriptElementMap[element] = candidate;
return i;
}
}
private void ClearTranscriptElements() => _transcriptElements.Clear();
return -1;
}
private void RemoveTranscriptElement(UIElement element) => _transcriptElements.Remove(element);
private bool ContainsTranscriptElement(UIElement element) => GetTranscriptElementIndex(element) >= 0;
private TranscriptVisualItem WrapTranscriptElement(string key, UIElement element)
{
var item = new TranscriptVisualItem(key, element);
_transcriptElementMap[element] = item;
return item;
}
private void AddTranscriptElement(UIElement element)
=> _transcriptElements.Add(WrapTranscriptElement(CreateTranscriptElementKey(), element));
private void AddDeferredTranscriptElement(string key, Func<UIElement> elementFactory)
{
TranscriptVisualItem? item = null;
item = new TranscriptVisualItem(
key,
elementFactory,
element => _transcriptElementMap[element] = item!);
_transcriptElements.Add(item);
}
private void ClearTranscriptElements()
{
_transcriptElements.Clear();
_transcriptElementMap.Clear();
}
private void RemoveTranscriptElement(UIElement element)
{
var index = GetTranscriptElementIndex(element);
if (index >= 0)
RemoveTranscriptElementAt(index);
}
private void RemoveTranscriptElementAt(int index)
{
if (index < 0 || index >= _transcriptElements.Count)
return;
var item = _transcriptElements[index];
if (item.Element != null)
_transcriptElementMap.Remove(item.Element);
_transcriptElements.RemoveAt(index);
}
@@ -88,7 +142,11 @@ public partial class ChatWindow
if (index < 0 || index >= _transcriptElements.Count)
return;
_transcriptElements[index] = element;
var previous = _transcriptElements[index];
if (previous.Element != null)
_transcriptElementMap.Remove(previous.Element);
_transcriptElements[index] = WrapTranscriptElement(CreateTranscriptElementKey(), element);
}
private double GetTranscriptScrollableHeight() => _transcriptScrollViewer?.ScrollableHeight ?? 0;

View File

@@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
xmlns:local="clr-namespace:AxCopilot.Views"
Title="AX Copilot — AX Agent"
Width="1180" Height="880"
MinWidth="780" MinHeight="560"
@@ -1378,6 +1379,11 @@
<VirtualizingStackPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type local:TranscriptVisualItem}">
<local:TranscriptVisualHost/>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Focusable" Value="False"/>

View File

@@ -3530,6 +3530,7 @@ public partial class ChatWindow : Window
private void ShowDropActionMenu(string[] files)
{
if (files == null || files.Length == 0) return;
// 파일 유형 판별
var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant();
string category;
@@ -4243,7 +4244,9 @@ public partial class ChatWindow : Window
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, command, StringComparison.OrdinalIgnoreCase))
return ("open", "");
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length <= 1)
if (parts.Length == 0)
return ("open", "");
if (parts.Length == 1)
return (parts[0].Trim().ToLowerInvariant(), "");
return (parts[0].Trim().ToLowerInvariant(), string.Join(' ', parts.Skip(1)).Trim());
}
@@ -5515,7 +5518,8 @@ public partial class ChatWindow : Window
}
private AgentLoopService GetAgentLoop(string tab) =>
_agentLoops.TryGetValue(tab, out var loop) ? loop : _agentLoops.Values.First();
_agentLoops.TryGetValue(tab, out var loop) ? loop : _agentLoops.Values.FirstOrDefault()
?? throw new InvalidOperationException($"No agent loop registered for tab '{tab}'");
private async Task<string> RunAgentLoopAsync(
string runTab,
@@ -6840,200 +6844,6 @@ public partial class ChatWindow : Window
// ─── 채팅창 내 에이전트 라이브 진행 카드 ─────────────────────────────────────
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 accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
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 System.Windows.Media.Animation.QuadraticEase
{ EasingMode = System.Windows.Media.Animation.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 = System.Windows.Media.Animation.RepeatBehavior.Forever,
EasingFunction = new System.Windows.Media.Animation.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 System.Windows.Media.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)
{
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))
{
_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 System.Windows.Media.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);
}
else
{
RemoveTranscriptElement(toRemove);
}
}
// ─── 미니 다이아몬드 아이콘 애니메이션 ───────────────────────────────────────
private void StartStatusDiamondAnimation()

View File

@@ -0,0 +1,30 @@
using System.Windows;
using System.Windows.Controls;
namespace AxCopilot.Views;
public sealed class TranscriptVisualHost : ContentControl
{
public TranscriptVisualHost()
{
DataContextChanged += (_, _) => SyncContent();
Loaded += (_, _) => SyncContent();
}
private void SyncContent()
{
if (DataContext is TranscriptVisualItem item)
{
Content = item.GetOrCreateElement();
return;
}
if (DataContext is UIElement element)
{
Content = element;
return;
}
Content = null;
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Windows;
namespace AxCopilot.Views;
public sealed class TranscriptVisualItem
{
private readonly Func<UIElement>? _factory;
private readonly Action<UIElement>? _onMaterialized;
private UIElement? _element;
public TranscriptVisualItem(string key, UIElement element)
{
Key = key;
_element = element;
}
public TranscriptVisualItem(string key, Func<UIElement> factory, Action<UIElement>? onMaterialized = null)
{
Key = key;
_factory = factory;
_onMaterialized = onMaterialized;
}
public string Key { get; }
public bool IsMaterialized => _element != null;
public UIElement? Element => _element;
public UIElement GetOrCreateElement()
{
if (_element != null)
return _element;
if (_factory == null)
throw new InvalidOperationException("Transcript visual factory is not available.");
_element = _factory();
_onMaterialized?.Invoke(_element);
return _element;
}
}