feat(team): interactive AskUserQuestion options in ToolApprovalSheet

- Options are clickable: single-select (radio) and multi-select (checkbox)
- Selected options highlighted with green border and filled indicator
- Submit button replaces Allow for AskUserQuestion - disabled until
  at least one option is selected
- Human-readable tool display names (AskUserQuestion -> Question, etc.)
- Selection resets when approval changes
This commit is contained in:
iliya 2026-03-28 16:49:24 +02:00
parent daef2db07c
commit 1e80f3db52

View file

@ -22,6 +22,30 @@ import type { ToolApprovalRequest } from '@shared/types';
// Tool icon mapping // Tool icon mapping
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Human-readable tool name for the approval header */
function getToolDisplayName(toolName: string): string {
switch (toolName) {
case 'AskUserQuestion':
return 'Question';
case 'Bash':
return 'Terminal';
case 'Read':
return 'Read File';
case 'Edit':
return 'Edit File';
case 'Write':
return 'Write File';
case 'NotebookEdit':
return 'Edit Notebook';
case 'Grep':
return 'Search Content';
case 'Glob':
return 'Find Files';
default:
return toolName;
}
}
function getToolIcon(toolName: string): React.JSX.Element { function getToolIcon(toolName: string): React.JSX.Element {
const cls = 'size-4 shrink-0'; const cls = 'size-4 shrink-0';
switch (toolName) { switch (toolName) {
@ -132,10 +156,12 @@ export const ToolApprovalSheet: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [diffExpanded, setDiffExpanded] = useState(false); const [diffExpanded, setDiffExpanded] = useState(false);
const [settingsExpanded, setSettingsExpanded] = useState(false); const [settingsExpanded, setSettingsExpanded] = useState(false);
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set());
// Clear error when current approval changes // Clear error + selection when current approval changes
useEffect(() => { useEffect(() => {
setError(null); setError(null);
setSelectedOptions(new Set());
}, [current?.requestId]); }, [current?.requestId]);
const handleRespond = useCallback( const handleRespond = useCallback(
@ -167,6 +193,21 @@ export const ToolApprovalSheet: React.FC = () => {
[current, disabled, respondToToolApproval] [current, disabled, respondToToolApproval]
); );
const isAskQuestion = current?.toolName === 'AskUserQuestion';
const hasSelection = selectedOptions.size > 0;
const handleOptionSelect = useCallback((label: string, multiSelect: boolean) => {
setSelectedOptions((prev) => {
const next = multiSelect ? new Set(prev) : new Set<string>();
if (next.has(label)) {
next.delete(label);
} else {
next.add(label);
}
return next;
});
}, []);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => { const handleKeyDown = (e: KeyboardEvent): void => {
const tag = document.activeElement?.tagName; const tag = document.activeElement?.tagName;
@ -222,7 +263,7 @@ export const ToolApprovalSheet: React.FC = () => {
)} )}
{getToolIcon(current.toolName)} {getToolIcon(current.toolName)}
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}> <span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{current.toolName} {getToolDisplayName(current.toolName)}
</span> </span>
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@ -247,6 +288,8 @@ export const ToolApprovalSheet: React.FC = () => {
toolName={current.toolName} toolName={current.toolName}
toolInput={current.toolInput} toolInput={current.toolInput}
projectPath={selectedTeamData?.config?.projectPath} projectPath={selectedTeamData?.config?.projectPath}
selectedOptions={isAskQuestion ? selectedOptions : undefined}
onOptionSelect={isAskQuestion ? handleOptionSelect : undefined}
/> />
{/* Diff preview (Write/Edit/NotebookEdit only) */} {/* Diff preview (Write/Edit/NotebookEdit only) */}
@ -280,19 +323,30 @@ export const ToolApprovalSheet: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled || (isAskQuestion && !hasSelection)}
onClick={() => handleRespond(true)} onClick={() => handleRespond(true)}
className="rounded-md px-3.5 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50" className="rounded-md px-3.5 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: 'rgb(5, 150, 105)' }} style={{
backgroundColor:
isAskQuestion && !hasSelection
? 'var(--color-surface-raised)'
: 'rgb(5, 150, 105)',
color: isAskQuestion && !hasSelection ? 'var(--color-text-muted)' : undefined,
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!disabled) if (!disabled && !(isAskQuestion && !hasSelection))
Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(16, 185, 129)' }); Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(16, 185, 129)' });
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(5, 150, 105)' }); Object.assign(e.currentTarget.style, {
backgroundColor:
isAskQuestion && !hasSelection
? 'var(--color-surface-raised)'
: 'rgb(5, 150, 105)',
});
}} }}
> >
Allow {isAskQuestion ? 'Submit' : 'Allow'}
</button> </button>
<button <button
type="button" type="button"
@ -375,10 +429,14 @@ const ToolInputPreview = ({
toolName, toolName,
toolInput, toolInput,
projectPath, projectPath,
selectedOptions,
onOptionSelect,
}: { }: {
toolName: string; toolName: string;
toolInput: Record<string, unknown>; toolInput: Record<string, unknown>;
projectPath?: string; projectPath?: string;
selectedOptions?: Set<string>;
onOptionSelect?: (label: string, multiSelect: boolean) => void;
}): React.JSX.Element => { }): React.JSX.Element => {
const text = renderToolInput(toolName, toolInput, projectPath); const text = renderToolInput(toolName, toolInput, projectPath);
const fileName = getToolInputFileName(toolName, toolInput); const fileName = getToolInputFileName(toolName, toolInput);
@ -423,33 +481,51 @@ const ToolInputPreview = ({
)} )}
{Array.isArray(q.options) && ( {Array.isArray(q.options) && (
<div className="space-y-1.5"> <div className="space-y-1.5">
{q.options.map((opt, oi) => ( {q.options.map((opt, oi) => {
<div const optKey = opt.label ?? `opt-${oi}`;
key={oi} const isSelected = selectedOptions?.has(optKey) ?? false;
className="flex items-start gap-2 rounded px-2 py-1.5" return (
style={{ backgroundColor: 'var(--color-surface-raised)' }} <button
> key={oi}
<span type="button"
className="mt-0.5 text-[10px]" onClick={() => onOptionSelect?.(optKey, q.multiSelect ?? false)}
style={{ color: 'var(--color-text-muted)' }} className="flex w-full items-start gap-2 rounded px-2 py-1.5 text-left transition-colors"
style={{
backgroundColor: isSelected
? 'rgba(5, 150, 105, 0.15)'
: 'var(--color-surface-raised)',
border: isSelected
? '1px solid rgba(5, 150, 105, 0.4)'
: '1px solid transparent',
}}
> >
{q.multiSelect ? '☐' : '○'} <span
</span> className="mt-0.5 text-[10px]"
<div className="min-w-0"> style={{
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}> color: isSelected ? 'rgb(52, 211, 153)' : 'var(--color-text-muted)',
{opt.label} }}
>
{q.multiSelect ? (isSelected ? '☑' : '☐') : isSelected ? '◉' : '○'}
</span> </span>
{opt.description && ( <div className="min-w-0">
<p <span
className="mt-0.5 text-[10px]" className="text-xs font-medium"
style={{ color: 'var(--color-text-muted)' }} style={{ color: isSelected ? 'rgb(52, 211, 153)' : 'var(--color-text)' }}
> >
{opt.description} {opt.label}
</p> </span>
)} {opt.description && (
</div> <p
</div> className="mt-0.5 text-[10px]"
))} style={{ color: 'var(--color-text-muted)' }}
>
{opt.description}
</p>
)}
</div>
</button>
);
})}
</div> </div>
)} )}
</div> </div>