mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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]:
|
||||||
|
|||||||
@@ -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 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }))),
|
||||||
|
|||||||
Reference in New Issue
Block a user