Some checks failed
Release Gate / gate (push) Has been cancelled
- 계획 승인 기본 흐름을 transcript inline 우선 구조로 더 정리하고, 계획 버튼은 저장된 계획을 여는 상세 보기 성격으로 분리했습니다. - OperationalStatusPresentationState를 확장해 runtime badge, compact strip, quick strip의 문구·강조색·노출 여부를 한 번에 계산하도록 통합했습니다. - ChatWindow 상태선/quick strip/status token 로직을 StatusPresentation partial로 분리해 메인 창 코드의 직접 분기와 렌더 책임을 줄였습니다. - 문서 이력(README, DEVELOPMENT)을 2026-04-06 01:37 KST 기준으로 갱신했습니다. - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
425 lines
14 KiB
C#
425 lines
14 KiB
C#
using System.Linq;
|
|
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(() =>
|
|
{
|
|
_pendingPlanSummary = planSummary;
|
|
_pendingPlanSteps = steps.ToList();
|
|
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();
|
|
ResetPendingPlanPresentation();
|
|
});
|
|
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();
|
|
});
|
|
}
|
|
else
|
|
{
|
|
await Dispatcher.InvokeAsync(() =>
|
|
{
|
|
_planViewerWindow?.Hide();
|
|
ResetPendingPlanPresentation();
|
|
});
|
|
}
|
|
|
|
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 (string.IsNullOrWhiteSpace(_pendingPlanSummary) && _pendingPlanSteps.Count == 0)
|
|
return;
|
|
|
|
EnsurePlanViewerWindow();
|
|
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_planViewerWindow.PlanText)
|
|
|| _planViewerWindow.PlanText != (_pendingPlanSummary ?? string.Empty)
|
|
|| !_planViewerWindow.Steps.SequenceEqual(_pendingPlanSteps))
|
|
{
|
|
_planViewerWindow.LoadPlanPreview(_pendingPlanSummary ?? "", _pendingPlanSteps);
|
|
}
|
|
_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();
|
|
ResetPendingPlanPresentation();
|
|
}
|
|
|
|
private void ResetPendingPlanPresentation()
|
|
{
|
|
_pendingPlanSummary = null;
|
|
_pendingPlanSteps.Clear();
|
|
ShowPlanButton(false);
|
|
}
|
|
|
|
private static bool IsWindowAlive(Window? w)
|
|
{
|
|
if (w == null)
|
|
return false;
|
|
try
|
|
{
|
|
var _ = w.IsVisible;
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|