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

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