From 5ab04bc53e86425eb0aed0e5d4c6c315cdfb1f79 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 18:56:53 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=ED=91=9C=EC=8B=9C=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EB=B0=8F=20=EB=B3=B8=EB=AC=B8=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=9C=A0=EC=A7=80=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상단 라이브 진행 카드를 이전 단계형 구조로 복구하고 스트리밍 중 현재 실행 이벤트가 본문 타임라인에 중복 표시되지 않도록 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개 테스트 통과를 확인했습니다. --- README.md | 7 + docs/DEVELOPMENT.md | 6 + .../Agent/AgentProgressSummarySanitizer.cs | 43 +-- .../ChatWindow.MessageBubblePresentation.cs | 2 +- .../ChatWindow.V2LiveProgressPresentation.cs | 300 +++++++++++++----- .../Views/ChatWindow.V2MessagePresentation.cs | 2 +- src/AxCopilot/Views/ChatWindow.V2Rendering.cs | 67 ++-- 7 files changed, 296 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 09ab581..36f07bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # AX Commander +- 업데이트: 2026-04-15 18:54 (KST) +- AX Agent 표시 회귀를 정리했습니다. `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`를 이전 라이브 진행 카드 구조로 되돌려 상단 라이브 카드가 다시 단계형으로 보이도록 복구했고, 스트리밍 중인 현재 실행 이벤트는 본문 타임라인에 중복으로 쌓이지 않도록 컷오프를 다시 적용했습니다. +- 본문 드래그 선택은 유지하되 사용자 말풍선 회귀는 되돌렸습니다. `src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`에서 사용자 버블만 일반 마크다운 렌더로 복원해 세로로 깨지던 표시를 막고, 어시스턴트 본문과 스트리밍 완료 본문(`src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`)은 계속 드래그 선택/복사할 수 있게 유지했습니다. +- `src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs`에는 `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 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_live_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_live_restore_tests\\` 통과 131 + - 업데이트: 2026-04-15 18:30 (KST) - Code 탭에서 동일 도구 호출이 같은 시그니처로 반복될 때 빠져나오지 못하던 루프를 보강했습니다. `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`에 일반 반복 시그니처 가드를 추가하고, `src/AxCopilot/Services/Agent/AgentLoopService.cs`가 읽기 전용 도구뿐 아니라 `build_run` 같은 실행 도구 반복도 감지해 다른 접근으로 전환하도록 정리했습니다. - `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`, `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`는 상단 라이브 진행 영역을 1~2줄 요약 카드로 축소하고, 실제 ToolCall/ToolResult 이력은 채팅 본문 타임라인에 계속 누적되도록 바꿨습니다. 내부 대기성 Thinking/LLM 대기 문구는 본문에서 더 공격적으로 숨겨 실행 이력이 덜 지저분하게 보이도록 조정했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1ba248d..6ebedd5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1478,3 +1478,9 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫 - 사용자에게 보이는 작업 설명도 `src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs`에서 다시 정리했습니다. 코드 탐색, 수정, 실행, 문서화, 권한 대기 같은 단계가 더 짧고 친절한 한국어 문구로 노출되며, 대상 파일/명령/쿼리 같은 힌트는 detail 줄로 별도 노출됩니다. - 채팅 본문 드래그 복사도 지원합니다. `src/AxCopilot/Services/MarkdownRenderer.cs`에 선택 가능한 RichTextBox 기반 마크다운 렌더를 추가했고, `src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`, `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`가 Cowork/Code 본문에 이를 사용하도록 연결했습니다. - 테스트는 `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs`, `src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs`, `src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs`를 갱신했고, `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\` 경고 0 / 오류 0, `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\` 131개 통과를 확인했습니다. +업데이트: 2026-04-15 18:54 (KST) +- AX Agent 라이브 진행 UI를 이전 구조로 복구했습니다. `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`는 상단 요약 1~2줄 카드 대신 단계형 라이브 카드와 도구 진행 행을 다시 사용하고, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`는 스트리밍 중 현재 실행 이벤트를 본문 타임라인에서 잠시 제외해 상단 카드와 본문이 중복 표시되지 않도록 원래 흐름으로 되돌렸습니다. +- 본문 드래그 선택은 유지하되 사용자 버블 회귀는 제거했습니다. `src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`에서 사용자 메시지 렌더만 기존 `MarkdownRenderer.Render(...)`로 되돌렸고, 어시스턴트 본문과 스트리밍 완료 본문은 계속 `RenderSelectable(...)`를 사용해 드래그 복사가 가능하도록 유지했습니다. +- `src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs`는 `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 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_live_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_live_restore_tests\\` 통과 131 diff --git a/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs b/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs index 0ce5483..7dece78 100644 --- a/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs +++ b/src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs @@ -5,22 +5,22 @@ namespace AxCopilot.Services.Agent; internal static class AgentProgressSummarySanitizer { private static readonly Regex s_previousToolCallRegex = - new(@"\[이전 도구 호출[^\]\n]*(?:\]|$)", RegexOptions.Compiled); + new("\\[?(?:\\uC774\\uC804 \\uB3C4\\uAD6C \\uC778\\uCD9C)[^\\]\\n]*(?:\\]|$)", RegexOptions.Compiled); private static readonly Regex s_previousToolBundleRegex = - new(@"\[이전 도구 호출 묶음 축약[^\]\n]*(?:\]|$)", RegexOptions.Compiled); + new("\\[?(?:\\uC774\\uC804 \\uB3C4\\uAD6C \\uC778\\uCD9C \\uBB36\\uC74C \\uCD95\\uC57D)[^\\]\\n]*(?:\\]|$)", RegexOptions.Compiled); private static readonly Regex s_listPrefixRegex = - new(@"^\s*(?:\d+\s*[\.\)]|[-*•])\s*", RegexOptions.Compiled); + new("^\\s*(?:\\d+\\s*[\\.\\)]|[-*\\u2022])\\s*", RegexOptions.Compiled); private static readonly Regex s_punctuationOnlyRegex = - new(@"^[\[\]\(\)\{\}/\\<>\.\-_:;,`'""*]+$", RegexOptions.Compiled); + new("^[\\[\\]\\(\\)\\{\\}/\\\\<>\\.\\-_:;,`'\"*]+$", RegexOptions.Compiled); private static readonly Regex s_numericFragmentRegex = - new(@"^\d+\s*[\.\)]?$", RegexOptions.Compiled); + new("^\\d+\\s*[\\.\\)]?$", RegexOptions.Compiled); private static readonly Regex s_toolTokenRegex = - new(@"^[A-Za-z][A-Za-z0-9_\-]{1,40}\]?$", RegexOptions.Compiled); + new("^[A-Za-z][A-Za-z0-9_\\-]{1,40}\\]?$", RegexOptions.Compiled); public static string NormalizeThinkingSummary(string? summary, string? toolName = null, int maxLength = 0) { @@ -49,10 +49,10 @@ internal static class AgentProgressSummarySanitizer return string.Empty; var normalized = string.Join(" ", cleanedLines); - normalized = Regex.Replace(normalized, @"\s{2,}", " ").Trim(); + normalized = Regex.Replace(normalized, "\\s{2,}", " ").Trim(); if (maxLength > 0 && normalized.Length > maxLength) - normalized = normalized[..maxLength].TrimEnd() + "…"; + normalized = normalized[..maxLength].TrimEnd() + "..."; return normalized; } @@ -64,22 +64,27 @@ internal static class AgentProgressSummarySanitizer return true; var lower = normalized.ToLowerInvariant(); - return lower.Contains("모델에 요청하는 중") - || lower.Contains("모델 첫 응답") - || lower.Contains("계속 기다리는 중") - || lower.Contains("응답을 기다리는 중") - || lower.Contains("스트리밍 중간 응답") - || lower.Contains("일시적 llm 오류") - || lower.Contains("gemini 무료 티어 대기") - || lower.Contains("도구명 정규화 적용") + return lower.Contains("\uBAA8\uB378\uC5D0 \uC694\uCCAD\uD558\uB294 \uC911") + || lower.Contains("\uBAA8\uB378 \uCCAB \uC751\uB2F5") + || lower.Contains("\uACC4\uC18D \uAE30\uB2E4\uB9AC\uB294 \uC911") + || lower.Contains("\uC751\uB2F5\uC744 \uAE30\uB2E4\uB9AC\uB294 \uC911") + || lower.Contains("\uC2A4\uD2B8\uB9AC\uBC0D \uC911\uAC04 \uC751\uB2F5") + || lower.Contains("\uC77C\uC2DC\uC801 llm \uC624\uB958") + || lower.Contains("gemini \uBB34\uB8CC \uD2F0\uC5B4 \uB300\uAE30") + || lower.Contains("\uB3C4\uAD6C\uBA85 \uC815\uADDC\uD654 \uC801\uC6A9") + || lower.Contains("skillruntime") + || lower.Contains("allowed_tools") + || lower.Contains("\uBA54\uC778 \uB8E8\uD504") + || lower.Contains("\uC77D\uAE30 \uB3C4\uAD6C \uC870\uAE30 \uC2E4\uD589 \uC900\uBE44") + || lower.Contains("\uC2A4\uD2B8\uB9AC\uBC0D \uB3C4\uAD6C \uAC10\uC9C0") || lower.Contains("[agentloopwait]"); } private static string CleanThinkingLine(string line) { var cleaned = s_listPrefixRegex.Replace(line.Trim(), string.Empty).Trim(); - cleaned = cleaned.Trim('*', '-', '•', '[', ']', '(', ')', '{', '}', ':', ';'); - cleaned = Regex.Replace(cleaned, @"\s{2,}", " ").Trim(); + cleaned = cleaned.Trim('*', '-', '\u2022', '[', ']', '(', ')', '{', '}', ':', ';'); + cleaned = Regex.Replace(cleaned, "\\s{2,}", " ").Trim(); return cleaned; } @@ -97,7 +102,7 @@ internal static class AgentProgressSummarySanitizer if (s_toolTokenRegex.IsMatch(line)) return true; - if (line.Length <= 3 && line.Any(ch => char.IsPunctuation(ch))) + if (line.Length <= 3 && line.Any(char.IsPunctuation)) return true; if (line.Length <= 4 && line.All(ch => char.IsPunctuation(ch) || char.IsWhiteSpace(ch))) diff --git a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs index 4ccab4b..c6d473b 100644 --- a/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs @@ -64,7 +64,7 @@ public partial class ChatWindow MarkdownRenderer.EnableFilePathHighlight = (System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; MarkdownRenderer.EnableCodeSymbolHighlight = true; - bubble.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, userCodeBgBrush); + bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, userCodeBgBrush); } else { diff --git a/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs b/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs index 6006f01..4a28e96 100644 --- a/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs @@ -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; - /// V2: 스트리밍 시작 시 상단 라이브 진행 요약 카드를 생성합니다. + /// V2: 스트리밍 시작 시 라이브 진행 컨테이너 생성 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(); } - /// V2: 라이브 카드는 1~2줄 요약만 유지하고, 상세 실행 이력은 본문 타임라인에 누적합니다. + /// V2: 에이전트 이벤트 수신 시 라이브 카드 업데이트 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; } /// V2: 스트리밍 종료 시 라이브 카드 제거 @@ -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)) { diff --git a/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs b/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs index e8c3e14..17239ed 100644 --- a/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs @@ -88,7 +88,7 @@ public partial class ChatWindow MarkdownRenderer.EnableFilePathHighlight = (System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; MarkdownRenderer.EnableCodeSymbolHighlight = true; - bubble.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, hintBg); + bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, hintBg); } else { diff --git a/src/AxCopilot/Views/ChatWindow.V2Rendering.cs b/src/AxCopilot/Views/ChatWindow.V2Rendering.cs index a618766..d6cc781 100644 --- a/src/AxCopilot/Views/ChatWindow.V2Rendering.cs +++ b/src/AxCopilot/Views/ChatWindow.V2Rendering.cs @@ -14,16 +14,14 @@ namespace AxCopilot.Views; public partial class ChatWindow { - // ─── V2 렌더 상태 ─────────────────────────────────────────────────── private string? _v2LastRenderedConversationId; private int _v2LastRenderedMessageCount; private int _v2LastRenderedEventCount; private readonly List _v2LastRenderedKeys = new(); - // V2 라이브 프로그레스 상태 private StackPanel? _v2LiveContainer; - private TextBlock? _v2LiveStatusText; - private TextBlock? _v2LiveDetailText; + private readonly Dictionary _v2LiveToolCards = new(); + private string? _v2LastLiveToolCallId; private void RenderMessagesV2( ChatConversation conv, @@ -37,7 +35,6 @@ public partial class ChatWindow { try { - // 대화 전환 감지 → 캐시 초기화 if (!string.Equals(_v2LastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase)) { _v2LastRenderedConversationId = conv.Id; @@ -47,31 +44,21 @@ public partial class ChatWindow _elementCache.Clear(); } - // ★ 패널이 비어있는데 V2 캐시가 남아있는 경우 강제 리셋 - // 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만 - // 빈 대화 탭을 거치면 V2가 호출되지 않아 캐시가 잔류하는 버그 방지 if (GetTranscriptElementCount() == 0 && _v2LastRenderedKeys.Count > 0) { - LogService.Info($"[V2Render] CACHE STALE RESET: panel=0 but cachedKeys={_v2LastRenderedKeys.Count}, forcing full rebuild"); + LogService.Debug($"[V2Render] CACHE STALE RESET: panel=0 but cachedKeys={_v2LastRenderedKeys.Count}, forcing full rebuild"); _v2LastRenderedKeys.Clear(); _v2LastRenderedMessageCount = 0; _v2LastRenderedEventCount = 0; } - // 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합) var timeline = BuildV2Timeline(visibleMessages, visibleEvents); - LogService.Debug($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}"); - // 새 키 목록 생성 var newKeys = new List(timeline.Count); foreach (var item in timeline) newKeys.Add(item.Key); - // 인크리멘탈 렌더 시도: 기존 키가 새 키의 접두사인 경우 추가분만 렌더 - // ★ 패널에 실제 엘리먼트가 있어야 인크리멘탈 가능 — - // 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만 - // V2 캐시(_v2LastRenderedKeys)는 유지되어 "이미 렌더됨"으로 오판하는 버그 방지 var actualElementCount = GetTranscriptElementCount(); var canIncremental = _v2LastRenderedKeys.Count > 0 && actualElementCount > 0 @@ -82,7 +69,6 @@ public partial class ChatWindow if (canIncremental) { - // 라이브 컨테이너가 있으면 임시 제거 (맨 끝에 다시 추가) if (_v2LiveContainer != null && ContainsTranscriptElement(_v2LiveContainer)) RemoveTranscriptElement(_v2LiveContainer); @@ -99,13 +85,11 @@ public partial class ChatWindow }); } - // 라이브 컨테이너 재삽입 if (_isStreaming) EnsureAgentLiveCardVisible(_activeTab); } else { - // 전체 재빌드 ClearTranscriptElements(); foreach (var item in timeline) @@ -121,7 +105,6 @@ public partial class ChatWindow }); } - // 라이브 컨테이너 재삽입 if (_isStreaming) EnsureAgentLiveCardVisible(_activeTab); } @@ -186,7 +169,6 @@ public partial class ChatWindow { var timeline = new List(visibleMessages.Count + visibleEvents.Count); - // 1. 메시지 추가 foreach (var msg in visibleMessages) { var capturedMsg = msg; @@ -194,46 +176,49 @@ public partial class ChatWindow timeline.Add(new V2TimelineItem(key, msg.Timestamp, () => CreateV2MessageElement(capturedMsg))); } - // 2. 실행 이벤트 추가 — ToolCall+ToolResult 쌍을 병합 - // 스트리밍 중이면 라이브 카드가 이미 표시하는 현재 실행 이벤트는 타임라인에서 제외 var eventIndex = 0; var events = visibleEvents.ToList(); + var liveCardCutoff = (_isStreaming && _v2LiveContainer != null) + ? _v2LiveStartTime + : DateTime.MaxValue; for (int i = 0; i < events.Count; i++) { var executionEvent = events[i]; + if (executionEvent.Timestamp.ToUniversalTime() >= liveCardCutoff) + continue; - // 스트리밍 중: 라이브 카드 시작 이후 이벤트는 라이브 카드에서 표시하므로 스킵 var agentEvent = ToAgentEvent(executionEvent); - - // SessionStart / UserPromptSubmit 숨김 - if (agentEvent.Type == AgentEventType.SessionStart || agentEvent.Type == AgentEventType.UserPromptSubmit) + if (ShouldHideV2TimelineEvent(agentEvent)) continue; var key = $"v2e_{executionEvent.Timestamp.Ticks}_{eventIndex++}"; - // ToolCall → 바로 다음 같은 도구의 ToolResult를 찾아 병합 if (agentEvent.Type == AgentEventType.ToolCall && i + 1 < events.Count) { var nextEvent = ToAgentEvent(events[i + 1]); - if (nextEvent.Type == AgentEventType.ToolResult + if (!ShouldHideV2TimelineEvent(nextEvent) + && nextEvent.Type == AgentEventType.ToolResult && string.Equals(nextEvent.ToolName, agentEvent.ToolName, StringComparison.OrdinalIgnoreCase)) { var capturedCall = agentEvent; var capturedResult = nextEvent; - timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp, + timeline.Add(new V2TimelineItem( + key, + executionEvent.Timestamp, () => CreateV2ToolExecutionCard(capturedCall, capturedResult))); - i++; // ToolResult 스킵 + i++; continue; } } var capturedEvent = agentEvent; - timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp, + timeline.Add(new V2TimelineItem( + key, + executionEvent.Timestamp, () => CreateV2AgentEventElement(capturedEvent))); } - // 시간순 정렬 var needsSort = false; for (int i = 1; i < timeline.Count; i++) { @@ -243,9 +228,25 @@ public partial class ChatWindow break; } } + if (needsSort) timeline.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp)); return timeline; } + + private static bool ShouldHideV2TimelineEvent(AgentEvent agentEvent) + { + if (agentEvent.Type == AgentEventType.SessionStart || agentEvent.Type == AgentEventType.UserPromptSubmit) + return true; + + if (agentEvent.Type != AgentEventType.Thinking) + return false; + + if (string.Equals(agentEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase) + || string.Equals(agentEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)) + return false; + + return AgentProgressSummarySanitizer.IsLowSignalStatusSummary(agentEvent.Summary, agentEvent.ToolName); + } }