AX Agent transcript 렌더 구조를 분리하고 권한/도구 결과 표시 체계를 정리한다
Some checks failed
Release Gate / gate (push) Has been cancelled

- 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)
This commit is contained in:
2026-04-05 22:55:56 +09:00
parent 13f0e23ed5
commit f0af86cc1e
9 changed files with 910 additions and 783 deletions

View File

@@ -1113,3 +1113,7 @@ MIT License
- 업데이트: 2026-04-05 22:40 (KST) - 업데이트: 2026-04-05 22:40 (KST)
- AX Agent 테마를 다시 점검해 기존 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 2종을 추가했다. `Nord`는 차분한 블루그레이 업무형 톤, `Ember`는 따뜻한 앰버 문서 작업 톤으로 구성했다. - AX Agent 테마를 다시 점검해 기존 `Claw / Codex / Slate` 외에 `Nord`, `Ember` 2종을 추가했다. `Nord`는 차분한 블루그레이 업무형 톤, `Ember`는 따뜻한 앰버 문서 작업 톤으로 구성했다.
- 내부 설정 `테마 스타일` 카드에서도 새 프리셋을 바로 선택할 수 있게 연결했고, `system / light / dark` 모드 조합으로 같은 방식으로 적용되도록 정리했다. - 내부 설정 `테마 스타일` 카드에서도 새 프리셋을 바로 선택할 수 있게 연결했고, `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를 소비한다.

View File

@@ -4874,3 +4874,8 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- 업데이트: 2026-04-05 22:40 (KST) - 업데이트: 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) 이다. - 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`를 노출했다. - [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만 소비하도록 바뀌었다.

View File

@@ -407,3 +407,15 @@
- `BuildTopicButtons()` rebuild frequency - `BuildTopicButtons()` rebuild frequency
- `OnAgentEvent` timeline churn during long Cowork/Code runs - `OnAgentEvent` timeline churn during long Cowork/Code runs
- compact queue summary still needs one more pass to fully match `claw-code` footer minimalism - 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를 계산

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -158,6 +158,17 @@ public sealed class AppStateService
public string StripText { get; init; } = ""; 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 ChatSessionStateService? ChatSession { get; private set; }
public SkillCatalogState Skills { get; } = new(); public SkillCatalogState Skills { get; } = new();
public McpCatalogState Mcp { 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<DraftQueueItem> GetDraftQueueItems(string tab) public IReadOnlyList<DraftQueueItem> GetDraftQueueItems(string tab)
=> ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty<DraftQueueItem>(); => ChatSession?.GetDraftQueueItems(tab) ?? Array.Empty<DraftQueueItem>();

View File

@@ -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<string?> ShowInlineUserAskAsync(string question, List<string> options, string defaultValue)
{
var tcs = new TaskCompletionSource<string?>();
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<string> options, string defaultValue, TaskCompletionSource<string?> 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<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
{
return async (planSummary, options) =>
{
var tcs = new TaskCompletionSource<string?>();
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;
}
}
}

View File

@@ -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
};
}
}

View File

@@ -9099,12 +9099,12 @@ public partial class ChatWindow : Window
private void UpdateTaskSummaryIndicators() private void UpdateTaskSummaryIndicators()
{ {
var status = _appState.GetOperationalStatus(_activeTab);
var hasLiveRuntimeActivity = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase) var hasLiveRuntimeActivity = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase)
&& (_runningConversationCount > 0 || _appState.ActiveTasks.Count > 0); && (_runningConversationCount > 0 || _appState.ActiveTasks.Count > 0);
var status = _appState.GetOperationalStatusPresentation(_activeTab, hasLiveRuntimeActivity);
if (RuntimeActivityBadge != null) if (RuntimeActivityBadge != null)
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge && hasLiveRuntimeActivity RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
? Visibility.Visible ? Visibility.Visible
: Visibility.Collapsed; : Visibility.Collapsed;
@@ -9119,12 +9119,7 @@ public partial class ChatWindow : Window
if (ConversationStatusStrip != null && ConversationStatusStripLabel != null) if (ConversationStatusStrip != null && ConversationStatusStripLabel != null)
{ {
var showCompactStrip = !string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase) if (!status.ShowCompactStrip)
&& (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)
{ {
ConversationStatusStrip.Visibility = Visibility.Collapsed; ConversationStatusStrip.Visibility = Visibility.Collapsed;
ConversationStatusStripLabel.Text = ""; ConversationStatusStripLabel.Text = "";
@@ -9735,398 +9730,6 @@ public partial class ChatWindow : Window
outerStack.Children.Add(resultLabel); outerStack.Children.Add(resultLabel);
} }
private async Task<string?> ShowInlineUserAskAsync(string question, List<string> options, string defaultValue)
{
var tcs = new TaskCompletionSource<string?>();
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<string> options, string defaultValue, TaskCompletionSource<string?> 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) 연동
// ════════════════════════════════════════════════════════════
/// <summary>PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다.</summary>
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
{
return async (planSummary, options) =>
{
var tcs = new TaskCompletionSource<string?>();
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();
};
}
/// <summary>하단 바에 계획 보기 버튼을 표시/숨김합니다.</summary>
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);
}
/// <summary>계획 뷰어에서 현재 실행 단계를 갱신합니다.</summary>
private void UpdatePlanViewerStep(AgentEvent evt)
{
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return;
if (evt.StepCurrent > 0)
_planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based
}
/// <summary>계획 실행 완료를 뷰어에 알립니다.</summary>
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) // 후속 작업 제안 칩 (suggest_actions)
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
@@ -10557,25 +10160,35 @@ public partial class ChatWindow : Window
// 전체 통계 이벤트는 별도 색상 (보라색 계열) // 전체 통계 이벤트는 별도 색상 (보라색 계열)
var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats"; 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 var (icon, label, bgHex, fgHex) = isTotalStats
? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED") ? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED")
: evt.Type switch : evt.Type switch
{ {
AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"), AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"),
AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true), AgentEventType.PermissionRequest => (permissionPresentation!.Icon, permissionPresentation.Label, permissionPresentation.BackgroundHex, permissionPresentation.ForegroundHex),
AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false), AgentEventType.PermissionGranted => (permissionPresentation!.Icon, permissionPresentation.Label, permissionPresentation.BackgroundHex, permissionPresentation.ForegroundHex),
AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"), AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"),
AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary), AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary),
AgentEventType.ToolCall => ("\uE8A7", GetTranscriptBadgeLabel(evt), "#EEF6FF", "#3B82F6"), AgentEventType.ToolCall => ("\uE8A7", transcriptBadgeLabel, "#EEF6FF", "#3B82F6"),
AgentEventType.ToolResult => ("\uE73E", GetTranscriptBadgeLabel(evt), "#EEF9EE", "#16A34A"), AgentEventType.ToolResult => (toolResultPresentation!.Icon, toolResultPresentation.Label, toolResultPresentation.BackgroundHex, toolResultPresentation.ForegroundHex),
AgentEventType.SkillCall => ("\uE8A5", GetTranscriptBadgeLabel(evt), "#FFF7ED", "#EA580C"), AgentEventType.SkillCall => ("\uE8A5", transcriptBadgeLabel, "#FFF7ED", "#EA580C"),
AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"), AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"),
AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"), AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"),
AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"), AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"),
AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"), AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"),
AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"), AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"),
_ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"), _ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"),
}; };
var itemDisplayName = evt.Type == AgentEventType.SkillCall var itemDisplayName = evt.Type == AgentEventType.SkillCall
? GetAgentItemDisplayName(evt.ToolName, slashPrefix: true) ? GetAgentItemDisplayName(evt.ToolName, slashPrefix: true)
@@ -18751,30 +18364,6 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi
return ("\uE70F", "계획 확인", "#FFF7ED", "#C2410C"); 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) private static bool IsDecisionPending(string? summary)
{ {
var text = summary?.Trim() ?? ""; var text = summary?.Trim() ?? "";
@@ -20535,261 +20124,6 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi
RenderMessages(); 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() private bool CanRetryCurrentConversation()
{ {
return !string.IsNullOrWhiteSpace(GetLastUserMessageFromConversation()); return !string.IsNullOrWhiteSpace(GetLastUserMessageFromConversation());
@@ -21539,97 +20873,6 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi
ShowTaskSummaryPopup(); 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( private Button CreateTaskSummaryActionButton(
string label, string label,
string bg, string bg,