[eric] chat: questions and tool permission asks render as the vendored tool-ui cards (question-flow + approval-card); QuestionForm extracted to kill an import cycle; registry loads the full question-flow union

This commit is contained in:
ciregenz
2026-08-09 10:01:02 -07:00
parent 0688c85607
commit 6bd3f93f07
11 changed files with 549 additions and 456 deletions
+9
View File
@@ -194,6 +194,15 @@ class GrantResolveRequest(BaseModel):
remember: bool = False
@apps_sdk.router.delete("/tools/grants/{output_id}")
@typechecked
async def reset_tool_grants(output_id: str) -> Dict[str, bool]:
from backend.apps.apps_sdk.tool_grants import clear_grants
clear_grants(output_id)
return {"ok": True}
@apps_sdk.router.post("/tools/grant")
@typechecked
async def tools_grant(body: GrantResolveRequest) -> Dict[str, bool]:
+9
View File
@@ -66,6 +66,15 @@ def set_grant(output_id: str, tool_key: str, decision: Literal["granted", "denie
p_write_grants(grants)
@typechecked
def clear_grants(output_id: str) -> None:
"""Reset an app to ask-by-default: forgets its remembered Always/Never decisions."""
with p_lock:
grants = p_read_grants()
if grants.pop(output_id, None) is not None:
p_write_grants(grants)
@typechecked
async def request_grant(output_id: str, app_name: str, tool_key: str, tool_label: str, args_preview: str) -> bool:
"""Ask the user over the websocket and block until they answer or the wait expires. Timeout and
@@ -107,7 +107,8 @@ export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
},
'question-flow': {
Component: lazy(() => import('./components/question-flow').then((m) => ({ default: m.QuestionFlow }))),
loadSchema: () => import('./components/question-flow/schema').then((m) => m.SerializableProgressiveModeSchema),
// The full union: progressive (step/title), upfront (steps[]), and receipt modes are all valid wire shapes.
loadSchema: () => import('./components/question-flow/schema').then((m) => m.SerializableQuestionFlowSchema),
},
'stats-display': {
Component: lazy(() => import('./components/stats-display').then((m) => ({ default: m.StatsDisplay }))),
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { API_BASE } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import VendoredToolUi from '@/toolui/VendoredToolUi';
export const APP_TOOL_GRANT_EVENT = 'openswarm:app-tool-grant';
@@ -41,25 +41,27 @@ const AppToolGrantHost: React.FC = () => {
setReq(null);
};
return (
<Dialog open onClose={() => answer(false, false)} maxWidth="xs" fullWidth>
<Box sx={{ p: 2.5, background: c.bg.surface }}>
<Typography sx={{ fontWeight: 700, fontSize: 15, mb: 0.5, color: c.text.primary }}>
"{req.app_name}" wants to use {req.tool_label}
</Typography>
<Typography sx={{ fontSize: 12.5, color: c.text.secondary, mb: 1.5 }}>
This app is asking to call one of your connected tools. Only allow it if you trust the app
with this action.
</Typography>
{req.args_preview && req.args_preview !== '{}' && (
<Box sx={{ fontFamily: 'monospace', fontSize: 11, p: 1, borderRadius: '8px', background: c.bg.page, color: c.text.secondary, mb: 1.5, maxHeight: 96, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{req.args_preview}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button size="small" color="inherit" onClick={() => answer(false, true)}>Never allow</Button>
<Button size="small" color="inherit" onClick={() => answer(false, false)}>Deny</Button>
<Button size="small" variant="outlined" onClick={() => answer(true, false)}>Allow once</Button>
<Button size="small" variant="contained" disableElevation onClick={() => answer(true, true)}>Always allow</Button>
<Dialog open onClose={() => answer(false, false)} maxWidth="xs" fullWidth PaperProps={{ sx: { background: 'transparent', boxShadow: 'none' } }}>
<Box sx={{ background: c.bg.surface, borderRadius: '14px', p: 1.5 }}>
<VendoredToolUi
name="approval-card"
props={{
id: req.request_id,
title: `"${req.app_name}" wants to use ${req.tool_label}`,
description: 'This app is asking to call one of your connected tools. Only allow it if you trust the app with this action.',
metadata: req.args_preview && req.args_preview !== '{}' ? [{ key: 'input', value: req.args_preview.slice(0, 120) }] : undefined,
confirmLabel: 'Allow once',
cancelLabel: 'Deny',
}}
extraProps={{
onConfirm: () => answer(true, false),
onCancel: () => answer(false, false),
}}
/>
{/* The remembered decisions ride outside the vendored card: its contract is confirm/cancel. */}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 0.5 }}>
<Button size="small" color="inherit" onClick={() => answer(false, true)} sx={{ fontSize: '0.75rem', textTransform: 'none', color: c.text.muted }}>Never allow</Button>
<Button size="small" color="inherit" onClick={() => answer(true, true)} sx={{ fontSize: '0.75rem', textTransform: 'none', color: c.text.secondary, fontWeight: 600 }}>Always allow</Button>
</Box>
</Box>
</Dialog>
@@ -26,7 +26,9 @@ import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useAppSelector } from '@/shared/hooks';
import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { getMcpInputSummary as getSharedMcpInputSummary, getWorkflowToolInputDisplay, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
import { getMcpInputSummary as getSharedMcpInputSummary, getWorkflowToolLabel } from '@/shared/mcpToolMeta';
import ToolPermissionCard from '../tool-ui/ToolPermissionCard';
import AskQuestionCard from '../tool-ui/AskQuestionCard';
interface IntegrationMeta {
label: string;
@@ -301,287 +303,15 @@ const ToolPreview: React.FC<ToolPreviewProps> = ({ request, tokens: c }) => {
}
};
function getOptionKey(opt: any): string {
return opt.id || opt.value || opt.label || opt.text || String(opt);
}
function getOptionLabel(opt: any): string {
return opt.label || opt.value || opt.text || String(opt);
}
type Answers = Record<number, string | string[]>;
export interface QuestionFormProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean, alwaysAllow?: boolean) => void;
onDeny: (requestId: string, message?: string) => void;
compact?: boolean;
}
const OTHER_KEY = '__other__';
export const QuestionForm: React.FC<QuestionFormProps> = ({ request, onApprove, onDeny, compact }) => {
const c = useClaudeTokens();
const questions: any[] = request.tool_input.questions || [];
const [answers, setAnswers] = useState<Answers>(() => {
const init: Answers = {};
questions.forEach((q: any, i: number) => {
init[i] = q.multiSelect ? [] : '';
});
return init;
});
const [otherActive, setOtherActive] = useState<Record<number, boolean>>({});
const [otherText, setOtherText] = useState<Record<number, string>>({});
const toggleOption = useCallback((qIdx: number, key: string, multi: boolean) => {
setAnswers((prev) => {
const copy = { ...prev };
if (multi) {
const arr = Array.isArray(copy[qIdx]) ? [...(copy[qIdx] as string[])] : [];
const idx = arr.indexOf(key);
if (idx >= 0) arr.splice(idx, 1);
else arr.push(key);
copy[qIdx] = arr;
} else {
copy[qIdx] = copy[qIdx] === key ? '' : key;
}
return copy;
});
if (key !== OTHER_KEY) {
if (!multi) {
setOtherActive((prev) => ({ ...prev, [qIdx]: false }));
setOtherText((prev) => ({ ...prev, [qIdx]: '' }));
}
}
}, []);
const toggleOther = useCallback((qIdx: number, multi: boolean) => {
setOtherActive((prev) => {
const wasActive = !!prev[qIdx];
if (wasActive) {
setOtherText((p) => ({ ...p, [qIdx]: '' }));
}
if (!multi && !wasActive) {
setAnswers((p) => ({ ...p, [qIdx]: '' }));
}
return { ...prev, [qIdx]: !wasActive };
});
}, []);
const setTextAnswer = useCallback((qIdx: number, text: string) => {
setAnswers((prev) => ({ ...prev, [qIdx]: text }));
}, []);
const handleSubmit = () => {
const answersDict: Record<string, string> = {};
questions.forEach((q: any, i: number) => {
const questionText = q.question || q.prompt || q.text || '';
const hasOptions = Array.isArray(q.options) && q.options.length > 0;
let answer = answers[i];
if (hasOptions && otherActive[i] && otherText[i]) {
if (q.multiSelect) {
const arr = Array.isArray(answer) ? [...answer] : [];
arr.push(otherText[i]);
answer = arr;
} else {
answer = otherText[i];
}
}
if (Array.isArray(answer)) {
answersDict[questionText] = answer.join(', ');
} else {
answersDict[questionText] = answer || '';
}
});
onApprove(request.id, { ...request.tool_input, questions, answers: answersDict });
};
const isSelected = (qIdx: number, key: string): boolean => {
const val = answers[qIdx];
if (Array.isArray(val)) return val.includes(key);
return val === key;
};
return (
<Box
sx={{
// Tint carries the container; the accent icon + title are the identity cue.
bgcolor: c.bg.secondary,
borderRadius: compact ? 2 : 2.5,
p: compact ? 1.5 : 2,
mx: compact ? 0 : 2,
mb: compact ? 0 : 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.5 }}>
<Box sx={{ color: c.accent.primary, display: 'flex', alignItems: 'center' }}>
<QuestionAnswerIcon sx={{ fontSize: '1rem' }} />
</Box>
<Typography sx={{ color: c.accent.primary, fontWeight: 700, fontSize: '0.875rem' }}>
Agent has a question
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 2 }}>
{questions.map((q: any, i: number) => {
const hasOptions = Array.isArray(q.options) && q.options.length > 0;
const multi = !!q.multiSelect;
const isOtherActive = !!otherActive[i];
return (
<Box key={i}>
{q.header && (
<Typography sx={{ color: c.text.muted, fontSize: '0.6875rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
{q.header}
</Typography>
)}
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', fontWeight: 500, mb: 0.75 }}>
{q.question || q.prompt || q.text || '(question)'}
</Typography>
{hasOptions ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{q.options.map((opt: any) => {
const key = getOptionKey(opt);
const selected = isSelected(i, key);
return (
<Chip
key={key}
label={getOptionLabel(opt)}
size="small"
onClick={() => toggleOption(i, key, multi)}
sx={{
fontSize: '0.75rem',
fontWeight: selected ? 600 : 400,
cursor: 'pointer',
color: selected ? c.accent.primary : c.text.secondary,
bgcolor: selected ? `${c.accent.primary}18` : 'transparent',
borderColor: selected ? c.accent.primary : c.border.medium,
borderWidth: 1,
borderStyle: 'solid',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: selected ? `${c.accent.primary}24` : `${c.text.secondary}0a`,
borderColor: selected ? c.accent.primary : c.text.secondary,
},
}}
/>
);
})}
<Chip
label="Other…"
size="small"
onClick={() => toggleOther(i, multi)}
sx={{
fontSize: '0.75rem',
fontWeight: isOtherActive ? 600 : 400,
fontStyle: 'italic',
cursor: 'pointer',
color: isOtherActive ? c.accent.primary : c.text.muted,
bgcolor: isOtherActive ? `${c.accent.primary}18` : 'transparent',
borderColor: isOtherActive ? c.accent.primary : c.border.subtle,
borderWidth: 1,
borderStyle: 'dashed',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isOtherActive ? `${c.accent.primary}24` : `${c.text.secondary}0a`,
borderColor: isOtherActive ? c.accent.primary : c.border.medium,
},
}}
/>
</Box>
{isOtherActive && (
<TextField
placeholder="Type your own answer..."
value={otherText[i] || ''}
onChange={(e) => setOtherText((prev) => ({ ...prev, [i]: e.target.value }))}
fullWidth
size="small"
autoFocus
sx={{
mt: 0.25,
'& .MuiOutlinedInput-root': {
color: c.text.primary,
fontSize: '0.8125rem',
'& fieldset': { borderColor: c.border.medium },
'&:hover fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
)}
</Box>
) : (
<TextField
placeholder="Type your answer..."
value={answers[i] || ''}
onChange={(e) => setTextAnswer(i, e.target.value)}
fullWidth
size="small"
multiline
maxRows={4}
sx={{
'& .MuiOutlinedInput-root': {
color: c.text.primary,
fontSize: '0.8125rem',
'& fieldset': { borderColor: c.border.medium },
'&:hover fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
)}
</Box>
);
})}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
startIcon={<SendIcon />}
onClick={handleSubmit}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary, filter: 'brightness(0.9)' },
fontWeight: 600,
fontSize: '0.8125rem',
}}
>
Submit
</Button>
<Button
variant="outlined"
onClick={() => onDeny(request.id)}
sx={{
borderColor: c.border.strong,
color: c.text.secondary,
'&:hover': { borderColor: c.text.secondary, bgcolor: `${c.text.secondary}08` },
fontWeight: 600,
fontSize: '0.8125rem',
}}
>
Dismiss
</Button>
</Box>
</Box>
);
};
const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) => {
const c = useClaudeTokens();
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [trustPattern, setTrustPattern] = useState(false);
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
const meta = useMcpToolMeta(parsed);
const accentColor = meta.integration?.color || c.status.warning;
const displayName = getWorkflowToolLabel(parsed.actionName) || parsed.displayName;
const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : '';
const workflowDetails = parsed.isMcp
? getWorkflowToolInputDisplay(request.tool_input, parsed.actionName, parsed.serverSlug)
: '';
const detailText = workflowDetails || JSON.stringify(request.tool_input, null, 2);
const isSensitive = !!request.sensitive_pattern;
if (!parsed.isMcp) {
@@ -705,170 +435,24 @@ const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) =>
);
}
// MCP tool asks render through the vendored tool-ui approval-card (ToolPermissionCard), the
// same design language as every other rich surface in the transcript.
const permDescription = [meta.serverLabel ? `via ${meta.serverLabel}` : '', meta.description, summary]
.filter(Boolean).join(' · ');
return (
<Box
sx={{
bgcolor: c.bg.surface,
// The brand-colored icon square carries the integration cue; no accent rail.
border: `1px solid ${c.border.subtle}`,
borderRadius: 2.5,
p: 0,
mx: 2,
mb: 1,
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, pt: 1.75, pb: 0.5 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
bgcolor: `${accentColor}14`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{meta.integration?.icon || <ExtensionIcon sx={{ fontSize: 18, color: accentColor }} />}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.875rem' }}>
{displayName}
</Typography>
<Chip
label={meta.serverLabel || parsed.serverSlug}
size="small"
sx={{
height: 18,
fontSize: '0.625rem',
fontWeight: 500,
bgcolor: `${accentColor}12`,
color: accentColor,
border: 'none',
'& .MuiChip-label': { px: 0.6 },
}}
/>
</Box>
{meta.description && (
<Typography
sx={{
color: c.text.tertiary,
fontSize: '0.75rem',
lineHeight: 1.3,
mt: 0.15,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{meta.description}
</Typography>
)}
</Box>
</Box>
<Box sx={{ px: 2, pt: 1, pb: 0.5 }}>
{summary && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
'&:hover .expand-icon': { color: c.text.secondary },
}}
onClick={() => setDetailsExpanded((v) => !v)}
>
<Typography
sx={{
color: c.text.secondary,
fontSize: '0.8125rem',
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{summary}
</Typography>
<IconButton className="expand-icon" size="small" sx={{ color: c.text.ghost, p: 0.25, flexShrink: 0 }}>
{detailsExpanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Box>
)}
<Collapse in={detailsExpanded || !summary}>
<Box sx={{ mt: summary ? 0.75 : 0 }}>
<CodeBlock tokens={c}>
{detailText}
</CodeBlock>
</Box>
</Collapse>
</Box>
<Box sx={{ display: 'flex', gap: 1, px: 2, pt: 1, pb: 1.75, flexWrap: 'wrap' }}>
<Button
variant="contained"
startIcon={<CheckIcon />}
onClick={() => onApprove(request.id)}
sx={{
bgcolor: c.status.success,
'&:hover': { bgcolor: '#1e4d15' },
fontWeight: 600,
fontSize: '0.8125rem',
textTransform: 'none',
borderRadius: 1.5,
px: 2,
}}
>
Approve
</Button>
<Button
variant="outlined"
startIcon={<DoneAllIcon />}
onClick={() => onApprove(request.id, undefined, false, true)}
sx={{
borderColor: c.status.success,
color: c.status.success,
'&:hover': { borderColor: '#1e4d15', bgcolor: 'rgba(45,122,31,0.06)' },
fontWeight: 600,
fontSize: '0.8125rem',
textTransform: 'none',
borderRadius: 1.5,
px: 2,
}}
>
Always approve
</Button>
<Button
variant="outlined"
startIcon={<CloseIcon />}
onClick={() => onDeny(request.id)}
sx={{
borderColor: c.status.error,
color: c.status.error,
'&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' },
fontWeight: 600,
fontSize: '0.8125rem',
textTransform: 'none',
borderRadius: 1.5,
px: 2,
}}
>
Deny
</Button>
</Box>
</Box>
<ToolPermissionCard
request={request}
title={displayName}
description={permDescription}
onApprove={onApprove}
onDeny={onDeny}
/>
);
};
const ApprovalBar: React.FC<Props> = (props) => {
if (props.request.tool_name === 'AskUserQuestion') {
return <QuestionForm request={props.request} onApprove={props.onApprove} onDeny={props.onDeny} />;
return <AskQuestionCard request={props.request} onApprove={props.onApprove} onDeny={props.onDeny} />;
}
return <GenericApprovalBar {...props} />;
};
@@ -933,7 +517,7 @@ export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, on
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{questions.map((req) => (
<QuestionForm key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
<AskQuestionCard key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
))}
{nonQuestions.length > 1 && (
@@ -0,0 +1,140 @@
import React, { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import VendoredToolUi from '@/toolui/VendoredToolUi';
import QuestionForm, { type QuestionFormProps } from './QuestionForm';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const OTHER_ID = '__other__';
interface AskQuestion {
question: string;
header?: string;
options?: Array<Record<string, unknown> | string>;
multiSelect?: boolean;
}
function optionId(opt: Record<string, unknown> | string, i: number): string {
if (typeof opt === 'string') return opt || `option-${i + 1}`;
return String(opt.id ?? opt.value ?? opt.label ?? opt.text ?? `option-${i + 1}`);
}
function optionLabel(opt: Record<string, unknown> | string): string {
if (typeof opt === 'string') return opt;
return String(opt.label ?? opt.value ?? opt.text ?? '');
}
function optionDescription(opt: Record<string, unknown> | string): string | undefined {
if (typeof opt === 'string') return undefined;
const d = opt.description;
return typeof d === 'string' && d ? d : undefined;
}
/** AskUserQuestion rendered through the vendored tool-ui question-flow (the modern stepped card),
* with a host-side follow-up input when the user picks "Other". Questions the flow's contract
* can't hold (free-text, no options) fall back to the classic form, so nothing loses function. */
const AskQuestionCard: React.FC<QuestionFormProps> = (props) => {
const { request, onApprove, onDeny, compact } = props;
const c = useClaudeTokens();
const questions: AskQuestion[] = useMemo(
() => (Array.isArray(request.tool_input.questions) ? request.tool_input.questions : []),
[request.tool_input.questions],
);
const flowFits = questions.length > 0 && questions.every((q) => Array.isArray(q.options) && q.options.length > 0);
const [flowAnswers, setFlowAnswers] = useState<Record<string, string[]> | null>(null);
const [otherText, setOtherText] = useState<Record<string, string>>({});
const steps = useMemo(() => questions.map((q, i) => ({
id: `q-${i}`,
title: q.question || '(question)',
description: q.header || undefined,
options: [
...(q.options || []).map((opt, j) => ({
id: optionId(opt, j),
label: optionLabel(opt) || `Option ${j + 1}`,
description: optionDescription(opt),
})),
{ id: OTHER_ID, label: 'Other…', description: 'Answer in your own words' },
],
selectionMode: (q.multiSelect ? 'multi' : 'single') as 'multi' | 'single',
})), [questions]);
if (!flowFits) return <QuestionForm {...props} />;
const submit = (answers: Record<string, string[]>): void => {
const answersDict: Record<string, string> = {};
questions.forEach((q, i) => {
const picked = answers[`q-${i}`] || [];
const resolved = picked.map((id) => (id === OTHER_ID ? (otherText[`q-${i}`] || '').trim() : id)).filter(Boolean);
answersDict[q.question || ''] = resolved.join(', ');
});
onApprove(request.id, { ...request.tool_input, questions, answers: answersDict });
};
const pendingOther = flowAnswers
? Object.entries(flowAnswers).filter(([, ids]) => ids.includes(OTHER_ID)).map(([stepId]) => stepId)
: [];
return (
<Box sx={{ mx: compact ? 0 : 2, mb: compact ? 0 : 1 }}>
{!flowAnswers && (
<VendoredToolUi
name="question-flow"
props={{ id: request.id, steps }}
extraProps={{
onComplete: (answers: Record<string, string[]>) => {
const needsOther = Object.values(answers).some((ids) => ids.includes(OTHER_ID));
if (needsOther) setFlowAnswers(answers);
else submit(answers);
},
}}
/>
)}
{flowAnswers && (
<Box sx={{ bgcolor: c.bg.secondary, borderRadius: 2.5, p: 2, display: 'flex', flexDirection: 'column', gap: 1.25 }}>
{pendingOther.map((stepId) => {
const idx = Number(stepId.slice(2));
return (
<Box key={stepId}>
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', fontWeight: 500, mb: 0.75 }}>
{questions[idx]?.question}
</Typography>
<TextField
placeholder="Your answer..."
value={otherText[stepId] || ''}
onChange={(e) => setOtherText((prev) => ({ ...prev, [stepId]: e.target.value }))}
fullWidth
size="small"
autoFocus
multiline
maxRows={4}
/>
</Box>
);
})}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" disableElevation size="small" onClick={() => submit(flowAnswers)}
disabled={pendingOther.some((s) => !(otherText[s] || '').trim())}
sx={{ fontWeight: 600, fontSize: '0.8125rem', textTransform: 'none' }}>
Submit
</Button>
<Button variant="text" size="small" color="inherit" onClick={() => setFlowAnswers(null)}
sx={{ fontSize: '0.8125rem', textTransform: 'none', color: c.text.secondary }}>
Back
</Button>
</Box>
</Box>
)}
{!flowAnswers && (
<Button variant="text" size="small" color="inherit" onClick={() => onDeny(request.id)}
sx={{ mt: 0.5, fontSize: '0.75rem', textTransform: 'none', color: c.text.muted }}>
Dismiss
</Button>
)}
</Box>
);
};
export default AskQuestionCard;
@@ -0,0 +1,278 @@
import React, { useCallback, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Chip from '@mui/material/Chip';
import SendIcon from '@mui/icons-material/Send';
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
function getOptionKey(opt: any): string {
return opt.id || opt.value || opt.label || opt.text || String(opt);
}
function getOptionLabel(opt: any): string {
return opt.label || opt.value || opt.text || String(opt);
}
type Answers = Record<number, string | string[]>;
export interface QuestionFormProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean, alwaysAllow?: boolean) => void;
onDeny: (requestId: string, message?: string) => void;
compact?: boolean;
}
const OTHER_KEY = '__other__';
const QuestionForm: React.FC<QuestionFormProps> = ({ request, onApprove, onDeny, compact }) => {
const c = useClaudeTokens();
const questions: any[] = request.tool_input.questions || [];
const [answers, setAnswers] = useState<Answers>(() => {
const init: Answers = {};
questions.forEach((q: any, i: number) => {
init[i] = q.multiSelect ? [] : '';
});
return init;
});
const [otherActive, setOtherActive] = useState<Record<number, boolean>>({});
const [otherText, setOtherText] = useState<Record<number, string>>({});
const toggleOption = useCallback((qIdx: number, key: string, multi: boolean) => {
setAnswers((prev) => {
const copy = { ...prev };
if (multi) {
const arr = Array.isArray(copy[qIdx]) ? [...(copy[qIdx] as string[])] : [];
const idx = arr.indexOf(key);
if (idx >= 0) arr.splice(idx, 1);
else arr.push(key);
copy[qIdx] = arr;
} else {
copy[qIdx] = copy[qIdx] === key ? '' : key;
}
return copy;
});
if (key !== OTHER_KEY) {
if (!multi) {
setOtherActive((prev) => ({ ...prev, [qIdx]: false }));
setOtherText((prev) => ({ ...prev, [qIdx]: '' }));
}
}
}, []);
const toggleOther = useCallback((qIdx: number, multi: boolean) => {
setOtherActive((prev) => {
const wasActive = !!prev[qIdx];
if (wasActive) {
setOtherText((p) => ({ ...p, [qIdx]: '' }));
}
if (!multi && !wasActive) {
setAnswers((p) => ({ ...p, [qIdx]: '' }));
}
return { ...prev, [qIdx]: !wasActive };
});
}, []);
const setTextAnswer = useCallback((qIdx: number, text: string) => {
setAnswers((prev) => ({ ...prev, [qIdx]: text }));
}, []);
const handleSubmit = () => {
const answersDict: Record<string, string> = {};
questions.forEach((q: any, i: number) => {
const questionText = q.question || q.prompt || q.text || '';
const hasOptions = Array.isArray(q.options) && q.options.length > 0;
let answer = answers[i];
if (hasOptions && otherActive[i] && otherText[i]) {
if (q.multiSelect) {
const arr = Array.isArray(answer) ? [...answer] : [];
arr.push(otherText[i]);
answer = arr;
} else {
answer = otherText[i];
}
}
if (Array.isArray(answer)) {
answersDict[questionText] = answer.join(', ');
} else {
answersDict[questionText] = answer || '';
}
});
onApprove(request.id, { ...request.tool_input, questions, answers: answersDict });
};
const isSelected = (qIdx: number, key: string): boolean => {
const val = answers[qIdx];
if (Array.isArray(val)) return val.includes(key);
return val === key;
};
return (
<Box
sx={{
// Tint carries the container; the accent icon + title are the identity cue.
bgcolor: c.bg.secondary,
borderRadius: compact ? 2 : 2.5,
p: compact ? 1.5 : 2,
mx: compact ? 0 : 2,
mb: compact ? 0 : 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.5 }}>
<Box sx={{ color: c.accent.primary, display: 'flex', alignItems: 'center' }}>
<QuestionAnswerIcon sx={{ fontSize: '1rem' }} />
</Box>
<Typography sx={{ color: c.accent.primary, fontWeight: 700, fontSize: '0.875rem' }}>
Agent has a question
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 2 }}>
{questions.map((q: any, i: number) => {
const hasOptions = Array.isArray(q.options) && q.options.length > 0;
const multi = !!q.multiSelect;
const isOtherActive = !!otherActive[i];
return (
<Box key={i}>
{q.header && (
<Typography sx={{ color: c.text.muted, fontSize: '0.6875rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
{q.header}
</Typography>
)}
<Typography sx={{ color: c.text.primary, fontSize: '0.875rem', fontWeight: 500, mb: 0.75 }}>
{q.question || q.prompt || q.text || '(question)'}
</Typography>
{hasOptions ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{q.options.map((opt: any) => {
const key = getOptionKey(opt);
const selected = isSelected(i, key);
return (
<Chip
key={key}
label={getOptionLabel(opt)}
size="small"
onClick={() => toggleOption(i, key, multi)}
sx={{
fontSize: '0.75rem',
fontWeight: selected ? 600 : 400,
cursor: 'pointer',
color: selected ? c.accent.primary : c.text.secondary,
bgcolor: selected ? `${c.accent.primary}18` : 'transparent',
borderColor: selected ? c.accent.primary : c.border.medium,
borderWidth: 1,
borderStyle: 'solid',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: selected ? `${c.accent.primary}24` : `${c.text.secondary}0a`,
borderColor: selected ? c.accent.primary : c.text.secondary,
},
}}
/>
);
})}
<Chip
label="Other…"
size="small"
onClick={() => toggleOther(i, multi)}
sx={{
fontSize: '0.75rem',
fontWeight: isOtherActive ? 600 : 400,
fontStyle: 'italic',
cursor: 'pointer',
color: isOtherActive ? c.accent.primary : c.text.muted,
bgcolor: isOtherActive ? `${c.accent.primary}18` : 'transparent',
borderColor: isOtherActive ? c.accent.primary : c.border.subtle,
borderWidth: 1,
borderStyle: 'dashed',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isOtherActive ? `${c.accent.primary}24` : `${c.text.secondary}0a`,
borderColor: isOtherActive ? c.accent.primary : c.border.medium,
},
}}
/>
</Box>
{isOtherActive && (
<TextField
placeholder="Type your own answer..."
value={otherText[i] || ''}
onChange={(e) => setOtherText((prev) => ({ ...prev, [i]: e.target.value }))}
fullWidth
size="small"
autoFocus
sx={{
mt: 0.25,
'& .MuiOutlinedInput-root': {
color: c.text.primary,
fontSize: '0.8125rem',
'& fieldset': { borderColor: c.border.medium },
'&:hover fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
)}
</Box>
) : (
<TextField
placeholder="Type your answer..."
value={answers[i] || ''}
onChange={(e) => setTextAnswer(i, e.target.value)}
fullWidth
size="small"
multiline
maxRows={4}
sx={{
'& .MuiOutlinedInput-root': {
color: c.text.primary,
fontSize: '0.8125rem',
'& fieldset': { borderColor: c.border.medium },
'&:hover fieldset': { borderColor: c.border.strong },
'&.Mui-focused fieldset': { borderColor: c.accent.primary },
},
}}
/>
)}
</Box>
);
})}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
startIcon={<SendIcon />}
onClick={handleSubmit}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.hover || c.accent.primary, filter: 'brightness(0.9)' },
fontWeight: 600,
fontSize: '0.8125rem',
}}
>
Submit
</Button>
<Button
variant="outlined"
onClick={() => onDeny(request.id)}
sx={{
borderColor: c.border.strong,
color: c.text.secondary,
'&:hover': { borderColor: c.text.secondary, bgcolor: `${c.text.secondary}08` },
fontWeight: 600,
fontSize: '0.8125rem',
}}
>
Dismiss
</Button>
</Box>
</Box>
);
};
export default QuestionForm;
@@ -0,0 +1,66 @@
import React, { useMemo } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import VendoredToolUi from '@/toolui/VendoredToolUi';
import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface Props {
request: ApprovalRequest;
title: string;
description?: string;
onApprove: (requestId: string, updatedInput?: Record<string, any>, trustPattern?: boolean, alwaysAllow?: boolean) => void;
onDeny: (requestId: string, message?: string) => void;
}
const METADATA_ROW_CAP = 5;
function metadataRows(toolInput: Record<string, unknown>): Array<{ key: string; value: string }> {
const rows: Array<{ key: string; value: string }> = [];
for (const [key, val] of Object.entries(toolInput)) {
if (key.startsWith('_') || val == null) continue;
const text = typeof val === 'string' ? val : JSON.stringify(val);
if (!text || text === '{}' || text === '[]') continue;
rows.push({ key, value: text.length > 120 ? `${text.slice(0, 117)}...` : text });
if (rows.length >= METADATA_ROW_CAP) break;
}
return rows;
}
/** Tool permission asks rendered through the vendored tool-ui approval-card (the modern surface),
* with the persistent "Always allow" escalation as a slim host-side row: the vendored contract is
* confirm/cancel, and widening it would fork the upstream component. */
const ToolPermissionCard: React.FC<Props> = ({ request, title, description, onApprove, onDeny }) => {
const c = useClaudeTokens();
const metadata = useMemo(() => metadataRows(request.tool_input), [request.tool_input]);
return (
<Box sx={{ mx: 2, mb: 1 }}>
<VendoredToolUi
name="approval-card"
props={{
id: request.id,
title,
description: description || undefined,
metadata: metadata.length ? metadata : undefined,
confirmLabel: 'Approve',
cancelLabel: 'Deny',
}}
extraProps={{
onConfirm: () => onApprove(request.id),
onCancel: () => onDeny(request.id),
}}
/>
<Button
variant="text"
size="small"
color="inherit"
onClick={() => onApprove(request.id, undefined, false, true)}
sx={{ mt: 0.25, fontSize: '0.75rem', textTransform: 'none', color: c.text.muted, '&:hover': { color: c.text.secondary } }}
>
Always allow {title}
</Button>
</Box>
);
};
export default ToolPermissionCard;
@@ -48,7 +48,7 @@ import { extractLatestShowUi, extractPendingAskUi, freezeIfDone, artifactName }
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { useBrowserPillShot } from '../desktop/useBrowserPillShot';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar';
import AskQuestionCard from '@/app/pages/AgentChat/tool-ui/AskQuestionCard';
import AgentChat from '@/app/pages/AgentChat/AgentChat';
import { parseMcpToolName, getMcpShortAction } from '@/shared/mcpToolMeta';
import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext';
@@ -1329,7 +1329,7 @@ const AgentCard: React.FC<Props> = ({
{hasPending && pendingReq && pendingReq.tool_name === 'AskUserQuestion' ? (
<Box onClick={(e) => e.stopPropagation()}>
<QuestionForm
<AskQuestionCard
compact
request={pendingReq}
onApprove={(requestId, updatedInput) =>
@@ -2,6 +2,7 @@ import { addViewCard, bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { deleteOutput, type Output } from '@/shared/state/outputsSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { setClipboardCards } from '@/shared/dashboardClipboard';
import { API_BASE } from '@/shared/config';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
@@ -47,6 +48,8 @@ export function viewCardMenuRows({
}]),
},
{ label: 'Share or publish...', onClick: onShare },
// Undoes remembered Always/Never tool decisions; the next call from this app asks again.
{ label: 'Reset tool permissions', onClick: () => { void fetch(`${API_BASE}/apps-sdk/tools/grants/${output.id}`, { method: 'DELETE' }); } },
{ kind: 'separator' },
{ label: 'Close', onClick: onClose },
{
+2 -1
View File
@@ -107,7 +107,8 @@ export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
},
'question-flow': {
Component: lazy(() => import('./components/question-flow').then((m) => ({ default: m.QuestionFlow }))),
loadSchema: () => import('./components/question-flow/schema').then((m) => m.SerializableProgressiveModeSchema),
// The full union: progressive (step/title), upfront (steps[]), and receipt modes are all valid wire shapes.
loadSchema: () => import('./components/question-flow/schema').then((m) => m.SerializableQuestionFlowSchema),
},
'stats-display': {
Component: lazy(() => import('./components/stats-display').then((m) => ({ default: m.StatsDisplay }))),