AX Agent 질문/의견 요청 흐름을 transcript 우선으로 전환
Some checks failed
Release Gate / gate (push) Has been cancelled

- user_ask 콜백을 별도 팝업 대신 본문 inline 카드 경로로 변경
- 선택지 pill, 직접 입력, 전달/취소 버튼을 timeline 안에서 처리
- 계획 승인과 질문 요청이 같은 transcript-first UX 원칙을 따르도록 정리
- claw-code parity 문서와 개발 이력 문서에 질문/승인 UX 기준을 반영

검증 결과
- 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 18:59:32 +09:00
parent 6c5b0c5be3
commit 4cbe60052e
4 changed files with 262 additions and 10 deletions

View File

@@ -67,6 +67,7 @@ public partial class ChatWindow : Window
private bool _aiIconPulseStopped; // 펄스 1회만 중지
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
private PlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어
private Border? _userAskCard; // transcript 내 질문 카드
private bool _userScrolled; // 사용자가 위로 스크롤했는지
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
@@ -200,16 +201,7 @@ public partial class ChatWindow : Window
return decision != PermissionRequestWindow.PermissionPromptResult.Reject;
},
UserAskCallback = async (question, options, defaultValue) =>
{
string? response = null;
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
await appDispatcher.InvokeAsync(() =>
{
response = UserAskDialog.Show(question, options, defaultValue);
});
return response;
},
UserAskCallback = ShowInlineUserAskAsync,
};
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
@@ -9902,6 +9894,250 @@ public partial class ChatWindow : Window
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) 연동
// ════════════════════════════════════════════════════════════