[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 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") @apps_sdk.router.post("/tools/grant")
@typechecked @typechecked
async def tools_grant(body: GrantResolveRequest) -> Dict[str, bool]: 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) 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 @typechecked
async def request_grant(output_id: str, app_name: str, tool_key: str, tool_label: str, args_preview: str) -> bool: 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 """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': { 'question-flow': {
Component: lazy(() => import('./components/question-flow').then((m) => ({ default: m.QuestionFlow }))), 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': { 'stats-display': {
Component: lazy(() => import('./components/stats-display').then((m) => ({ default: m.StatsDisplay }))), Component: lazy(() => import('./components/stats-display').then((m) => ({ default: m.StatsDisplay }))),
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { API_BASE } from '@/shared/config'; import { API_BASE } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import VendoredToolUi from '@/toolui/VendoredToolUi';
export const APP_TOOL_GRANT_EVENT = 'openswarm:app-tool-grant'; export const APP_TOOL_GRANT_EVENT = 'openswarm:app-tool-grant';
@@ -41,25 +41,27 @@ const AppToolGrantHost: React.FC = () => {
setReq(null); setReq(null);
}; };
return ( return (
<Dialog open onClose={() => answer(false, false)} maxWidth="xs" fullWidth> <Dialog open onClose={() => answer(false, false)} maxWidth="xs" fullWidth PaperProps={{ sx: { background: 'transparent', boxShadow: 'none' } }}>
<Box sx={{ p: 2.5, background: c.bg.surface }}> <Box sx={{ background: c.bg.surface, borderRadius: '14px', p: 1.5 }}>
<Typography sx={{ fontWeight: 700, fontSize: 15, mb: 0.5, color: c.text.primary }}> <VendoredToolUi
"{req.app_name}" wants to use {req.tool_label} name="approval-card"
</Typography> props={{
<Typography sx={{ fontSize: 12.5, color: c.text.secondary, mb: 1.5 }}> id: req.request_id,
This app is asking to call one of your connected tools. Only allow it if you trust the app title: `"${req.app_name}" wants to use ${req.tool_label}`,
with this action. description: 'This app is asking to call one of your connected tools. Only allow it if you trust the app with this action.',
</Typography> metadata: req.args_preview && req.args_preview !== '{}' ? [{ key: 'input', value: req.args_preview.slice(0, 120) }] : undefined,
{req.args_preview && req.args_preview !== '{}' && ( confirmLabel: 'Allow once',
<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' }}> cancelLabel: 'Deny',
{req.args_preview} }}
</Box> extraProps={{
)} onConfirm: () => answer(true, false),
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}> onCancel: () => answer(false, false),
<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> {/* The remembered decisions ride outside the vendored card: its contract is confirm/cancel. */}
<Button size="small" variant="contained" disableElevation onClick={() => answer(true, true)}>Always allow</Button> <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>
</Box> </Box>
</Dialog> </Dialog>
@@ -26,7 +26,9 @@ import { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useAppSelector } from '@/shared/hooks'; import { useAppSelector } from '@/shared/hooks';
import { ToolDefinition } from '@/shared/state/toolsSlice'; import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext'; 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 { interface IntegrationMeta {
label: string; 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 GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) => {
const c = useClaudeTokens(); const c = useClaudeTokens();
const [detailsExpanded, setDetailsExpanded] = useState(false);
const [trustPattern, setTrustPattern] = useState(false); const [trustPattern, setTrustPattern] = useState(false);
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]); const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
const meta = useMcpToolMeta(parsed); const meta = useMcpToolMeta(parsed);
const accentColor = meta.integration?.color || c.status.warning;
const displayName = getWorkflowToolLabel(parsed.actionName) || parsed.displayName; const displayName = getWorkflowToolLabel(parsed.actionName) || parsed.displayName;
const summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : ''; 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; const isSensitive = !!request.sensitive_pattern;
if (!parsed.isMcp) { 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 ( return (
<Box <ToolPermissionCard
sx={{ request={request}
bgcolor: c.bg.surface, title={displayName}
// The brand-colored icon square carries the integration cue; no accent rail. description={permDescription}
border: `1px solid ${c.border.subtle}`, onApprove={onApprove}
borderRadius: 2.5, onDeny={onDeny}
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>
); );
}; };
const ApprovalBar: React.FC<Props> = (props) => { const ApprovalBar: React.FC<Props> = (props) => {
if (props.request.tool_name === 'AskUserQuestion') { 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} />; return <GenericApprovalBar {...props} />;
}; };
@@ -933,7 +517,7 @@ export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, on
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{questions.map((req) => ( {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 && ( {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 { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { useBrowserPillShot } from '../desktop/useBrowserPillShot'; import { useBrowserPillShot } from '../desktop/useBrowserPillShot';
import { useAppDispatch, useAppSelector } from '@/shared/hooks'; 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 AgentChat from '@/app/pages/AgentChat/AgentChat';
import { parseMcpToolName, getMcpShortAction } from '@/shared/mcpToolMeta'; import { parseMcpToolName, getMcpShortAction } from '@/shared/mcpToolMeta';
import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext'; import { useClaudeTokens, DarkTokensScope } from '@/shared/styles/ThemeContext';
@@ -1329,7 +1329,7 @@ const AgentCard: React.FC<Props> = ({
{hasPending && pendingReq && pendingReq.tool_name === 'AskUserQuestion' ? ( {hasPending && pendingReq && pendingReq.tool_name === 'AskUserQuestion' ? (
<Box onClick={(e) => e.stopPropagation()}> <Box onClick={(e) => e.stopPropagation()}>
<QuestionForm <AskQuestionCard
compact compact
request={pendingReq} request={pendingReq}
onApprove={(requestId, updatedInput) => onApprove={(requestId, updatedInput) =>
@@ -2,6 +2,7 @@ import { addViewCard, bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { deleteOutput, type Output } from '@/shared/state/outputsSlice'; import { deleteOutput, type Output } from '@/shared/state/outputsSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown'; import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { setClipboardCards } from '@/shared/dashboardClipboard'; import { setClipboardCards } from '@/shared/dashboardClipboard';
import { API_BASE } from '@/shared/config';
import type { AppDispatch } from '@/shared/state/store'; import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu'; import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord'; import { chord } from '../desktop/chord';
@@ -47,6 +48,8 @@ export function viewCardMenuRows({
}]), }]),
}, },
{ label: 'Share or publish...', onClick: onShare }, { 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' }, { kind: 'separator' },
{ label: 'Close', onClick: onClose }, { label: 'Close', onClick: onClose },
{ {
+2 -1
View File
@@ -107,7 +107,8 @@ export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
}, },
'question-flow': { 'question-flow': {
Component: lazy(() => import('./components/question-flow').then((m) => ({ default: m.QuestionFlow }))), 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': { 'stats-display': {
Component: lazy(() => import('./components/stats-display').then((m) => ({ default: m.StatsDisplay }))), Component: lazy(() => import('./components/stats-display').then((m) => ({ default: m.StatsDisplay }))),