Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs
lacvet 82e58bde57 동시 Cowork·Code 실행 시 메인 루프 상태 혼선을 탭별로 분리
Cowork와 Code를 동시에 실행할 때 메인 루프 번호와 라이브 진행 힌트가 서로 섞이던 문제를 수정했다. ChatWindow가 전역 단일 진행 상태를 공유하던 구조를 탭별 현재 run 상태, 진행 스텝, 라이브 힌트, 대기 UI 이벤트로 분리해 현재 탭 기준으로만 렌더링하도록 정리했다.

AppStateService에 탭별 최신 run 상태 추적을 추가하고 ConversationList, TaskSummary, Timeline, V2 라이브 카드가 활성 탭의 run 메타를 읽도록 변경했다. AppStateServiceTests에 탭별 run iteration 분리 회귀 테스트를 추가했고 README와 DEVELOPMENT 문서에도 2026-04-15 22:25 (KST) 기준 이력을 반영했다.

검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_tab_loop_isolation\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation\\ (경고 0 / 오류 0)
검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AppStateServiceTests" -p:OutputPath=bin\\verify_tab_loop_isolation_tests\\ -p:IntermediateOutputPath=obj\\verify_tab_loop_isolation_tests\\ (통과 45)
2026-04-15 22:26:10 +09:00

467 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class ChatWindow
{
private DispatcherTimer? _v2LiveElapsedTimer;
private DateTime _v2LiveStartTime;
private TextBlock? _v2LiveElapsedText;
private Border? _v2LiveStatusCard;
private TextBlock? _v2LiveStatusText;
private TextBlock? _v2LiveStatusDetailText;
private TextBlock? _v2LiveStatusMetaText;
/// <summary>V2: 스트리밍 시작 시 라이브 진행 컨테이너 생성</summary>
private void ShowAgentLiveCardV2(string runTab)
{
if (MessageList == null) return;
if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase)) return;
RemoveAgentLiveCardV2(animated: false);
_v2LiveStartTime = DateTime.UtcNow;
_v2LiveToolCards.Clear();
_v2LastLiveToolCallId = null;
var msgMaxWidth = GetMessageMaxWidth();
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var accentColor = ResolveLiveProgressAccentColor(accentBrush);
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
_v2LiveContainer = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
Width = msgMaxWidth,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0, 4, 0, 6),
};
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 4) };
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 (agentName, _, _) = GetAgentIdentity();
var (liveIconHost, livePixels, liveGlows, liveRotate, liveScale) = CreateMiniLauncherIconEx(4.0, "none");
var canvas = liveIconHost.Children.OfType<Canvas>().FirstOrDefault();
if (canvas != null)
{
var animState = new ChatIconAnimState
{
Host = liveIconHost, Canvas = canvas, Pixels = livePixels,
Glows = liveGlows, Rotate = liveRotate, Scale = liveScale,
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
};
StartChatIconAnimation(animState);
}
Grid.SetColumn(liveIconHost, 0);
headerGrid.Children.Add(liveIconHost);
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);
_v2LiveElapsedText = new TextBlock
{
Text = "",
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.70,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(_v2LiveElapsedText, 2);
headerGrid.Children.Add(_v2LiveElapsedText);
_v2LiveContainer.Children.Add(headerGrid);
_v2LiveStatusText = new TextBlock
{
FontSize = 14,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
};
_v2LiveStatusDetailText = new TextBlock
{
FontSize = 11,
Foreground = secondaryText,
Opacity = 0.88,
TextWrapping = TextWrapping.Wrap,
LineHeight = 18,
Margin = new Thickness(0, 4, 0, 0),
};
_v2LiveStatusMetaText = new TextBlock
{
FontSize = 10,
Foreground = secondaryText,
Opacity = 0.72,
Margin = new Thickness(0, 8, 0, 0),
};
_v2LiveStatusCard = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x12, accentColor.R, accentColor.G, accentColor.B)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x36, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(14, 12, 14, 12),
Margin = new Thickness(0, 4, 0, 6),
Child = new StackPanel
{
Children =
{
_v2LiveStatusText,
_v2LiveStatusDetailText,
_v2LiveStatusMetaText,
}
}
};
_v2LiveContainer.Children.Add(_v2LiveStatusCard);
RefreshV2LiveStatusCard(runTab);
AddTranscriptElement(_v2LiveContainer);
ForceScrollToEnd();
_v2LiveElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_v2LiveElapsedTimer.Tick += (_, _) =>
{
if (_v2LiveElapsedText == null) return;
var sec = (int)(DateTime.UtcNow - _v2LiveStartTime).TotalSeconds;
_v2LiveElapsedText.Text = sec > 0 ? $"{sec}초 경과" : "";
};
_v2LiveElapsedTimer.Start();
}
/// <summary>V2: 에이전트 이벤트 수신 시 라이브 카드 업데이트</summary>
private void UpdateAgentLiveCardV2(AgentEvent agentEvent)
{
if (_v2LiveContainer == null) return;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var msgMaxWidth = GetMessageMaxWidth();
UpdateV2LiveStatusCardFromEvent(agentEvent);
switch (agentEvent.Type)
{
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;
}
}
}
/// <summary>V2: 스트리밍 종료 시 라이브 카드 제거</summary>
private void RemoveAgentLiveCardV2(bool animated = true)
{
_v2LiveElapsedTimer?.Stop();
_v2LiveElapsedTimer = null;
_v2LiveElapsedText = null;
_v2LiveStatusCard = null;
_v2LiveStatusText = null;
_v2LiveStatusDetailText = null;
_v2LiveStatusMetaText = null;
if (_v2LiveContainer == null) return;
_chatIconAnimStates.RemoveAll(s => s.Host != null && !s.Host.IsVisible);
var toRemove = _v2LiveContainer;
_v2LiveContainer = null;
_v2LiveToolCards.Clear();
_v2LastLiveToolCallId = null;
if (animated && ContainsTranscriptElement(toRemove))
{
var anim = new DoubleAnimation(toRemove.Opacity, 0, TimeSpan.FromMilliseconds(160));
anim.Completed += (_, _) => RemoveTranscriptElement(toRemove);
toRemove.BeginAnimation(UIElement.OpacityProperty, anim);
return;
}
RemoveTranscriptElement(toRemove);
}
private void RefreshV2LiveStatusCard(string runTab)
{
if (_v2LiveStatusText == null)
return;
var hint = GetLiveAgentProgressHint(runTab);
if (hint != null)
{
var narrative = AgentStatusNarrativeCatalog.BuildFromEvent(hint, runTab);
var hintSummary = string.IsNullOrWhiteSpace(hint.Summary)
? narrative.Message
: hint.Summary;
UpdateV2LiveStatusCard(hintSummary, narrative.Detail, BuildReadableProgressMetaText(hint));
return;
}
var initial = AgentStatusNarrativeCatalog.BuildInitial(runTab);
UpdateV2LiveStatusCard(initial.Message, initial.Detail, null);
}
private void UpdateV2LiveStatusCardFromEvent(AgentEvent agentEvent)
{
if (_v2LiveStatusText == null)
return;
var narrative = AgentStatusNarrativeCatalog.BuildFromEvent(agentEvent, _activeTab);
var normalizedThinking = agentEvent.Type == AgentEventType.Thinking
? AgentProgressSummarySanitizer.NormalizeThinkingSummary(
agentEvent.Summary,
agentEvent.ToolName,
maxLength: 160)
: string.Empty;
var message = string.IsNullOrWhiteSpace(normalizedThinking)
? narrative.Message
: normalizedThinking;
UpdateV2LiveStatusCard(message, narrative.Detail, BuildReadableProgressMetaText(agentEvent));
}
private void UpdateV2LiveStatusCard(string message, string? detail, string? meta)
{
if (_v2LiveStatusText == null || _v2LiveStatusDetailText == null || _v2LiveStatusMetaText == null)
return;
_v2LiveStatusText.Text = string.IsNullOrWhiteSpace(message)
? "작업을 이어가고 있습니다..."
: message;
_v2LiveStatusDetailText.Text = detail ?? string.Empty;
_v2LiveStatusDetailText.Visibility = string.IsNullOrWhiteSpace(detail)
? Visibility.Collapsed
: Visibility.Visible;
_v2LiveStatusMetaText.Text = meta ?? string.Empty;
_v2LiveStatusMetaText.Visibility = string.IsNullOrWhiteSpace(meta)
? Visibility.Collapsed
: Visibility.Visible;
}
}