From f0af86cc1e792cc5329562fbb3006fc8da2830f1 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 5 Apr 2026 22:55:56 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20transcript=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A5=BC=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B6=8C=ED=95=9C/=EB=8F=84=EA=B5=AC=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=91=9C=EC=8B=9C=20=EC=B2=B4=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatWindow.xaml.cs에 몰려 있던 의견 요청, 계획 승인, 작업 요약 렌더를 partial 파일로 분리해 transcript 책임을 낮췄다. - PermissionRequestPresentationCatalog와 ToolResultPresentationCatalog를 추가해 권한 요청 및 도구 결과 badge를 타입별로 해석하도록 정리했다. - AppStateService에 OperationalStatusPresentationState를 추가하고 상태선 계산을 presentation 계층으로 한 번 더 분리했다. - README.md, docs/DEVELOPMENT.md, docs/claw-code-parity-plan.md에 2026-04-06 00:58 (KST) 기준 변경 내용을 반영했다. - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0) --- README.md | 4 + docs/DEVELOPMENT.md | 5 + docs/claw-code-parity-plan.md | 12 + .../PermissionRequestPresentationCatalog.cs | 40 + .../Agent/ToolResultPresentationCatalog.cs | 37 + src/AxCopilot/Services/AppStateService.cs | 31 + .../Views/ChatWindow.InlineInteractions.cs | 397 +++++++++ src/AxCopilot/Views/ChatWindow.TaskSummary.cs | 358 ++++++++ src/AxCopilot/Views/ChatWindow.xaml.cs | 809 +----------------- 9 files changed, 910 insertions(+), 783 deletions(-) create mode 100644 src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs create mode 100644 src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs create mode 100644 src/AxCopilot/Views/ChatWindow.InlineInteractions.cs create mode 100644 src/AxCopilot/Views/ChatWindow.TaskSummary.cs diff --git a/README.md b/README.md index 16119f0..429ec5f 100644 --- a/README.md +++ b/README.md @@ -1113,3 +1113,7 @@ MIT License - 업데이트: 2026-04-05 22:40 (KST) - AX Agent 테마를 다시 점검해 기존 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 2종을 추가했다. `Nord`는 차분한 블루그레이 업무형 톤, `Ember`는 따뜻한 앰버 문서 작업 톤으로 구성했다. - 내부 설정 `테마 스타일` 카드에서도 새 프리셋을 바로 선택할 수 있게 연결했고, `system / light / dark` 모드 조합으로 같은 방식으로 적용되도록 정리했다. +- 업데이트: 2026-04-06 00:58 (KST) + - AX Agent transcript 품질 향상을 위해 렌더 책임을 실제로 분리했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)를 추가해 `의견 요청`, `계획 승인`, `작업 요약` UI 로직을 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)에서 분리했다. + - [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)를 추가해 권한 요청과 도구 결과를 `명령/네트워크/파일`, `성공/실패/거부/취소` 기준으로 나눠 transcript badge에 재사용하도록 정리했다. + - [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState`와 `GetOperationalStatusPresentation(...)`을 추가해 status/runtime summary 계산을 전용 요약 모델로 한 번 더 계층화했다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 상태선 갱신은 이제 이 presentation summary를 소비한다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 77284ff..5e1e896 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4874,3 +4874,8 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 업데이트: 2026-04-05 22:40 (KST) - AX Agent 테마 리소스 구조를 다시 점검한 뒤 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 프리셋을 추가했다. 새 리소스 파일은 [AgentNordLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordLight.xaml), [AgentNordDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordDark.xaml), [AgentNordSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentNordSystem.xaml), [AgentEmberLight.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberLight.xaml), [AgentEmberDark.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberDark.xaml), [AgentEmberSystem.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Themes/AgentEmberSystem.xaml) 이다. - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `BuildAgentThemeDictionaryUri()`, `RefreshOverlayThemeCards()`에 새 프리셋 분기를 추가했고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 `테마 스타일` 선택 카드에도 `Nord`, `Ember`를 노출했다. +- 업데이트: 2026-04-06 00:58 (KST) + - transcript renderer 분리 1차를 반영했다. [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs)에 `ShowInlineUserAskAsync`, `CreatePlanDecisionCallback`, `ShowPlanButton`, `EnsurePlanViewerWindow` 계열을 옮기고, [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs)에 `ShowTaskSummaryPopup`, `BuildTaskSummaryCard`를 옮겨 메인 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 transcript 책임을 줄였다. + - 권한/도구 결과 presentation catalog를 추가했다. [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs)는 `명령/네트워크/파일/일반` 권한 요청·허용 메타를, [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs)는 `success/error/reject/cancel` 기준의 도구 결과 badge 메타를 제공한다. + - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `AddAgentEventBanner(...)`는 이제 이 catalog를 사용해 권한 요청과 도구 결과 badge를 결정한다. + - [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)에 `OperationalStatusPresentationState`와 `GetOperationalStatusPresentation(...)`을 추가해 status line/runtime summary 계산을 presentation layer로 분리했다. `UpdateTaskSummaryIndicators()`는 presentation summary만 소비하도록 바뀌었다. diff --git a/docs/claw-code-parity-plan.md b/docs/claw-code-parity-plan.md index bf5d105..ee90ec6 100644 --- a/docs/claw-code-parity-plan.md +++ b/docs/claw-code-parity-plan.md @@ -407,3 +407,15 @@ - `BuildTopicButtons()` rebuild frequency - `OnAgentEvent` timeline churn during long Cowork/Code runs - compact queue summary still needs one more pass to fully match `claw-code` footer minimalism + +## Progress Notes +- 업데이트: 2026-04-06 00:58 (KST) + - transcript renderer 분리 1차 완료 + - AX 적용: [ChatWindow.InlineInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs), [ChatWindow.TaskSummary.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TaskSummary.cs) + - 완료 조건: `plan / ask / task-summary` 렌더 helper가 메인 `ChatWindow.xaml.cs` 밖으로 이동 + - permission / tool-result presentation catalog 도입 + - AX 적용: [PermissionRequestPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs), [ToolResultPresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs) + - 완료 조건: `AddAgentEventBanner(...)`가 권한/도구 결과 badge 메타를 inline switch가 아니라 catalog에서 해석 + - runtime summary 전용 계층 1차 반영 + - AX 적용: [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs) + - 완료 조건: 상태선 UI가 `OperationalStatusPresentationState`를 소비해 strip/runtime badge visibility를 계산 diff --git a/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs b/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs new file mode 100644 index 0000000..c463244 --- /dev/null +++ b/src/AxCopilot/Services/Agent/PermissionRequestPresentationCatalog.cs @@ -0,0 +1,40 @@ +namespace AxCopilot.Services.Agent; + +internal sealed record PermissionRequestPresentation( + string Icon, + string Label, + string BackgroundHex, + string ForegroundHex); + +internal static class PermissionRequestPresentationCatalog +{ + public static PermissionRequestPresentation Resolve(string? toolName, bool pending) + { + var tool = toolName?.Trim().ToLowerInvariant() ?? ""; + + if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell")) + { + return pending + ? new PermissionRequestPresentation("\uE756", "명령 권한 요청", "#FEF2F2", "#DC2626") + : new PermissionRequestPresentation("\uE73E", "명령 권한 허용", "#ECFDF5", "#059669"); + } + + if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http")) + { + return pending + ? new PermissionRequestPresentation("\uE774", "네트워크 권한 요청", "#FFF7ED", "#C2410C") + : new PermissionRequestPresentation("\uE73E", "네트워크 권한 허용", "#ECFDF5", "#059669"); + } + + if (tool.Contains("file")) + { + return pending + ? new PermissionRequestPresentation("\uE8A5", "파일 권한 요청", "#FFF7ED", "#C2410C") + : new PermissionRequestPresentation("\uE73E", "파일 권한 허용", "#ECFDF5", "#059669"); + } + + return pending + ? new PermissionRequestPresentation("\uE897", "권한 요청", "#FFF7ED", "#C2410C") + : new PermissionRequestPresentation("\uE73E", "권한 허용", "#ECFDF5", "#059669"); + } +} diff --git a/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs b/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs new file mode 100644 index 0000000..8d21551 --- /dev/null +++ b/src/AxCopilot/Services/Agent/ToolResultPresentationCatalog.cs @@ -0,0 +1,37 @@ +namespace AxCopilot.Services.Agent; + +internal sealed record ToolResultPresentation( + string Icon, + string Label, + string BackgroundHex, + string ForegroundHex, + string StatusKind); + +internal static class ToolResultPresentationCatalog +{ + public static ToolResultPresentation Resolve(AgentEvent evt, string fallbackLabel) + { + var summary = evt.Summary?.Trim() ?? ""; + + if (summary.Contains("취소", StringComparison.OrdinalIgnoreCase) || + summary.Contains("중단", StringComparison.OrdinalIgnoreCase) || + evt.Type == AgentEventType.StopRequested) + { + return new ToolResultPresentation("\uE711", "도구 취소", "#F8FAFC", "#475569", "cancel"); + } + + if (summary.Contains("거부", StringComparison.OrdinalIgnoreCase) || + summary.Contains("반려", StringComparison.OrdinalIgnoreCase) || + summary.Contains("권한 거부", StringComparison.OrdinalIgnoreCase)) + { + return new ToolResultPresentation("\uE783", "도구 거부", "#FEF2F2", "#DC2626", "reject"); + } + + if (!evt.Success || evt.Type == AgentEventType.Error) + { + return new ToolResultPresentation("\uE783", "도구 실패", "#FEF2F2", "#DC2626", "error"); + } + + return new ToolResultPresentation("\uE73E", fallbackLabel, "#ECFDF5", "#16A34A", "success"); + } +} diff --git a/src/AxCopilot/Services/AppStateService.cs b/src/AxCopilot/Services/AppStateService.cs index 5da7037..b28d74b 100644 --- a/src/AxCopilot/Services/AppStateService.cs +++ b/src/AxCopilot/Services/AppStateService.cs @@ -158,6 +158,17 @@ public sealed class AppStateService public string StripText { get; init; } = ""; } + public sealed class OperationalStatusPresentationState + { + public bool ShowRuntimeBadge { get; init; } + public string RuntimeLabel { get; init; } = ""; + public bool ShowLastCompleted { get; init; } + public string LastCompletedText { get; init; } = ""; + public bool ShowCompactStrip { get; init; } + public string StripKind { get; init; } = "none"; + public string StripText { get; init; } = ""; + } + public ChatSessionStateService? ChatSession { get; private set; } public SkillCatalogState Skills { get; } = new(); public McpCatalogState Mcp { get; } = new(); @@ -620,6 +631,26 @@ public sealed class AppStateService }; } + public OperationalStatusPresentationState GetOperationalStatusPresentation(string tab, bool hasLiveRuntimeActivity) + { + var status = GetOperationalStatus(tab); + var showCompactStrip = !string.Equals(tab, "Chat", StringComparison.OrdinalIgnoreCase) + && (string.Equals(status.StripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase) + || string.Equals(status.StripKind, "failed_run", StringComparison.OrdinalIgnoreCase) + || string.Equals(status.StripKind, "permission_denied", StringComparison.OrdinalIgnoreCase)); + + return new OperationalStatusPresentationState + { + ShowRuntimeBadge = status.ShowRuntimeBadge && hasLiveRuntimeActivity, + RuntimeLabel = status.RuntimeLabel, + ShowLastCompleted = status.ShowLastCompleted, + LastCompletedText = status.LastCompletedText, + ShowCompactStrip = showCompactStrip, + StripKind = showCompactStrip ? status.StripKind : "none", + StripText = showCompactStrip ? status.StripText : "", + }; + } + public IReadOnlyList GetDraftQueueItems(string tab) => ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty(); diff --git a/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs b/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs new file mode 100644 index 0000000..8a436e0 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.InlineInteractions.cs @@ -0,0 +1,397 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private async Task ShowInlineUserAskAsync(string question, List options, string defaultValue) + { + var tcs = new TaskCompletionSource(); + + await Dispatcher.InvokeAsync(() => + { + AddUserAskCard(question, options, defaultValue, tcs); + }); + + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); + if (completed != tcs.Task) + { + await Dispatcher.InvokeAsync(RemoveUserAskCard); + return null; + } + + return await tcs.Task; + } + + private void RemoveUserAskCard() + { + if (_userAskCard == null) + return; + + MessagePanel.Children.Remove(_userAskCard); + _userAskCard = null; + } + + private void AddUserAskCard(string question, List options, string defaultValue, TaskCompletionSource tcs) + { + RemoveUserAskCard(); + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; + var accentColor = ((SolidColorBrush)accentBrush).Color; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x24, accentColor.R, accentColor.G, accentColor.B)); + var itemBg = TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x10, accentColor.R, accentColor.G, accentColor.B)); + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x16, 0xFF, 0xFF, 0xFF)); + var okBrush = BrushFromHex("#10B981"); + var dangerBrush = BrushFromHex("#EF4444"); + + var container = new Border + { + Margin = new Thickness(40, 4, 90, 8), + HorizontalAlignment = HorizontalAlignment.Left, + MaxWidth = Math.Max(420, GetMessageMaxWidth() - 36), + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(14, 12, 14, 12), + }; + + var outer = new StackPanel(); + outer.Children.Add(new TextBlock + { + Text = "의견 요청", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }); + outer.Children.Add(new TextBlock + { + Text = question, + Margin = new Thickness(0, 4, 0, 10), + FontSize = 12.5, + Foreground = primaryText, + TextWrapping = TextWrapping.Wrap, + LineHeight = 20, + }); + + Border? selectedOption = null; + string selectedResponse = defaultValue; + + if (options.Count > 0) + { + var optionPanel = new WrapPanel + { + Margin = new Thickness(0, 0, 0, 10), + ItemWidth = double.NaN, + }; + + foreach (var option in options.Where(static option => !string.IsNullOrWhiteSpace(option))) + { + var optionLabel = option.Trim(); + var optBorder = new Border + { + Background = Brushes.Transparent, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(10, 6, 10, 6), + Margin = new Thickness(0, 0, 8, 8), + Cursor = Cursors.Hand, + Child = new TextBlock + { + Text = optionLabel, + FontSize = 12, + Foreground = primaryText, + }, + }; + + optBorder.MouseEnter += (s, _) => + { + if (!ReferenceEquals(selectedOption, s)) + ((Border)s).Background = hoverBg; + }; + optBorder.MouseLeave += (s, _) => + { + if (!ReferenceEquals(selectedOption, s)) + ((Border)s).Background = Brushes.Transparent; + }; + optBorder.MouseLeftButtonUp += (_, _) => + { + if (selectedOption != null) + { + selectedOption.Background = Brushes.Transparent; + selectedOption.BorderBrush = borderBrush; + } + + selectedOption = optBorder; + selectedOption.Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)); + selectedOption.BorderBrush = accentBrush; + selectedResponse = optionLabel; + }; + + optionPanel.Children.Add(optBorder); + } + + outer.Children.Add(optionPanel); + } + + outer.Children.Add(new TextBlock + { + Text = "직접 입력", + FontSize = 11.5, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 6), + }); + + var inputBox = new TextBox + { + Text = defaultValue, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + MinHeight = 42, + MaxHeight = 100, + FontSize = 12.5, + Padding = new Thickness(10, 8, 10, 8), + Background = Brushes.Transparent, + Foreground = primaryText, + CaretBrush = primaryText, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + }; + inputBox.TextChanged += (_, _) => + { + if (!string.IsNullOrWhiteSpace(inputBox.Text)) + { + selectedResponse = inputBox.Text.Trim(); + if (selectedOption != null) + { + selectedOption.Background = Brushes.Transparent; + selectedOption.BorderBrush = borderBrush; + selectedOption = null; + } + } + }; + outer.Children.Add(inputBox); + + var buttonRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 12, 0, 0), + }; + + Border BuildActionButton(string label, Brush bg, Brush fg) + { + return new Border + { + Background = bg, + BorderBrush = bg, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(12, 7, 12, 7), + Margin = new Thickness(8, 0, 0, 0), + Cursor = Cursors.Hand, + Child = new TextBlock + { + Text = label, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = fg, + }, + }; + } + + var cancelBtn = BuildActionButton("취소", Brushes.Transparent, dangerBrush); + cancelBtn.BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xEF, 0x44, 0x44)); + cancelBtn.MouseLeftButtonUp += (_, _) => + { + RemoveUserAskCard(); + tcs.TrySetResult(null); + }; + buttonRow.Children.Add(cancelBtn); + + var submitBtn = BuildActionButton("전달", okBrush, Brushes.White); + submitBtn.MouseLeftButtonUp += (_, _) => + { + var finalResponse = !string.IsNullOrWhiteSpace(inputBox.Text) + ? inputBox.Text.Trim() + : selectedResponse?.Trim(); + + if (string.IsNullOrWhiteSpace(finalResponse)) + finalResponse = defaultValue?.Trim(); + + if (string.IsNullOrWhiteSpace(finalResponse)) + return; + + RemoveUserAskCard(); + tcs.TrySetResult(finalResponse); + }; + buttonRow.Children.Add(submitBtn); + + outer.Children.Add(buttonRow); + container.Child = outer; + _userAskCard = container; + + container.Opacity = 0; + container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); + MessagePanel.Children.Add(container); + ForceScrollToEnd(); + inputBox.Focus(); + inputBox.CaretIndex = inputBox.Text.Length; + } + + private Func, Task> CreatePlanDecisionCallback() + { + return async (planSummary, options) => + { + var tcs = new TaskCompletionSource(); + var steps = TaskDecomposer.ExtractSteps(planSummary); + + await Dispatcher.InvokeAsync(() => + { + EnsurePlanViewerWindow(); + _planViewerWindow?.LoadPlan(planSummary, steps, tcs); + ShowPlanButton(true); + AddDecisionButtons(tcs, options); + }); + + var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); + if (completed != tcs.Task) + { + await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); + return "취소"; + } + + var result = await tcs.Task; + var agentDecision = result; + if (result == null) + { + agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix); + } + else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase) + && !string.Equals(result, "승인", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(result)) + { + agentDecision = $"수정 요청: {result.Trim()}"; + } + + if (result == null) + { + await Dispatcher.InvokeAsync(() => + { + _planViewerWindow?.SwitchToExecutionMode(); + _planViewerWindow?.Hide(); + }); + } + else + { + await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); + } + + return agentDecision; + }; + } + + private void EnsurePlanViewerWindow() + { + if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) + return; + + _planViewerWindow = new PlanViewerWindow(this); + _planViewerWindow.Closing += (_, e) => + { + e.Cancel = true; + _planViewerWindow.Hide(); + }; + } + + private void ShowPlanButton(bool show) + { + if (!show) + { + for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--) + { + if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn") + { + if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep") + MoodIconPanel.Children.RemoveAt(i - 1); + if (i < MoodIconPanel.Children.Count) + MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1)); + break; + } + } + return; + } + + foreach (var child in MoodIconPanel.Children) + { + if (child is Border b && b.Tag?.ToString() == "PlanBtn") + return; + } + + var separator = new Border + { + Width = 1, + Height = 18, + Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray, + Margin = new Thickness(4, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + Tag = "PlanSep", + }; + MoodIconPanel.Children.Add(separator); + + var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981"); + planBtn.Tag = "PlanBtn"; + planBtn.MouseLeftButtonUp += (_, e) => + { + e.Handled = true; + if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) + { + _planViewerWindow.Show(); + _planViewerWindow.Activate(); + } + }; + MoodIconPanel.Children.Add(planBtn); + } + + private void UpdatePlanViewerStep(AgentEvent evt) + { + if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) + return; + + if (evt.StepCurrent > 0) + _planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); + } + + private void CompletePlanViewer() + { + if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) + _planViewerWindow.MarkComplete(); + ShowPlanButton(false); + } + + private static bool IsWindowAlive(Window? w) + { + if (w == null) + return false; + try + { + var _ = w.IsVisible; + return true; + } + catch + { + return false; + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.TaskSummary.cs b/src/AxCopilot/Views/ChatWindow.TaskSummary.cs new file mode 100644 index 0000000..7e4b9a1 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.TaskSummary.cs @@ -0,0 +1,358 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private void RuntimeTaskSummary_Click(object sender, MouseButtonEventArgs e) + { + e.Handled = true; + _taskSummaryTarget = sender as UIElement ?? RuntimeActivityBadge; + ShowTaskSummaryPopup(); + } + + private void ShowTaskSummaryPopup() + { + if (_taskSummaryTarget == null) + return; + + if (_taskSummaryPopup != null) + _taskSummaryPopup.IsOpen = false; + + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var popupBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var panel = new StackPanel { Margin = new Thickness(2) }; + panel.Children.Add(new TextBlock + { + Text = "작업 요약", + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + Margin = new Thickness(8, 5, 8, 2), + }); + panel.Children.Add(new TextBlock + { + Text = "현재 상태 요약", + FontSize = 8.5, + Foreground = secondaryText, + Margin = new Thickness(8, 0, 8, 5), + }); + + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + AddTaskSummaryObservabilitySections(panel, currentConversation); + + if (!string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)) + { + var currentRun = new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#E2E8F0"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(6, 0, 6, 6), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = $"실행 run {ShortRunId(_appState.AgentRun.RunId)}", + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + FontSize = 9.75, + }, + new TextBlock + { + Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · step {_appState.AgentRun.LastIteration}", + Margin = new Thickness(0, 2, 0, 0), + Foreground = GetRunStatusBrush(_appState.AgentRun.Status), + FontSize = 9, + }, + new TextBlock + { + Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary, + Margin = new Thickness(0, 3, 0, 0), + TextWrapping = TextWrapping.Wrap, + Foreground = Brushes.DimGray, + FontSize = 9, + } + } + } + }; + panel.Children.Add(currentRun); + } + + var recentAgentRuns = _appState.GetRecentAgentRuns(1); + if (recentAgentRuns.Count > 0) + { + panel.Children.Add(new TextBlock + { + Text = "마지막 실행", + FontSize = 9, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.DimGray, + Margin = new Thickness(8, 0, 8, 2), + }); + + foreach (var run in recentAgentRuns) + { + var runEvents = GetExecutionEventsForRun(run.RunId, 1); + var runFilePaths = GetExecutionEventFilePaths(run.RunId, 1); + var runDisplay = _appState.GetRunDisplay(run); + var runCardStack = new StackPanel + { + Children = + { + new TextBlock + { + Text = runDisplay.HeaderText, + FontWeight = FontWeights.SemiBold, + Foreground = GetRunStatusBrush(run.Status), + FontSize = 9.5, + }, + new TextBlock + { + Text = runDisplay.MetaText, + Margin = new Thickness(0, 1, 0, 0), + Foreground = secondaryText, + FontSize = 8.25, + }, + new TextBlock + { + Text = TruncateForStatus(runDisplay.SummaryText, 92), + Margin = new Thickness(0, 1.5, 0, 0), + TextWrapping = TextWrapping.Wrap, + Foreground = secondaryText, + FontSize = 8.5, + } + } + }; + + if (runEvents.Count > 0 || runFilePaths.Count > 0) + { + var activitySummary = new StackPanel(); + activitySummary.Children.Add(new TextBlock + { + Text = $"로그 {runEvents.Count} · 파일 {runFilePaths.Count}", + FontSize = 8, + Foreground = secondaryText, + }); + + if (!string.IsNullOrWhiteSpace(run.RunId)) + { + var capturedRunId = run.RunId; + var timelineButton = CreateTaskSummaryActionButton( + "타임라인", + "#F8FAFC", + "#CBD5E1", + "#334155", + (_, _) => ScrollToRunInTimeline(capturedRunId), + trailingMargin: false); + timelineButton.Margin = new Thickness(0, 5, 0, 0); + activitySummary.Children.Add(timelineButton); + } + + runCardStack.Children.Add(new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#E2E8F0"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(7), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 5, 0, 0), + Child = activitySummary + }); + } + + if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase)) + { + var capturedRun = run; + var followUpButton = CreateTaskSummaryActionButton( + "후속 큐", + "#ECFDF5", + "#BBF7D0", + "#166534", + (_, _) => EnqueueFollowUpFromRun(capturedRun), + trailingMargin: false); + followUpButton.Margin = new Thickness(0, 6, 0, 0); + runCardStack.Children.Add(followUpButton); + } + + if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation()) + { + var retryButton = CreateTaskSummaryActionButton( + "다시 시도", + "#FEF2F2", + "#FCA5A5", + "#991B1B", + (_, _) => + { + _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); + RetryLastUserMessageFromConversation(); + }, + trailingMargin: false); + retryButton.Margin = new Thickness(0, 6, 0, 0); + runCardStack.Children.Add(retryButton); + } + + panel.Children.Add(new Border + { + Background = popupBackground, + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(7), + Padding = new Thickness(7, 5, 7, 5), + Margin = new Thickness(6, 0, 6, 4), + Child = runCardStack + }); + } + } + + var activeTasks = FilterTaskSummaryItems(_appState.ActiveTasks).Take(3).ToList(); + var recentTasks = FilterTaskSummaryItems(_appState.RecentTasks).Take(2).ToList(); + + foreach (var task in activeTasks) + panel.Children.Add(BuildTaskSummaryCard(task, active: true)); + + if (ShouldIncludeRecentTaskSummary(activeTasks)) + { + foreach (var task in recentTasks) + panel.Children.Add(BuildTaskSummaryCard(task, active: false)); + } + + if (activeTasks.Count == 0 && recentTasks.Count == 0) + { + panel.Children.Add(new TextBlock + { + Text = "표시할 작업 이력이 없습니다.", + Margin = new Thickness(10, 2, 10, 8), + Foreground = secondaryText, + }); + } + + _taskSummaryPopup = new Popup + { + PlacementTarget = _taskSummaryTarget, + Placement = PlacementMode.Top, + AllowsTransparency = true, + StaysOpen = false, + PopupAnimation = PopupAnimation.Fade, + Child = new Border + { + Background = popupBackground, + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(6), + Child = new ScrollViewer + { + Content = panel, + MaxHeight = 340, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + } + } + }; + + _taskSummaryPopup.IsOpen = true; + } + + private Border BuildTaskSummaryCard(TaskRunStore.TaskRun task, bool active) + { + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; + var (kindIcon, kindColor) = GetTaskKindVisual(task.Kind); + var categoryLabel = GetTranscriptTaskCategory(task); + var displayTitle = string.Equals(task.Kind, "tool", StringComparison.OrdinalIgnoreCase) + || string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) + || string.Equals(task.Kind, "hook", StringComparison.OrdinalIgnoreCase) + ? GetAgentItemDisplayName(task.Title) + : task.Title; + var taskStack = new StackPanel(); + var headerRow = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 2), + }; + headerRow.Children.Add(new TextBlock + { + Text = kindIcon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 9.5, + Foreground = kindColor, + Margin = new Thickness(0, 0, 4, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + headerRow.Children.Add(new TextBlock + { + Text = active + ? $"진행 중 · {displayTitle}" + : $"{GetTaskStatusLabel(task.Status)} · {displayTitle}", + FontSize = 9.5, + FontWeight = FontWeights.SemiBold, + Foreground = active ? primaryText : secondaryText, + TextWrapping = TextWrapping.Wrap, + }); + taskStack.Children.Add(headerRow); + + taskStack.Children.Add(new Border + { + Background = BrushFromHex("#F8FAFC"), + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(999), + Padding = new Thickness(6, 1, 6, 1), + Margin = new Thickness(0, 0, 0, 4), + HorizontalAlignment = HorizontalAlignment.Left, + Child = new TextBlock + { + Text = categoryLabel, + FontSize = 8, + FontWeight = FontWeights.SemiBold, + Foreground = secondaryText, + }, + }); + + if (!string.IsNullOrWhiteSpace(task.Summary)) + { + taskStack.Children.Add(new TextBlock + { + Text = TruncateForStatus(task.Summary, 96), + FontSize = 8.75, + Foreground = secondaryText, + TextWrapping = TextWrapping.Wrap, + }); + } + + var reviewChipRow = BuildReviewSignalChipRow( + kind: task.Kind, + toolName: task.Title, + title: displayTitle, + summary: task.Summary); + if (reviewChipRow != null) + taskStack.Children.Add(reviewChipRow); + + var actionRow = BuildTaskSummaryActionRow(task, active); + if (actionRow != null) + taskStack.Children.Add(actionRow); + + return new Border + { + Background = active ? BrushFromHex("#F8FAFC") : (TryFindResource("LauncherBackground") as Brush ?? Brushes.White), + BorderBrush = BrushFromHex("#E5E7EB"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(7), + Padding = new Thickness(8, 5, 8, 5), + Margin = new Thickness(8, 0, 8, 4), + Child = taskStack + }; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index fc60257..277b269 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -9099,12 +9099,12 @@ public partial class ChatWindow : Window private void UpdateTaskSummaryIndicators() { - var status = _appState.GetOperationalStatus(_activeTab); var hasLiveRuntimeActivity = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase) && (_runningConversationCount > 0 || _appState.ActiveTasks.Count > 0); + var status = _appState.GetOperationalStatusPresentation(_activeTab, hasLiveRuntimeActivity); if (RuntimeActivityBadge != null) - RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge && hasLiveRuntimeActivity + RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge ? Visibility.Visible : Visibility.Collapsed; @@ -9119,12 +9119,7 @@ public partial class ChatWindow : Window if (ConversationStatusStrip != null && ConversationStatusStripLabel != null) { - var showCompactStrip = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase) - && (string.Equals(status.StripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase) - || string.Equals(status.StripKind, "failed_run", StringComparison.OrdinalIgnoreCase) - || string.Equals(status.StripKind, "permission_denied", StringComparison.OrdinalIgnoreCase)); - - if (!showCompactStrip) + if (!status.ShowCompactStrip) { ConversationStatusStrip.Visibility = Visibility.Collapsed; ConversationStatusStripLabel.Text = ""; @@ -9735,398 +9730,6 @@ public partial class ChatWindow : Window outerStack.Children.Add(resultLabel); } - private async Task ShowInlineUserAskAsync(string question, List options, string defaultValue) - { - var tcs = new TaskCompletionSource(); - - await Dispatcher.InvokeAsync(() => - { - AddUserAskCard(question, options, defaultValue, tcs); - }); - - var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); - if (completed != tcs.Task) - { - await Dispatcher.InvokeAsync(() => - { - RemoveUserAskCard(); - }); - return null; - } - - return await tcs.Task; - } - - private void RemoveUserAskCard() - { - if (_userAskCard == null) - return; - - MessagePanel.Children.Remove(_userAskCard); - _userAskCard = null; - } - - private void AddUserAskCard(string question, List options, string defaultValue, TaskCompletionSource tcs) - { - RemoveUserAskCard(); - - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; - var accentColor = ((SolidColorBrush)accentBrush).Color; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var borderBrush = TryFindResource("BorderColor") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x24, accentColor.R, accentColor.G, accentColor.B)); - var itemBg = TryFindResource("ItemBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x10, accentColor.R, accentColor.G, accentColor.B)); - var hoverBg = TryFindResource("ItemHoverBackground") as Brush - ?? new SolidColorBrush(Color.FromArgb(0x16, 0xFF, 0xFF, 0xFF)); - var okBrush = BrushFromHex("#10B981"); - var dangerBrush = BrushFromHex("#EF4444"); - - var container = new Border - { - Margin = new Thickness(40, 4, 90, 8), - HorizontalAlignment = HorizontalAlignment.Left, - MaxWidth = Math.Max(420, GetMessageMaxWidth() - 36), - Background = itemBg, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(14), - Padding = new Thickness(14, 12, 14, 12), - }; - - var outer = new StackPanel(); - outer.Children.Add(new TextBlock - { - Text = "의견 요청", - FontSize = 12.5, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - }); - outer.Children.Add(new TextBlock - { - Text = question, - Margin = new Thickness(0, 4, 0, 10), - FontSize = 12.5, - Foreground = primaryText, - TextWrapping = TextWrapping.Wrap, - LineHeight = 20, - }); - - Border? selectedOption = null; - string selectedResponse = defaultValue; - - if (options.Count > 0) - { - var optionPanel = new WrapPanel - { - Margin = new Thickness(0, 0, 0, 10), - ItemWidth = double.NaN, - }; - - foreach (var option in options.Where(static option => !string.IsNullOrWhiteSpace(option))) - { - var optionLabel = option.Trim(); - var optBorder = new Border - { - Background = Brushes.Transparent, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(999), - Padding = new Thickness(10, 6, 10, 6), - Margin = new Thickness(0, 0, 8, 8), - Cursor = Cursors.Hand, - Child = new TextBlock - { - Text = optionLabel, - FontSize = 12, - Foreground = primaryText, - }, - }; - - optBorder.MouseEnter += (s, _) => - { - if (!ReferenceEquals(selectedOption, s)) - ((Border)s).Background = hoverBg; - }; - optBorder.MouseLeave += (s, _) => - { - if (!ReferenceEquals(selectedOption, s)) - ((Border)s).Background = Brushes.Transparent; - }; - optBorder.MouseLeftButtonUp += (_, _) => - { - if (selectedOption != null) - { - selectedOption.Background = Brushes.Transparent; - selectedOption.BorderBrush = borderBrush; - } - - selectedOption = optBorder; - selectedOption.Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)); - selectedOption.BorderBrush = accentBrush; - selectedResponse = optionLabel; - }; - - optionPanel.Children.Add(optBorder); - } - - outer.Children.Add(optionPanel); - } - - outer.Children.Add(new TextBlock - { - Text = "직접 입력", - FontSize = 11.5, - Foreground = secondaryText, - Margin = new Thickness(0, 0, 0, 6), - }); - - var inputBox = new TextBox - { - Text = defaultValue, - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap, - MinHeight = 42, - MaxHeight = 100, - FontSize = 12.5, - Padding = new Thickness(10, 8, 10, 8), - Background = Brushes.Transparent, - Foreground = primaryText, - CaretBrush = primaryText, - BorderBrush = borderBrush, - BorderThickness = new Thickness(1), - }; - inputBox.TextChanged += (_, _) => - { - if (!string.IsNullOrWhiteSpace(inputBox.Text)) - { - selectedResponse = inputBox.Text.Trim(); - if (selectedOption != null) - { - selectedOption.Background = Brushes.Transparent; - selectedOption.BorderBrush = borderBrush; - selectedOption = null; - } - } - }; - outer.Children.Add(inputBox); - - var buttonRow = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Margin = new Thickness(0, 12, 0, 0), - }; - - Border BuildActionButton(string label, Brush bg, Brush fg) - { - return new Border - { - Background = bg, - BorderBrush = bg, - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(999), - Padding = new Thickness(12, 7, 12, 7), - Margin = new Thickness(8, 0, 0, 0), - Cursor = Cursors.Hand, - Child = new TextBlock - { - Text = label, - FontSize = 12, - FontWeight = FontWeights.SemiBold, - Foreground = fg, - }, - }; - } - - var cancelBtn = BuildActionButton("취소", Brushes.Transparent, dangerBrush); - cancelBtn.BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xEF, 0x44, 0x44)); - cancelBtn.MouseLeftButtonUp += (_, _) => - { - RemoveUserAskCard(); - tcs.TrySetResult(null); - }; - buttonRow.Children.Add(cancelBtn); - - var submitBtn = BuildActionButton("전달", okBrush, Brushes.White); - submitBtn.MouseLeftButtonUp += (_, _) => - { - var finalResponse = !string.IsNullOrWhiteSpace(inputBox.Text) - ? inputBox.Text.Trim() - : selectedResponse?.Trim(); - - if (string.IsNullOrWhiteSpace(finalResponse)) - finalResponse = defaultValue?.Trim(); - - if (string.IsNullOrWhiteSpace(finalResponse)) - return; - - RemoveUserAskCard(); - tcs.TrySetResult(finalResponse); - }; - buttonRow.Children.Add(submitBtn); - - outer.Children.Add(buttonRow); - container.Child = outer; - _userAskCard = container; - - container.Opacity = 0; - container.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180))); - MessagePanel.Children.Add(container); - ForceScrollToEnd(); - inputBox.Focus(); - inputBox.CaretIndex = inputBox.Text.Length; - } - - // ════════════════════════════════════════════════════════════ - // 실행 계획 뷰어 (PlanViewerWindow) 연동 - // ════════════════════════════════════════════════════════════ - - /// PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다. - private Func, Task> CreatePlanDecisionCallback() - { - return async (planSummary, options) => - { - var tcs = new TaskCompletionSource(); - var steps = Services.Agent.TaskDecomposer.ExtractSteps(planSummary); - - await Dispatcher.InvokeAsync(() => - { - EnsurePlanViewerWindow(); - _planViewerWindow?.LoadPlan(planSummary, steps, tcs); - ShowPlanButton(true); - AddDecisionButtons(tcs, options); - }); - - // 5분 타임아웃 - var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5))); - if (completed != tcs.Task) - { - await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); - return "취소"; - } - - var result = await tcs.Task; - var agentDecision = result; - if (result == null) - { - agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix); - } - else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase) - && !string.Equals(result, "승인", StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(result)) - { - agentDecision = $"수정 요청: {result.Trim()}"; - } - - // 승인된 경우 — 실행 모드로 전환 - if (result == null) // null = 승인 - { - await Dispatcher.InvokeAsync(() => - { - _planViewerWindow?.SwitchToExecutionMode(); - _planViewerWindow?.Hide(); // 숨기고 하단 버튼으로 다시 열기 - }); - } - else - { - await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide()); - } - - return agentDecision; - }; - } - - private void EnsurePlanViewerWindow() - { - if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) - return; - - _planViewerWindow = new PlanViewerWindow(this); - _planViewerWindow.Closing += (_, e) => - { - e.Cancel = true; - _planViewerWindow.Hide(); - }; - } - - /// 하단 바에 계획 보기 버튼을 표시/숨김합니다. - private void ShowPlanButton(bool show) - { - if (!show) - { - // 계획 버튼 제거 - for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--) - { - if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn") - { - // 앞의 구분선도 제거 - if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep") - MoodIconPanel.Children.RemoveAt(i - 1); - if (i < MoodIconPanel.Children.Count) - MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1)); - break; - } - } - return; - } - - // 이미 있으면 무시 - foreach (var child in MoodIconPanel.Children) - { - if (child is Border b && b.Tag?.ToString() == "PlanBtn") return; - } - - // 구분선 - var separator = new Border - { - Width = 1, Height = 18, - Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray, - Margin = new Thickness(4, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - Tag = "PlanSep", - }; - MoodIconPanel.Children.Add(separator); - - // 계획 버튼 - var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981"); - planBtn.Tag = "PlanBtn"; - planBtn.MouseLeftButtonUp += (_, e) => - { - e.Handled = true; - if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) - { - _planViewerWindow.Show(); - _planViewerWindow.Activate(); - } - }; - MoodIconPanel.Children.Add(planBtn); - } - - /// 계획 뷰어에서 현재 실행 단계를 갱신합니다. - private void UpdatePlanViewerStep(AgentEvent evt) - { - if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return; - if (evt.StepCurrent > 0) - _planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based - } - - /// 계획 실행 완료를 뷰어에 알립니다. - private void CompletePlanViewer() - { - if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow)) - _planViewerWindow.MarkComplete(); - ShowPlanButton(false); - } - - private static bool IsWindowAlive(Window? w) - { - if (w == null) return false; - try { var _ = w.IsVisible; return true; } - catch { return false; } - } - // ════════════════════════════════════════════════════════════ // 후속 작업 제안 칩 (suggest_actions) // ════════════════════════════════════════════════════════════ @@ -10557,25 +10160,35 @@ public partial class ChatWindow : Window // 전체 통계 이벤트는 별도 색상 (보라색 계열) var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats"; + var transcriptBadgeLabel = GetTranscriptBadgeLabel(evt); + var permissionPresentation = evt.Type switch + { + AgentEventType.PermissionRequest => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: true), + AgentEventType.PermissionGranted => PermissionRequestPresentationCatalog.Resolve(evt.ToolName, pending: false), + _ => null + }; + var toolResultPresentation = evt.Type == AgentEventType.ToolResult + ? ToolResultPresentationCatalog.Resolve(evt, transcriptBadgeLabel) + : null; var (icon, label, bgHex, fgHex) = isTotalStats ? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED") : evt.Type switch { - AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"), - AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true), - AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false), - AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"), + AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"), + AgentEventType.PermissionRequest => (permissionPresentation!.Icon, permissionPresentation.Label, permissionPresentation.BackgroundHex, permissionPresentation.ForegroundHex), + AgentEventType.PermissionGranted => (permissionPresentation!.Icon, permissionPresentation.Label, permissionPresentation.BackgroundHex, permissionPresentation.ForegroundHex), + AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"), AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary), - AgentEventType.ToolCall => ("\uE8A7", GetTranscriptBadgeLabel(evt), "#EEF6FF", "#3B82F6"), - AgentEventType.ToolResult => ("\uE73E", GetTranscriptBadgeLabel(evt), "#EEF9EE", "#16A34A"), - AgentEventType.SkillCall => ("\uE8A5", GetTranscriptBadgeLabel(evt), "#FFF7ED", "#EA580C"), - AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"), - AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"), - AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"), - AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"), - AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"), - _ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"), + AgentEventType.ToolCall => ("\uE8A7", transcriptBadgeLabel, "#EEF6FF", "#3B82F6"), + AgentEventType.ToolResult => (toolResultPresentation!.Icon, toolResultPresentation.Label, toolResultPresentation.BackgroundHex, toolResultPresentation.ForegroundHex), + AgentEventType.SkillCall => ("\uE8A5", transcriptBadgeLabel, "#FFF7ED", "#EA580C"), + AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"), + AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"), + AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"), + AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"), + AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"), + _ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"), }; var itemDisplayName = evt.Type == AgentEventType.SkillCall ? GetAgentItemDisplayName(evt.ToolName, slashPrefix: true) @@ -18751,30 +18364,6 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi return ("\uE70F", "계획 확인", "#FFF7ED", "#C2410C"); } - private static (string icon, string label, string bgHex, string fgHex) GetPermissionBadgeMeta(string? toolName, bool pending) - { - var tool = toolName?.Trim().ToLowerInvariant() ?? ""; - - if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell")) - return pending - ? ("\uE756", "명령 권한 요청", "#FEF2F2", "#DC2626") - : ("\uE73E", "명령 권한 허용", "#ECFDF5", "#059669"); - - if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http")) - return pending - ? ("\uE774", "네트워크 권한 요청", "#FFF7ED", "#C2410C") - : ("\uE73E", "네트워크 권한 허용", "#ECFDF5", "#059669"); - - if (tool.Contains("file")) - return pending - ? ("\uE8A5", "파일 권한 요청", "#FFF7ED", "#C2410C") - : ("\uE73E", "파일 권한 허용", "#ECFDF5", "#059669"); - - return pending - ? ("\uE897", "권한 요청", "#FFF7ED", "#C2410C") - : ("\uE73E", "권한 허용", "#ECFDF5", "#059669"); -} - private static bool IsDecisionPending(string? summary) { var text = summary?.Trim() ?? ""; @@ -20535,261 +20124,6 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi RenderMessages(); } - private void RuntimeTaskSummary_Click(object sender, MouseButtonEventArgs e) - { - e.Handled = true; - _taskSummaryTarget = sender as UIElement ?? RuntimeActivityBadge; - ShowTaskSummaryPopup(); - } - - private void ShowTaskSummaryPopup() - { - if (_taskSummaryTarget == null) - return; - - if (_taskSummaryPopup != null) - _taskSummaryPopup.IsOpen = false; - - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; - var popupBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; - var panel = new StackPanel { Margin = new Thickness(2) }; - panel.Children.Add(new TextBlock - { - Text = "작업 요약", - FontSize = 11, - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - Margin = new Thickness(8, 5, 8, 2), - }); - panel.Children.Add(new TextBlock - { - Text = "현재 상태 요약", - FontSize = 8.5, - Foreground = secondaryText, - Margin = new Thickness(8, 0, 8, 5), - }); - - ChatConversation? currentConversation; - lock (_convLock) currentConversation = _currentConversation; - AddTaskSummaryObservabilitySections(panel, currentConversation); - - if (!string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)) - { - var currentRun = new Border - { - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#E2E8F0"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(8, 6, 8, 6), - Margin = new Thickness(6, 0, 6, 6), - Child = new StackPanel - { - Children = - { - new TextBlock - { - Text = $"실행 run {ShortRunId(_appState.AgentRun.RunId)}", - FontWeight = FontWeights.SemiBold, - Foreground = primaryText, - FontSize = 9.75, - }, - new TextBlock - { - Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · step {_appState.AgentRun.LastIteration}", - Margin = new Thickness(0, 2, 0, 0), - Foreground = GetRunStatusBrush(_appState.AgentRun.Status), - FontSize = 9, - }, - new TextBlock - { - Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary, - Margin = new Thickness(0, 3, 0, 0), - TextWrapping = TextWrapping.Wrap, - Foreground = Brushes.DimGray, - FontSize = 9, - } - } - } - }; - panel.Children.Add(currentRun); - } - - var recentAgentRuns = _appState.GetRecentAgentRuns(1); - if (recentAgentRuns.Count > 0) - { - panel.Children.Add(new TextBlock - { - Text = "마지막 실행", - FontSize = 9, - FontWeight = FontWeights.SemiBold, - Foreground = Brushes.DimGray, - Margin = new Thickness(8, 0, 8, 2), - }); - - foreach (var run in recentAgentRuns) - { - var runEvents = GetExecutionEventsForRun(run.RunId, 1); - var runFilePaths = GetExecutionEventFilePaths(run.RunId, 1); - var runDisplay = _appState.GetRunDisplay(run); - var runCardStack = new StackPanel - { - Children = - { - new TextBlock - { - Text = runDisplay.HeaderText, - FontWeight = FontWeights.SemiBold, - Foreground = GetRunStatusBrush(run.Status), - FontSize = 9.5, - }, - new TextBlock - { - Text = runDisplay.MetaText, - Margin = new Thickness(0, 1, 0, 0), - Foreground = secondaryText, - FontSize = 8.25, - }, - new TextBlock - { - Text = TruncateForStatus(runDisplay.SummaryText, 92), - Margin = new Thickness(0, 1.5, 0, 0), - TextWrapping = TextWrapping.Wrap, - Foreground = secondaryText, - FontSize = 8.5, - } - } - }; - - if (runEvents.Count > 0 || runFilePaths.Count > 0) - { - var activitySummary = new StackPanel(); - activitySummary.Children.Add(new TextBlock - { - Text = $"로그 {runEvents.Count} · 파일 {runFilePaths.Count}", - FontSize = 8, - Foreground = secondaryText, - }); - - if (!string.IsNullOrWhiteSpace(run.RunId)) - { - var capturedRunId = run.RunId; - var timelineButton = CreateTaskSummaryActionButton( - "타임라인", - "#F8FAFC", - "#CBD5E1", - "#334155", - (_, _) => ScrollToRunInTimeline(capturedRunId), - trailingMargin: false); - timelineButton.Margin = new Thickness(0, 5, 0, 0); - activitySummary.Children.Add(timelineButton); - } - - runCardStack.Children.Add(new Border - { - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#E2E8F0"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(7), - Padding = new Thickness(6, 4, 6, 4), - Margin = new Thickness(0, 5, 0, 0), - Child = activitySummary - }); - } - - if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase)) - { - var capturedRun = run; - var followUpButton = CreateTaskSummaryActionButton( - "후속 큐", - "#ECFDF5", - "#BBF7D0", - "#166534", - (_, _) => EnqueueFollowUpFromRun(capturedRun), - trailingMargin: false); - followUpButton.Margin = new Thickness(0, 6, 0, 0); - runCardStack.Children.Add(followUpButton); - } - - if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation()) - { - var retryButton = CreateTaskSummaryActionButton( - "다시 시도", - "#FEF2F2", - "#FCA5A5", - "#991B1B", - (_, _) => - { - _taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false); - RetryLastUserMessageFromConversation(); - }, - trailingMargin: false); - retryButton.Margin = new Thickness(0, 6, 0, 0); - runCardStack.Children.Add(retryButton); - } - - panel.Children.Add(new Border - { - Background = popupBackground, - BorderBrush = BrushFromHex("#E5E7EB"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(7), - Padding = new Thickness(7, 5, 7, 5), - Margin = new Thickness(6, 0, 6, 4), - Child = runCardStack - }); - } - } - - var activeTasks = FilterTaskSummaryItems(_appState.ActiveTasks).Take(3).ToList(); - var recentTasks = FilterTaskSummaryItems(_appState.RecentTasks).Take(2).ToList(); - - foreach (var task in activeTasks) - panel.Children.Add(BuildTaskSummaryCard(task, active: true)); - - if (ShouldIncludeRecentTaskSummary(activeTasks)) - { - foreach (var task in recentTasks) - panel.Children.Add(BuildTaskSummaryCard(task, active: false)); - } - - if (activeTasks.Count == 0 && recentTasks.Count == 0) - { - panel.Children.Add(new TextBlock - { - Text = "표시할 작업 이력이 없습니다.", - Margin = new Thickness(10, 2, 10, 8), - Foreground = secondaryText, - }); - } - - _taskSummaryPopup = new Popup - { - PlacementTarget = _taskSummaryTarget, - Placement = PlacementMode.Top, - AllowsTransparency = true, - StaysOpen = false, - PopupAnimation = PopupAnimation.Fade, - Child = new Border - { - Background = popupBackground, - BorderBrush = BrushFromHex("#E5E7EB"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(12), - Padding = new Thickness(6), - Child = new ScrollViewer - { - Content = panel, - MaxHeight = 340, - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - } - } - }; - - _taskSummaryPopup.IsOpen = true; - } - private bool CanRetryCurrentConversation() { return !string.IsNullOrWhiteSpace(GetLastUserMessageFromConversation()); @@ -21539,97 +20873,6 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi ShowTaskSummaryPopup(); } - private Border BuildTaskSummaryCard(TaskRunStore.TaskRun task, bool active) - { - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray; - var (kindIcon, kindColor) = GetTaskKindVisual(task.Kind); - var categoryLabel = GetTranscriptTaskCategory(task); - var displayTitle = string.Equals(task.Kind, "tool", StringComparison.OrdinalIgnoreCase) - || string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) - || string.Equals(task.Kind, "hook", StringComparison.OrdinalIgnoreCase) - ? GetAgentItemDisplayName(task.Title) - : task.Title; - var taskStack = new StackPanel(); - var headerRow = new StackPanel - { - Orientation = Orientation.Horizontal, - Margin = new Thickness(0, 0, 0, 2), - }; - headerRow.Children.Add(new TextBlock - { - Text = kindIcon, - FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 9.5, - Foreground = kindColor, - Margin = new Thickness(0, 0, 4, 0), - VerticalAlignment = VerticalAlignment.Center, - }); - headerRow.Children.Add(new TextBlock - { - Text = active - ? $"진행 중 · {displayTitle}" - : $"{GetTaskStatusLabel(task.Status)} · {displayTitle}", - FontSize = 9.5, - FontWeight = FontWeights.SemiBold, - Foreground = active ? primaryText : secondaryText, - TextWrapping = TextWrapping.Wrap, - }); - taskStack.Children.Add(headerRow); - - taskStack.Children.Add(new Border - { - Background = BrushFromHex("#F8FAFC"), - BorderBrush = BrushFromHex("#E5E7EB"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(999), - Padding = new Thickness(6, 1, 6, 1), - Margin = new Thickness(0, 0, 0, 4), - HorizontalAlignment = HorizontalAlignment.Left, - Child = new TextBlock - { - Text = categoryLabel, - FontSize = 8, - FontWeight = FontWeights.SemiBold, - Foreground = secondaryText, - }, - }); - - if (!string.IsNullOrWhiteSpace(task.Summary)) - { - taskStack.Children.Add(new TextBlock - { - Text = TruncateForStatus(task.Summary, 96), - FontSize = 8.75, - Foreground = secondaryText, - TextWrapping = TextWrapping.Wrap, - }); - } - - var reviewChipRow = BuildReviewSignalChipRow( - kind: task.Kind, - toolName: task.Title, - title: displayTitle, - summary: task.Summary); - if (reviewChipRow != null) - taskStack.Children.Add(reviewChipRow); - - var actionRow = BuildTaskSummaryActionRow(task, active); - if (actionRow != null) - taskStack.Children.Add(actionRow); - - return new Border - { - Background = active ? BrushFromHex("#F8FAFC") : (TryFindResource("LauncherBackground") as Brush ?? Brushes.White), - BorderBrush = BrushFromHex("#E5E7EB"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(7), - Padding = new Thickness(8, 5, 8, 5), - Margin = new Thickness(8, 0, 8, 4), - Child = taskStack - }; - } - private Button CreateTaskSummaryActionButton( string label, string bg,