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:
parent
daef2db07c
commit
1e80f3db52
1 changed files with 107 additions and 31 deletions
|
|
@ -22,6 +22,30 @@ import type { ToolApprovalRequest } from '@shared/types';
|
|||
// 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 {
|
||||
const cls = 'size-4 shrink-0';
|
||||
switch (toolName) {
|
||||
|
|
@ -132,10 +156,12 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [diffExpanded, setDiffExpanded] = 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(() => {
|
||||
setError(null);
|
||||
setSelectedOptions(new Set());
|
||||
}, [current?.requestId]);
|
||||
|
||||
const handleRespond = useCallback(
|
||||
|
|
@ -167,6 +193,21 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
[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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const tag = document.activeElement?.tagName;
|
||||
|
|
@ -222,7 +263,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
)}
|
||||
{getToolIcon(current.toolName)}
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
{current.toolName}
|
||||
{getToolDisplayName(current.toolName)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
|
@ -247,6 +288,8 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
toolName={current.toolName}
|
||||
toolInput={current.toolInput}
|
||||
projectPath={selectedTeamData?.config?.projectPath}
|
||||
selectedOptions={isAskQuestion ? selectedOptions : undefined}
|
||||
onOptionSelect={isAskQuestion ? handleOptionSelect : undefined}
|
||||
/>
|
||||
|
||||
{/* Diff preview (Write/Edit/NotebookEdit only) */}
|
||||
|
|
@ -280,19 +323,30 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
disabled={disabled || (isAskQuestion && !hasSelection)}
|
||||
onClick={() => handleRespond(true)}
|
||||
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) => {
|
||||
if (!disabled)
|
||||
if (!disabled && !(isAskQuestion && !hasSelection))
|
||||
Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(16, 185, 129)' });
|
||||
}}
|
||||
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
|
||||
type="button"
|
||||
|
|
@ -375,10 +429,14 @@ const ToolInputPreview = ({
|
|||
toolName,
|
||||
toolInput,
|
||||
projectPath,
|
||||
selectedOptions,
|
||||
onOptionSelect,
|
||||
}: {
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
projectPath?: string;
|
||||
selectedOptions?: Set<string>;
|
||||
onOptionSelect?: (label: string, multiSelect: boolean) => void;
|
||||
}): React.JSX.Element => {
|
||||
const text = renderToolInput(toolName, toolInput, projectPath);
|
||||
const fileName = getToolInputFileName(toolName, toolInput);
|
||||
|
|
@ -423,33 +481,51 @@ const ToolInputPreview = ({
|
|||
)}
|
||||
{Array.isArray(q.options) && (
|
||||
<div className="space-y-1.5">
|
||||
{q.options.map((opt, oi) => (
|
||||
<div
|
||||
key={oi}
|
||||
className="flex items-start gap-2 rounded px-2 py-1.5"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 text-[10px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
{q.options.map((opt, oi) => {
|
||||
const optKey = opt.label ?? `opt-${oi}`;
|
||||
const isSelected = selectedOptions?.has(optKey) ?? false;
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
type="button"
|
||||
onClick={() => onOptionSelect?.(optKey, q.multiSelect ?? false)}
|
||||
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>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{opt.label}
|
||||
<span
|
||||
className="mt-0.5 text-[10px]"
|
||||
style={{
|
||||
color: isSelected ? 'rgb(52, 211, 153)' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{q.multiSelect ? (isSelected ? '☑' : '☐') : isSelected ? '◉' : '○'}
|
||||
</span>
|
||||
{opt.description && (
|
||||
<p
|
||||
className="mt-0.5 text-[10px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: isSelected ? 'rgb(52, 211, 153)' : 'var(--color-text)' }}
|
||||
>
|
||||
{opt.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.description && (
|
||||
<p
|
||||
className="mt-0.5 text-[10px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{opt.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue