diff --git a/backend/apps/apps_sdk/apps_sdk.py b/backend/apps/apps_sdk/apps_sdk.py index 844fcac4..8fead90b 100644 --- a/backend/apps/apps_sdk/apps_sdk.py +++ b/backend/apps/apps_sdk/apps_sdk.py @@ -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]: diff --git a/backend/apps/apps_sdk/tool_grants.py b/backend/apps/apps_sdk/tool_grants.py index a02113a8..a60662a0 100644 --- a/backend/apps/apps_sdk/tool_grants.py +++ b/backend/apps/apps_sdk/tool_grants.py @@ -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 diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/registry.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/registry.tsx index 42092504..2d4eecc6 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/toolui/registry.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/registry.tsx @@ -107,7 +107,8 @@ export const TOOL_UI_REGISTRY: Record = { }, '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 }))), diff --git a/frontend/src/app/components/apps/AppToolGrantHost.tsx b/frontend/src/app/components/apps/AppToolGrantHost.tsx index 72426e3c..fa4d781c 100644 --- a/frontend/src/app/components/apps/AppToolGrantHost.tsx +++ b/frontend/src/app/components/apps/AppToolGrantHost.tsx @@ -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 ( - answer(false, false)} maxWidth="xs" fullWidth> - - - "{req.app_name}" wants to use {req.tool_label} - - - This app is asking to call one of your connected tools. Only allow it if you trust the app - with this action. - - {req.args_preview && req.args_preview !== '{}' && ( - - {req.args_preview} - - )} - - - - - + answer(false, false)} maxWidth="xs" fullWidth PaperProps={{ sx: { background: 'transparent', boxShadow: 'none' } }}> + + answer(true, false), + onCancel: () => answer(false, false), + }} + /> + {/* The remembered decisions ride outside the vendored card: its contract is confirm/cancel. */} + + + diff --git a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx index 02f51c33..c7bd649a 100644 --- a/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx +++ b/frontend/src/app/pages/AgentChat/shell/ApprovalBar.tsx @@ -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 = ({ 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; - -export interface QuestionFormProps { - request: ApprovalRequest; - onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => void; - onDeny: (requestId: string, message?: string) => void; - compact?: boolean; -} - -const OTHER_KEY = '__other__'; - -export const QuestionForm: React.FC = ({ request, onApprove, onDeny, compact }) => { - const c = useClaudeTokens(); - const questions: any[] = request.tool_input.questions || []; - const [answers, setAnswers] = useState(() => { - const init: Answers = {}; - questions.forEach((q: any, i: number) => { - init[i] = q.multiSelect ? [] : ''; - }); - return init; - }); - const [otherActive, setOtherActive] = useState>({}); - const [otherText, setOtherText] = useState>({}); - - 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 = {}; - 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 ( - - - - - - - Agent has a question - - - - - {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 ( - - {q.header && ( - - {q.header} - - )} - - {q.question || q.prompt || q.text || '(question)'} - - {hasOptions ? ( - - - {q.options.map((opt: any) => { - const key = getOptionKey(opt); - const selected = isSelected(i, key); - return ( - 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, - }, - }} - /> - ); - })} - 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, - }, - }} - /> - - {isOtherActive && ( - 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 }, - }, - }} - /> - )} - - ) : ( - 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 }, - }, - }} - /> - )} - - ); - })} - - - - - - - - ); -}; - const GenericApprovalBar: React.FC = ({ 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 = ({ 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 ( - - - - {meta.integration?.icon || } - - - - - - {displayName} - - - - {meta.description && ( - - {meta.description} - - )} - - - - - {summary && ( - setDetailsExpanded((v) => !v)} - > - - {summary} - - - {detailsExpanded ? : } - - - )} - - - - {detailText} - - - - - - - - - - - + ); }; const ApprovalBar: React.FC = (props) => { if (props.request.tool_name === 'AskUserQuestion') { - return ; + return ; } return ; }; @@ -933,7 +517,7 @@ export const BatchApprovalBar: React.FC = ({ requests, on return ( {questions.map((req) => ( - + ))} {nonQuestions.length > 1 && ( diff --git a/frontend/src/app/pages/AgentChat/tool-ui/AskQuestionCard.tsx b/frontend/src/app/pages/AgentChat/tool-ui/AskQuestionCard.tsx new file mode 100644 index 00000000..3fde2a34 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/AskQuestionCard.tsx @@ -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 | string>; + multiSelect?: boolean; +} + +function optionId(opt: Record | 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): string { + if (typeof opt === 'string') return opt; + return String(opt.label ?? opt.value ?? opt.text ?? ''); +} + +function optionDescription(opt: Record | 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 = (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 | null>(null); + const [otherText, setOtherText] = useState>({}); + + 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 ; + + const submit = (answers: Record): void => { + const answersDict: Record = {}; + 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 ( + + {!flowAnswers && ( + ) => { + const needsOther = Object.values(answers).some((ids) => ids.includes(OTHER_ID)); + if (needsOther) setFlowAnswers(answers); + else submit(answers); + }, + }} + /> + )} + {flowAnswers && ( + + {pendingOther.map((stepId) => { + const idx = Number(stepId.slice(2)); + return ( + + + {questions[idx]?.question} + + setOtherText((prev) => ({ ...prev, [stepId]: e.target.value }))} + fullWidth + size="small" + autoFocus + multiline + maxRows={4} + /> + + ); + })} + + + + + + )} + {!flowAnswers && ( + + )} + + ); +}; + +export default AskQuestionCard; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/QuestionForm.tsx b/frontend/src/app/pages/AgentChat/tool-ui/QuestionForm.tsx new file mode 100644 index 00000000..c2f8ecaf --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/QuestionForm.tsx @@ -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; + +export interface QuestionFormProps { + request: ApprovalRequest; + onApprove: (requestId: string, updatedInput?: Record, trustPattern?: boolean, alwaysAllow?: boolean) => void; + onDeny: (requestId: string, message?: string) => void; + compact?: boolean; +} + +const OTHER_KEY = '__other__'; + +const QuestionForm: React.FC = ({ request, onApprove, onDeny, compact }) => { + const c = useClaudeTokens(); + const questions: any[] = request.tool_input.questions || []; + const [answers, setAnswers] = useState(() => { + const init: Answers = {}; + questions.forEach((q: any, i: number) => { + init[i] = q.multiSelect ? [] : ''; + }); + return init; + }); + const [otherActive, setOtherActive] = useState>({}); + const [otherText, setOtherText] = useState>({}); + + 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 = {}; + 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 ( + + + + + + + Agent has a question + + + + + {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 ( + + {q.header && ( + + {q.header} + + )} + + {q.question || q.prompt || q.text || '(question)'} + + {hasOptions ? ( + + + {q.options.map((opt: any) => { + const key = getOptionKey(opt); + const selected = isSelected(i, key); + return ( + 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, + }, + }} + /> + ); + })} + 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, + }, + }} + /> + + {isOtherActive && ( + 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 }, + }, + }} + /> + )} + + ) : ( + 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 }, + }, + }} + /> + )} + + ); + })} + + + + + + + + ); +}; + +export default QuestionForm; diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ToolPermissionCard.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ToolPermissionCard.tsx new file mode 100644 index 00000000..0aab1983 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/tool-ui/ToolPermissionCard.tsx @@ -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, trustPattern?: boolean, alwaysAllow?: boolean) => void; + onDeny: (requestId: string, message?: string) => void; +} + +const METADATA_ROW_CAP = 5; + +function metadataRows(toolInput: Record): 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 = ({ request, title, description, onApprove, onDeny }) => { + const c = useClaudeTokens(); + const metadata = useMemo(() => metadataRows(request.tool_input), [request.tool_input]); + return ( + + onApprove(request.id), + onCancel: () => onDeny(request.id), + }} + /> + + + ); +}; + +export default ToolPermissionCard; diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index d5fe2fbb..1be5fb88 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -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 = ({ {hasPending && pendingReq && pendingReq.tool_name === 'AskUserQuestion' ? ( e.stopPropagation()}> - diff --git a/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts index 9cc7f8d9..3b0d8aa6 100644 --- a/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts +++ b/frontend/src/app/pages/Dashboard/cards/viewCardMenuRows.ts @@ -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 }, { diff --git a/frontend/src/toolui/registry.tsx b/frontend/src/toolui/registry.tsx index 42092504..2d4eecc6 100644 --- a/frontend/src/toolui/registry.tsx +++ b/frontend/src/toolui/registry.tsx @@ -107,7 +107,8 @@ export const TOOL_UI_REGISTRY: Record = { }, '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 }))),