AX Agent 라이브 진행 표시 회귀 복구 및 본문 선택 유지 정리

상단 라이브 진행 카드를 이전 단계형 구조로 복구하고 스트리밍 중 현재 실행 이벤트가 본문 타임라인에 중복 표시되지 않도록 V2 렌더 컷오프를 다시 적용했습니다.

사용자 말풍선은 기존 마크다운 렌더로 되돌려 세로로 깨지던 표시를 해결하고, 어시스턴트 본문과 스트리밍 완료 본문은 계속 드래그 선택/복사가 가능하도록 유지했습니다. 또한 SkillRuntime, allowed_tools, 메인 루프 요청, 읽기 도구 조기 실행 준비, 스트리밍 도구 감지 같은 저신호 내부 문구를 추가 필터링해 화면 노이즈를 줄였습니다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_restore\\ -p:IntermediateOutputPath=obj\\verify_live_restore\\ 에서 경고 0 오류 0을 확인했습니다.

검증: AgentLoopCodeQualityTests, AgentStatusNarrativeCatalogTests, AgentProgressSummarySanitizerTests 필터로 dotnet test를 실행해 131개 테스트 통과를 확인했습니다.
This commit is contained in:
2026-04-15 18:56:53 +09:00
parent 53838a046b
commit 5ab04bc53e
7 changed files with 296 additions and 131 deletions

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
@@ -15,7 +16,7 @@ public partial class ChatWindow
private DateTime _v2LiveStartTime;
private TextBlock? _v2LiveElapsedText;
/// <summary>V2: 스트리밍 시작 시 상단 라이브 진행 요약 카드를 생성합니다.</summary>
/// <summary>V2: 스트리밍 시작 시 라이브 진행 컨테이너 생성</summary>
private void ShowAgentLiveCardV2(string runTab)
{
if (MessageList == null) return;
@@ -24,13 +25,11 @@ public partial class ChatWindow
RemoveAgentLiveCardV2(animated: false);
_v2LiveStartTime = DateTime.UtcNow;
_v2LiveToolCards.Clear();
_v2LastLiveToolCallId = null;
var msgMaxWidth = GetMessageMaxWidth();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
_v2LiveContainer = new StackPanel
{
@@ -52,12 +51,8 @@ public partial class ChatWindow
{
var animState = new ChatIconAnimState
{
Host = liveIconHost,
Canvas = canvas,
Pixels = livePixels,
Glows = liveGlows,
Rotate = liveRotate,
Scale = liveScale,
Host = liveIconHost, Canvas = canvas, Pixels = livePixels,
Glows = liveGlows, Rotate = liveRotate, Scale = liveScale,
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
};
StartChatIconAnimation(animState);
@@ -83,7 +78,7 @@ public partial class ChatWindow
Text = "",
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.7,
Opacity = 0.70,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
@@ -92,39 +87,6 @@ public partial class ChatWindow
_v2LiveContainer.Children.Add(headerGrid);
var summaryCard = new Border
{
Background = hintBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10, 12, 10),
};
var summaryStack = new StackPanel();
_v2LiveStatusText = new TextBlock
{
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
};
summaryStack.Children.Add(_v2LiveStatusText);
_v2LiveDetailText = new TextBlock
{
FontSize = 10.5,
Foreground = secondaryText,
Opacity = 0.86,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 0),
};
summaryStack.Children.Add(_v2LiveDetailText);
summaryCard.Child = summaryStack;
_v2LiveContainer.Children.Add(summaryCard);
ApplyV2LiveNarrative(AgentStatusNarrativeCatalog.BuildInitial(runTab));
AddTranscriptElement(_v2LiveContainer);
ForceScrollToEnd();
@@ -138,41 +100,225 @@ public partial class ChatWindow
_v2LiveElapsedTimer.Start();
}
/// <summary>V2: 라이브 카드는 1~2줄 요약만 유지하고, 상세 실행 이력은 본문 타임라인에 누적합니다.</summary>
/// <summary>V2: 에이전트 이벤트 수신 시 라이브 카드 업데이트</summary>
private void UpdateAgentLiveCardV2(AgentEvent agentEvent)
{
if (_v2LiveContainer == null)
return;
if (_v2LiveContainer == null) return;
if (agentEvent.Type == AgentEventType.Thinking
&& !string.Equals(agentEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(agentEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)
&& AgentProgressSummarySanitizer.IsLowSignalStatusSummary(agentEvent.Summary, agentEvent.ToolName))
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var msgMaxWidth = GetMessageMaxWidth();
switch (agentEvent.Type)
{
return;
case AgentEventType.ToolCall:
{
var (icon, iconColor) = GetV2ToolIcon(agentEvent.ToolName);
var toolId = $"{agentEvent.ToolName}_{agentEvent.Timestamp.Ticks}";
_v2LastLiveToolCallId = toolId;
var outerGrid = new Grid { Margin = new Thickness(0, 2, 0, 2) };
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var accentColor = ResolveLiveProgressAccentColor(
TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue);
var pulseLine = new Border
{
Width = 2,
Background = new SolidColorBrush(Color.FromArgb(0x80, accentColor.R, accentColor.G, accentColor.B)),
CornerRadius = new CornerRadius(1),
Margin = new Thickness(12, 0, 8, 0),
};
var pulseAnim = new DoubleAnimation(0.4, 1.0, TimeSpan.FromMilliseconds(800))
{
AutoReverse = true,
RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new SineEase(),
};
pulseLine.BeginAnimation(UIElement.OpacityProperty, pulseAnim);
Grid.SetColumn(pulseLine, 0);
outerGrid.Children.Add(pulseLine);
var card = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x1C, accentColor.R, accentColor.G, accentColor.B)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 6, 10, 6),
Tag = "pending",
};
Grid.SetColumn(card, 1);
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon,
FontSize = 12,
Foreground = iconColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
sp.Children.Add(new TextBlock
{
Text = GetV2ToolDisplayName(agentEvent.ToolName),
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
if (!string.IsNullOrWhiteSpace(agentEvent.FilePath))
{
sp.Children.Add(new TextBlock
{
Text = TruncateFilePath(agentEvent.FilePath, 50),
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.7,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
});
}
sp.Children.Add(new TextBlock
{
Text = "...",
FontSize = 11,
Foreground = secondaryText,
Opacity = 0.5,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(6, 0, 0, 0),
});
card.Child = sp;
outerGrid.Children.Add(card);
_v2LiveToolCards[toolId] = card;
_v2LiveContainer.Children.Add(outerGrid);
AutoScrollIfNeeded();
break;
}
case AgentEventType.ToolResult:
{
if (_v2LastLiveToolCallId != null && _v2LiveToolCards.TryGetValue(_v2LastLiveToolCallId, out var pendingCard))
{
var isSuccess = agentEvent.Success;
var statusIcon = isSuccess ? "\uE73E" : "\uE711";
var statusColor = isSuccess
? new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A))
: new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50));
pendingCard.Background = isSuccess
? new SolidColorBrush(Color.FromArgb(0x0A, 0x66, 0xBB, 0x6A))
: new SolidColorBrush(Color.FromArgb(0x0A, 0xEF, 0x53, 0x50));
pendingCard.BorderBrush = isSuccess
? new SolidColorBrush(Color.FromArgb(0x30, 0x66, 0xBB, 0x6A))
: new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x53, 0x50));
pendingCard.Tag = "complete";
if (pendingCard.Child is StackPanel sp)
{
if (sp.Children.Count > 0 && sp.Children[^1] is TextBlock lastTb && lastTb.Text == "...")
sp.Children.RemoveAt(sp.Children.Count - 1);
sp.Children.Add(new TextBlock
{
Text = statusIcon,
FontFamily = s_segoeIconFont,
FontSize = 11,
Foreground = statusColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(6, 0, 0, 0),
});
var elapsed = NormalizeProgressElapsedMs(agentEvent.ElapsedMs);
if (elapsed > 0)
{
sp.Children.Add(new TextBlock
{
Text = $"{elapsed / 1000.0:F1}s",
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.6,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
});
}
}
var parent = pendingCard.Parent as Grid;
if (parent?.Children[0] is Border pulseLine)
{
pulseLine.BeginAnimation(UIElement.OpacityProperty, null);
pulseLine.Opacity = 1;
pulseLine.Background = isSuccess
? new SolidColorBrush(Color.FromArgb(0x60, 0x66, 0xBB, 0x6A))
: new SolidColorBrush(Color.FromArgb(0x60, 0xEF, 0x53, 0x50));
}
var cardToCollapse = pendingCard;
var outerGridToCollapse = parent;
var collapseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1200) };
collapseTimer.Tick += (_, _) =>
{
collapseTimer.Stop();
if (cardToCollapse == null) return;
var padAnim = new ThicknessAnimation(
cardToCollapse.Padding,
new Thickness(8, 2, 8, 2),
TimeSpan.FromMilliseconds(200))
{ EasingFunction = new QuadraticEase() };
var opAnim = new DoubleAnimation(1.0, 0.55, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new QuadraticEase() };
cardToCollapse.BeginAnimation(Border.PaddingProperty, padAnim);
cardToCollapse.BeginAnimation(UIElement.OpacityProperty, opAnim);
if (outerGridToCollapse != null)
outerGridToCollapse.Margin = new Thickness(0, 1, 0, 1);
};
collapseTimer.Start();
_v2LastLiveToolCallId = null;
}
break;
}
case AgentEventType.Thinking:
{
var thinkText = AgentProgressSummarySanitizer.NormalizeThinkingSummary(
agentEvent.Summary,
agentEvent.ToolName,
maxLength: 100);
if (string.IsNullOrWhiteSpace(thinkText)) break;
var thinkRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(22, 2, 0, 2),
};
thinkRow.Children.Add(new TextBlock
{
Text = "\uE915",
FontFamily = s_segoeIconFont,
FontSize = 10,
Foreground = new SolidColorBrush(Color.FromRgb(0x59, 0xA5, 0xF5)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
thinkRow.Children.Add(new TextBlock
{
Text = thinkText,
FontSize = 10.5,
FontStyle = FontStyles.Italic,
Foreground = secondaryText,
Opacity = 0.82,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = msgMaxWidth - 60,
});
_v2LiveContainer.Children.Add(thinkRow);
AutoScrollIfNeeded();
break;
}
}
ApplyV2LiveNarrative(AgentStatusNarrativeCatalog.BuildFromEvent(agentEvent, _activeTab));
AutoScrollIfNeeded();
}
private void ApplyV2LiveNarrative(AgentStatusNarrative narrative)
{
if (_v2LiveStatusText != null)
_v2LiveStatusText.Text = narrative.Message;
if (_v2LiveDetailText == null)
return;
if (string.IsNullOrWhiteSpace(narrative.Detail))
{
_v2LiveDetailText.Text = string.Empty;
_v2LiveDetailText.Visibility = Visibility.Collapsed;
return;
}
_v2LiveDetailText.Text = narrative.Detail;
_v2LiveDetailText.Visibility = Visibility.Visible;
}
/// <summary>V2: 스트리밍 종료 시 라이브 카드 제거</summary>
@@ -181,8 +327,6 @@ public partial class ChatWindow
_v2LiveElapsedTimer?.Stop();
_v2LiveElapsedTimer = null;
_v2LiveElapsedText = null;
_v2LiveStatusText = null;
_v2LiveDetailText = null;
if (_v2LiveContainer == null) return;
@@ -190,6 +334,8 @@ public partial class ChatWindow
var toRemove = _v2LiveContainer;
_v2LiveContainer = null;
_v2LiveToolCards.Clear();
_v2LastLiveToolCallId = null;
if (animated && ContainsTranscriptElement(toRemove))
{