mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: Agentic Refactor 5, Tool Ui Approvals and Questions
This commit is contained in:
@@ -1,195 +1,10 @@
|
||||
import React, { useState, useMemo } 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 Collapse from '@mui/material/Collapse';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { ApprovalRequest } from '@/shared/state/agentsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { parseMcpToolName, useMcpToolMeta, getMcpInputSummary } from './approvalUtils';
|
||||
import ToolPreview, { getToolIcon, CodeBlock } from './ToolPreview';
|
||||
import { QuestionForm } from './QuestionForm';
|
||||
|
||||
export { QuestionForm } from './QuestionForm';
|
||||
export type { QuestionFormProps } from './QuestionForm';
|
||||
export { BatchApprovalBar } from './BatchApprovalBar';
|
||||
export { parseMcpToolName, useMcpToolMeta } from './approvalUtils';
|
||||
export type { ParsedTool } from './approvalUtils';
|
||||
export { getToolIcon } from './ToolPreview';
|
||||
|
||||
interface Props {
|
||||
request: ApprovalRequest;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
}
|
||||
|
||||
const GenericApprovalBar: React.FC<Props> = ({ request, onApprove, onDeny }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [denyMessage, setDenyMessage] = useState('');
|
||||
const [showDenyInput, setShowDenyInput] = useState(false);
|
||||
const [detailsExpanded, setDetailsExpanded] = 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 summary = parsed.isMcp ? getMcpInputSummary(parsed.actionName, request.tool_input) : '';
|
||||
|
||||
if (!parsed.isMcp) {
|
||||
return (
|
||||
<Box sx={{ bgcolor: c.status.warningBg, border: '1px solid rgba(128,92,31,0.2)', borderRadius: 2.5, p: 2, mx: 2, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.25 }}>
|
||||
<Box sx={{ color: c.status.warning, display: 'flex', alignItems: 'center' }}>
|
||||
{getToolIcon(request.tool_name)}
|
||||
</Box>
|
||||
<Typography sx={{ color: c.status.warning, fontWeight: 700, fontSize: '0.85rem' }}>
|
||||
Permission Required
|
||||
</Typography>
|
||||
<Chip label={request.tool_name} size="small" sx={{
|
||||
height: 20, fontSize: '0.7rem', fontWeight: 600, fontFamily: c.font.mono,
|
||||
bgcolor: 'rgba(128,92,31,0.15)', color: c.status.warning, border: 'none',
|
||||
}} />
|
||||
</Box>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<ToolPreview request={request} tokens={c} />
|
||||
</Box>
|
||||
{showDenyInput && (
|
||||
<TextField placeholder="Reason for denying (optional)..." value={denyMessage}
|
||||
onChange={(e) => setDenyMessage(e.target.value)} fullWidth size="small"
|
||||
sx={{
|
||||
mb: 1.5, '& .MuiOutlinedInput-root': {
|
||||
color: c.text.primary, fontSize: '0.8rem',
|
||||
'& fieldset': { borderColor: c.border.strong },
|
||||
'&.Mui-focused fieldset': { borderColor: c.status.error },
|
||||
},
|
||||
}} />
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" startIcon={<CheckIcon />} onClick={() => onApprove(request.id)}
|
||||
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Approve
|
||||
</Button>
|
||||
{showDenyInput ? (
|
||||
<Button variant="contained" startIcon={<CloseIcon />}
|
||||
onClick={() => { onDeny(request.id, denyMessage || undefined); setShowDenyInput(false); setDenyMessage(''); }}
|
||||
sx={{ bgcolor: c.status.error, '&:hover': { bgcolor: '#8f2828' }, fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Deny
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outlined" onClick={() => setShowDenyInput(true)}
|
||||
sx={{ borderColor: c.status.error, color: c.status.error, '&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' }, fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
Deny
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
bgcolor: c.bg.surface, border: `1px solid ${c.border.subtle}`, borderLeft: `3px solid ${accentColor}`,
|
||||
borderRadius: 2.5, p: 0, mx: 2, mb: 1, overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, px: 2, pt: 1.75, pb: 0.5 }}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: 1.5, bgcolor: `${accentColor}14`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
{meta.integration?.icon || <ExtensionIcon sx={{ fontSize: 18, color: accentColor }} />}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ color: c.text.primary, fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
{parsed.displayName}
|
||||
</Typography>
|
||||
<Chip label={meta.serverLabel || parsed.serverSlug} size="small" sx={{
|
||||
height: 18, fontSize: '0.65rem', 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.78rem', 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.82rem', fontFamily: c.font.mono,
|
||||
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}>{JSON.stringify(request.tool_input, null, 2)}</CodeBlock>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{showDenyInput && (
|
||||
<Box sx={{ px: 2, pb: 0.5 }}>
|
||||
<TextField placeholder="Reason for denying (optional)..." value={denyMessage}
|
||||
onChange={(e) => setDenyMessage(e.target.value)} fullWidth size="small" autoFocus
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: c.text.primary, fontSize: '0.8rem',
|
||||
'& fieldset': { borderColor: c.border.strong },
|
||||
'&.Mui-focused fieldset': { borderColor: c.status.error },
|
||||
},
|
||||
}} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, px: 2, pt: 1, pb: 1.75 }}>
|
||||
<Button variant="contained" startIcon={<CheckIcon />} onClick={() => onApprove(request.id)}
|
||||
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.8rem', textTransform: 'none', borderRadius: 1.5, px: 2 }}>
|
||||
Approve
|
||||
</Button>
|
||||
{showDenyInput ? (
|
||||
<Button variant="contained" startIcon={<CloseIcon />}
|
||||
onClick={() => { onDeny(request.id, denyMessage || undefined); setShowDenyInput(false); setDenyMessage(''); }}
|
||||
sx={{ bgcolor: c.status.error, '&:hover': { bgcolor: '#8f2828' }, fontWeight: 600, fontSize: '0.8rem', textTransform: 'none', borderRadius: 1.5, px: 2 }}>
|
||||
Deny
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outlined" onClick={() => setShowDenyInput(true)}
|
||||
sx={{ borderColor: c.status.error, color: c.status.error, '&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' }, fontWeight: 600, fontSize: '0.8rem', textTransform: 'none', borderRadius: 1.5, px: 2 }}>
|
||||
Deny
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ApprovalBar: React.FC<Props> = (props) => {
|
||||
if (props.request.tool_name === 'AskUserQuestion') {
|
||||
return <QuestionForm request={props.request} onApprove={props.onApprove} onDeny={props.onDeny} />;
|
||||
}
|
||||
return <GenericApprovalBar {...props} />;
|
||||
};
|
||||
|
||||
export default ApprovalBar;
|
||||
/**
|
||||
* Re-export stub — logic moved to toolkit/approval-tools.tsx.
|
||||
* Kept for backward compatibility with AgentChat, DynamicIsland, and Dashboard imports.
|
||||
*/
|
||||
export { ApprovalRouter as default } from './toolkit/approval-tools';
|
||||
export { ToolQuestion as QuestionForm } from './toolkit/approval-tools';
|
||||
export type { ToolQuestionProps as QuestionFormProps } from './toolkit/approval-tools';
|
||||
export { BatchApprovalWrapper as BatchApprovalBar } from './toolkit/approval-tools';
|
||||
export { parseMcpToolName, useMcpToolMeta, getToolIcon } from './toolkit/approval-tools';
|
||||
export type { ParsedTool } from './toolkit/approval-tools';
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { ApprovalRequest } from '@/shared/state/agentsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { ParsedTool, parseMcpToolName, useMcpToolMeta } from './approvalUtils';
|
||||
import { getToolIcon } from './ToolPreview';
|
||||
import { QuestionForm } from './QuestionForm';
|
||||
import ApprovalBar from './ApprovalBar';
|
||||
|
||||
interface ToolGroup {
|
||||
toolName: string;
|
||||
parsed: ParsedTool;
|
||||
requests: ApprovalRequest[];
|
||||
}
|
||||
|
||||
interface BatchApprovalBarProps {
|
||||
requests: ApprovalRequest[];
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const BatchApprovalBar: React.FC<BatchApprovalBarProps> = ({ requests, onApprove, onDeny }) => {
|
||||
const c = useClaudeTokens();
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
const questions = requests.filter((r) => r.tool_name === 'AskUserQuestion');
|
||||
const nonQuestions = requests.filter((r) => r.tool_name !== 'AskUserQuestion');
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ToolGroup>();
|
||||
for (const req of nonQuestions) {
|
||||
const existing = map.get(req.tool_name);
|
||||
if (existing) {
|
||||
existing.requests.push(req);
|
||||
} else {
|
||||
map.set(req.tool_name, {
|
||||
toolName: req.tool_name,
|
||||
parsed: parseMcpToolName(req.tool_name),
|
||||
requests: [req],
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, [nonQuestions]);
|
||||
|
||||
const handleApproveAll = () => { for (const req of nonQuestions) onApprove(req.id); };
|
||||
const handleDenyAll = () => { for (const req of nonQuestions) onDeny(req.id); };
|
||||
const handleApproveGroup = (g: ToolGroup) => { for (const req of g.requests) onApprove(req.id); };
|
||||
const handleDenyGroup = (g: ToolGroup) => { for (const req of g.requests) onDeny(req.id); };
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{questions.map((req) => (
|
||||
<QuestionForm key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
|
||||
))}
|
||||
|
||||
{nonQuestions.length > 1 && (
|
||||
<Box sx={{
|
||||
mx: 2, mb: 0.5, borderRadius: 2.5, border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.surface, overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1.25,
|
||||
bgcolor: c.status.warningBg, borderBottom: `1px solid ${c.border.subtle}`,
|
||||
}}>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.status.warning, flex: 1 }}>
|
||||
{nonQuestions.length} pending approvals
|
||||
</Typography>
|
||||
<Button variant="contained" size="small" startIcon={<CheckIcon />} onClick={handleApproveAll}
|
||||
sx={{ bgcolor: c.status.success, '&:hover': { bgcolor: '#1e4d15' }, fontWeight: 600, fontSize: '0.78rem', textTransform: 'none', borderRadius: 1.5, px: 1.5, minHeight: 30 }}>
|
||||
Approve All
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" startIcon={<CloseIcon />} onClick={handleDenyAll}
|
||||
sx={{ borderColor: c.status.error, color: c.status.error, '&:hover': { borderColor: '#8f2828', bgcolor: 'rgba(181,51,51,0.04)' }, fontWeight: 600, fontSize: '0.78rem', textTransform: 'none', borderRadius: 1.5, px: 1.5, minHeight: 30 }}>
|
||||
Deny All
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{groups.map((group) => (
|
||||
<GroupRow
|
||||
key={group.toolName}
|
||||
group={group}
|
||||
expanded={expandedGroup === group.toolName}
|
||||
onToggle={() => setExpandedGroup((prev) => prev === group.toolName ? null : group.toolName)}
|
||||
onApprove={onApprove}
|
||||
onDeny={onDeny}
|
||||
onApproveGroup={() => handleApproveGroup(group)}
|
||||
onDenyGroup={() => handleDenyGroup(group)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{nonQuestions.length === 1 && (
|
||||
<ApprovalBar request={nonQuestions[0]} onApprove={onApprove} onDeny={onDeny} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupRowProps {
|
||||
group: ToolGroup;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
onApproveGroup: () => void;
|
||||
onDenyGroup: () => void;
|
||||
}
|
||||
|
||||
const GroupRow: React.FC<GroupRowProps> = ({ group, expanded, onToggle, onApprove, onDeny, onApproveGroup, onDenyGroup }) => {
|
||||
const c = useClaudeTokens();
|
||||
const meta = useMcpToolMeta(group.parsed);
|
||||
const accentColor = meta.integration?.color || c.status.warning;
|
||||
|
||||
return (
|
||||
<Box sx={{ borderBottom: `1px solid ${c.border.subtle}`, '&:last-child': { borderBottom: 'none' } }}>
|
||||
<Box
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1.5, px: 2, py: 1,
|
||||
cursor: 'pointer', '&:hover': { bgcolor: c.bg.secondary }, transition: 'background-color 0.1s',
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
width: 26, height: 26, borderRadius: 1, bgcolor: `${accentColor}14`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
{group.parsed.isMcp
|
||||
? (meta.integration?.icon || <ExtensionIcon sx={{ fontSize: 15, color: accentColor }} />)
|
||||
: getToolIcon(group.toolName)}
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 600, color: c.text.primary, flex: 1 }}>
|
||||
{group.parsed.isMcp ? group.parsed.displayName : group.toolName}
|
||||
</Typography>
|
||||
|
||||
<Chip label={`${group.requests.length}`} size="small"
|
||||
sx={{ height: 20, minWidth: 24, fontSize: '0.72rem', fontWeight: 700, bgcolor: `${accentColor}18`, color: accentColor, border: 'none' }} />
|
||||
|
||||
{group.requests.length > 1 && (
|
||||
<>
|
||||
<Button variant="text" size="small"
|
||||
onClick={(e) => { e.stopPropagation(); onApproveGroup(); }}
|
||||
sx={{ color: c.status.success, fontWeight: 600, fontSize: '0.72rem', textTransform: 'none', minWidth: 0, px: 1, minHeight: 24 }}>
|
||||
Approve {group.requests.length}
|
||||
</Button>
|
||||
<Button variant="text" size="small"
|
||||
onClick={(e) => { e.stopPropagation(); onDenyGroup(); }}
|
||||
sx={{ color: c.status.error, fontWeight: 600, fontSize: '0.72rem', textTransform: 'none', minWidth: 0, px: 1, minHeight: 24 }}>
|
||||
Deny {group.requests.length}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<IconButton size="small" sx={{ color: c.text.ghost, p: 0.25 }}>
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ px: 1, pb: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{group.requests.map((req) => (
|
||||
<ApprovalBar key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
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>) => 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={{
|
||||
bgcolor: c.bg.secondary, border: `1px solid ${c.accent.primary}33`,
|
||||
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.85rem' }}>
|
||||
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.7rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', mb: 0.25 }}>
|
||||
{q.header}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', 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.78rem', 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.78rem', 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.82rem',
|
||||
'& 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.82rem',
|
||||
'& 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.8rem' }}>
|
||||
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.8rem' }}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import QuestionAnswerIcon from '@mui/icons-material/QuestionAnswer';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import { ApprovalRequest } from '@/shared/state/agentsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export function getToolIcon(toolName: string) {
|
||||
switch (toolName) {
|
||||
case 'Bash': return <TerminalIcon sx={{ fontSize: '1rem' }} />;
|
||||
case 'Read': return <DescriptionIcon sx={{ fontSize: '1rem' }} />;
|
||||
case 'Write': case 'Edit': return <EditIcon sx={{ fontSize: '1rem' }} />;
|
||||
case 'Grep': case 'Glob': return <SearchIcon sx={{ fontSize: '1rem' }} />;
|
||||
case 'AskUserQuestion': return <QuestionAnswerIcon sx={{ fontSize: '1rem' }} />;
|
||||
default: return <BuildIcon sx={{ fontSize: '1rem' }} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolPreviewProps {
|
||||
request: ApprovalRequest;
|
||||
tokens: ReturnType<typeof useClaudeTokens>;
|
||||
}
|
||||
|
||||
export const CodeBlock: React.FC<{ tokens: ReturnType<typeof useClaudeTokens>; children: React.ReactNode }> = ({ tokens: c, children }) => (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
bgcolor: c.bg.secondary,
|
||||
borderRadius: 1.5,
|
||||
p: 1.5,
|
||||
m: 0,
|
||||
maxHeight: 150,
|
||||
overflow: 'auto',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: c.font.mono,
|
||||
'&::-webkit-scrollbar': { width: 5 },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: c.border.medium,
|
||||
borderRadius: 3,
|
||||
'&:hover': { background: c.border.strong },
|
||||
},
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${c.border.medium} transparent`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ToolPreview: React.FC<ToolPreviewProps> = ({ request, tokens: c }) => {
|
||||
const { tool_name, tool_input } = request;
|
||||
|
||||
switch (tool_name) {
|
||||
case 'Bash': {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
{tool_input.description && (
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.78rem' }}>
|
||||
{tool_input.description}
|
||||
</Typography>
|
||||
)}
|
||||
<CodeBlock tokens={c}>{tool_input.command || '(empty command)'}</CodeBlock>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Read':
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DescriptionIcon sx={{ fontSize: '0.9rem', color: c.text.muted }} />
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.8rem', fontFamily: c.font.mono }}>
|
||||
{tool_input.file_path || tool_input.path || JSON.stringify(tool_input)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'Write':
|
||||
case 'Edit': {
|
||||
const path = tool_input.file_path || tool_input.path || '';
|
||||
const content = tool_input.content || tool_input.new_content || tool_input.old_string;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<EditIcon sx={{ fontSize: '0.9rem', color: c.text.muted }} />
|
||||
<Typography sx={{ color: c.text.secondary, fontSize: '0.8rem', fontFamily: c.font.mono }}>
|
||||
{path}
|
||||
</Typography>
|
||||
</Box>
|
||||
{content && <CodeBlock tokens={c}>{typeof content === 'string' ? content : JSON.stringify(content, null, 2)}</CodeBlock>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'Grep':
|
||||
case 'Glob': {
|
||||
const pattern = tool_input.pattern || tool_input.glob_pattern || tool_input.query || '';
|
||||
const path = tool_input.path || tool_input.directory || '';
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={pattern}
|
||||
size="small"
|
||||
sx={{ fontFamily: c.font.mono, fontSize: '0.75rem', bgcolor: c.bg.secondary, color: c.text.secondary, border: `1px solid ${c.border.subtle}` }}
|
||||
/>
|
||||
{path && (
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.75rem', fontFamily: c.font.mono }}>
|
||||
in {path}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'AskUserQuestion':
|
||||
return null;
|
||||
|
||||
default: {
|
||||
const preview = tool_input.command || tool_input.file_path || tool_input.path || tool_input.query || null;
|
||||
if (preview) {
|
||||
return <CodeBlock tokens={c}>{preview}</CodeBlock>;
|
||||
}
|
||||
return <CodeBlock tokens={c}>{JSON.stringify(tool_input, null, 2)}</CodeBlock>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ToolPreview;
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { OptionList } from '@/components/tool-ui/option-list';
|
||||
import type { OptionListSelection } from '@/components/tool-ui/option-list';
|
||||
import { QuestionFlow } from '@/components/tool-ui/question-flow';
|
||||
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
|
||||
|
||||
function optionKey(opt: any): string {
|
||||
return opt.id || opt.value || opt.label || opt.text || String(opt);
|
||||
}
|
||||
|
||||
function optionLabel(opt: any): string {
|
||||
return opt.label || opt.value || opt.text || String(opt);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FreeTextQuestion (fallback for questions without options)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FreeTextQuestion: React.FC<{
|
||||
id: string;
|
||||
question: string;
|
||||
header?: string;
|
||||
onSubmit: (answer: string) => void;
|
||||
onDismiss: () => void;
|
||||
}> = ({ id, question, header, onSubmit, onDismiss }) => {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full max-w-md flex-col gap-3 text-foreground"
|
||||
data-slot="free-text-question"
|
||||
data-tool-ui-id={id}
|
||||
>
|
||||
<div className="bg-card flex flex-col gap-3 rounded-2xl border p-5 shadow-xs">
|
||||
{header && (
|
||||
<span className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{header}
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-base font-semibold leading-tight">{question}</h2>
|
||||
<textarea
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
rows={3}
|
||||
placeholder="Type your answer..."
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent transition-colors"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
onClick={() => onSubmit(text)}
|
||||
disabled={!text.trim()}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolQuestion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolQuestionProps {
|
||||
request: ApprovalRequest;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const ToolQuestion: React.FC<ToolQuestionProps> = ({ request, onApprove, onDeny }) => {
|
||||
const rawQuestions: any[] = request.tool_input.questions || [];
|
||||
|
||||
const questions = useMemo(() => {
|
||||
if (rawQuestions.length > 0) return rawQuestions;
|
||||
if (request.tool_input.question) {
|
||||
return [{
|
||||
question: request.tool_input.question,
|
||||
options: request.tool_input.options,
|
||||
multiSelect: request.tool_input.allow_multiple ?? request.tool_input.multiSelect,
|
||||
header: request.tool_input.header,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}, [rawQuestions, request.tool_input]);
|
||||
|
||||
const buildAnswersPayload = useCallback(
|
||||
(answersDict: Record<string, string>) => {
|
||||
return { ...request.tool_input, questions, answers: answersDict };
|
||||
},
|
||||
[request.tool_input, questions],
|
||||
);
|
||||
|
||||
// Multi-step questions with options -> QuestionFlow upfront mode
|
||||
const stepsWithOptions = useMemo(() => {
|
||||
if (questions.length <= 1) return null;
|
||||
const steps = questions
|
||||
.map((q: any, i: number) => {
|
||||
const opts = Array.isArray(q.options) ? q.options : [];
|
||||
if (opts.length === 0) return null;
|
||||
return {
|
||||
id: `q-${i}`,
|
||||
title: q.question || q.prompt || q.text || `Question ${i + 1}`,
|
||||
description: q.header,
|
||||
options: opts.map((opt: any) => ({
|
||||
id: optionKey(opt),
|
||||
label: optionLabel(opt),
|
||||
description: opt.description,
|
||||
})),
|
||||
selectionMode: (q.multiSelect || q.allow_multiple ? 'multi' : 'single') as 'multi' | 'single',
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
options: Array<{ id: string; label: string; description?: string }>;
|
||||
selectionMode: 'multi' | 'single';
|
||||
}>;
|
||||
return steps.length > 1 ? steps : null;
|
||||
}, [questions]);
|
||||
|
||||
const handleFlowComplete = useCallback(
|
||||
(answers: Record<string, string[]>) => {
|
||||
const answersDict: Record<string, string> = {};
|
||||
if (stepsWithOptions) {
|
||||
stepsWithOptions.forEach((step, i) => {
|
||||
const q = questions[i];
|
||||
const questionText = q?.question || q?.prompt || q?.text || '';
|
||||
const selection = answers[step.id] || [];
|
||||
answersDict[questionText] = selection.join(', ');
|
||||
});
|
||||
}
|
||||
onApprove(request.id, buildAnswersPayload(answersDict));
|
||||
},
|
||||
[stepsWithOptions, questions, request.id, onApprove, buildAnswersPayload],
|
||||
);
|
||||
|
||||
if (stepsWithOptions) {
|
||||
return (
|
||||
<QuestionFlow
|
||||
id={request.id}
|
||||
steps={stepsWithOptions}
|
||||
onComplete={handleFlowComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Single question
|
||||
const q = questions[0] || request.tool_input;
|
||||
const questionText: string = q.question || q.prompt || q.text || '(question)';
|
||||
const options: any[] = Array.isArray(q.options) ? q.options : [];
|
||||
const isMulti: boolean = !!(q.multiSelect || q.allow_multiple);
|
||||
|
||||
if (options.length > 0) {
|
||||
const mappedOptions = options.map((opt: any) => ({
|
||||
id: optionKey(opt),
|
||||
label: optionLabel(opt),
|
||||
description: opt.description,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-foreground px-1">
|
||||
<h2 className="text-base font-semibold leading-tight">{questionText}</h2>
|
||||
</div>
|
||||
<OptionList
|
||||
id={request.id}
|
||||
options={mappedOptions}
|
||||
selectionMode={isMulti ? 'multi' : 'single'}
|
||||
actions={[
|
||||
{ id: 'cancel', label: 'Skip' },
|
||||
{ id: 'confirm', label: 'Submit' },
|
||||
]}
|
||||
onAction={(actionId: string, selection: OptionListSelection) => {
|
||||
if (actionId === 'confirm') {
|
||||
const answer = Array.isArray(selection) ? selection : selection ? [selection] : [];
|
||||
onApprove(request.id, { answer });
|
||||
} else {
|
||||
onDeny(request.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Free-text fallback
|
||||
return (
|
||||
<FreeTextQuestion
|
||||
id={request.id}
|
||||
question={questionText}
|
||||
header={q.header}
|
||||
onSubmit={(answer) => {
|
||||
const answersDict: Record<string, string> = { [questionText]: answer };
|
||||
onApprove(request.id, buildAnswersPayload(answersDict));
|
||||
}}
|
||||
onDismiss={() => onDeny(request.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,182 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import type { Toolkit } from '@assistant-ui/react';
|
||||
import { ApprovalCard } from '@/components/tool-ui/approval-card';
|
||||
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import type { ToolDefinition } from '@/shared/state/toolsSlice';
|
||||
import {
|
||||
parseMcpToolName, sanitizeServerName, getMcpInputSummary,
|
||||
getToolIconName, buildMetadata, isDangerous,
|
||||
INTEGRATION_META,
|
||||
type ParsedTool, type McpToolMeta,
|
||||
} from './approval-utils';
|
||||
import { ToolQuestion } from './approval-question';
|
||||
|
||||
// Re-exports so external consumers can import everything from this file
|
||||
export {
|
||||
parseMcpToolName, sanitizeServerName, getMcpInputSummary,
|
||||
getToolIcon, getToolIconName, buildMetadata, isDangerous,
|
||||
INTEGRATION_META,
|
||||
} from './approval-utils';
|
||||
export type {
|
||||
IntegrationMeta, ParsedTool, McpToolMeta,
|
||||
} from './approval-utils';
|
||||
export { ToolQuestion } from './approval-question';
|
||||
export type { ToolQuestionProps } from './approval-question';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useMcpToolMeta (React hook — lives here alongside other component code)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
|
||||
const toolItems = useAppSelector((s) => s.tools.items);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!parsed.isMcp) {
|
||||
return { integration: null, description: '', serverLabel: '' };
|
||||
}
|
||||
|
||||
const toolDef: ToolDefinition | undefined = Object.values(toolItems).find(
|
||||
(t) => t.mcp_config && Object.keys(t.mcp_config).length > 0
|
||||
&& sanitizeServerName(t.name) === parsed.serverSlug,
|
||||
);
|
||||
|
||||
if (!toolDef) {
|
||||
return { integration: null, description: '', serverLabel: parsed.serverSlug };
|
||||
}
|
||||
|
||||
const description = toolDef.tool_permissions?._tool_descriptions?.[parsed.actionName] || '';
|
||||
const integration = INTEGRATION_META[toolDef.name] || null;
|
||||
const serverLabel = toolDef.name;
|
||||
|
||||
return { integration, description, serverLabel };
|
||||
}, [parsed, toolItems]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolApproval
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ToolApprovalProps {
|
||||
request: ApprovalRequest;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ToolApproval: React.FC<ToolApprovalProps> = ({ request, onApprove, onDeny }) => {
|
||||
const parsed = useMemo(() => parseMcpToolName(request.tool_name), [request.tool_name]);
|
||||
const meta = useMcpToolMeta(parsed);
|
||||
const summary = parsed.isMcp
|
||||
? getMcpInputSummary(parsed.actionName, request.tool_input)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
id={request.id}
|
||||
title={parsed.isMcp ? parsed.displayName : `Run ${request.tool_name}`}
|
||||
description={meta.description || summary || undefined}
|
||||
icon={parsed.isMcp ? 'puzzle' : getToolIconName(request.tool_name)}
|
||||
metadata={buildMetadata(request.tool_input)}
|
||||
variant={isDangerous(request.tool_name, request.tool_input) ? 'destructive' : 'default'}
|
||||
confirmLabel="Approve"
|
||||
cancelLabel="Deny"
|
||||
onConfirm={() => onApprove(request.id)}
|
||||
onCancel={() => onDeny(request.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BatchApprovalWrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BatchApprovalWrapperProps {
|
||||
requests: ApprovalRequest[];
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const BatchApprovalWrapper: React.FC<BatchApprovalWrapperProps> = ({
|
||||
requests, onApprove, onDeny,
|
||||
}) => {
|
||||
const questionReqs = useMemo(
|
||||
() => requests.filter((r) => r.tool_name === 'AskUserQuestion'),
|
||||
[requests],
|
||||
);
|
||||
const approvalReqs = useMemo(
|
||||
() => requests.filter((r) => r.tool_name !== 'AskUserQuestion'),
|
||||
[requests],
|
||||
);
|
||||
|
||||
const handleApproveAll = useCallback(() => {
|
||||
for (const req of approvalReqs) onApprove(req.id);
|
||||
}, [approvalReqs, onApprove]);
|
||||
|
||||
const handleDenyAll = useCallback(() => {
|
||||
for (const req of approvalReqs) onDeny(req.id);
|
||||
}, [approvalReqs, onDeny]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-2 pb-1">
|
||||
{questionReqs.map((req) => (
|
||||
<ToolQuestion key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
|
||||
))}
|
||||
|
||||
{approvalReqs.length > 1 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-2">
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{approvalReqs.length} pending approvals
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
onClick={handleApproveAll}
|
||||
>
|
||||
Approve All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-semibold text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={handleDenyAll}
|
||||
>
|
||||
Deny All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{approvalReqs.map((req) => (
|
||||
<ToolApproval key={req.id} request={req} onApprove={onApprove} onDeny={onDeny} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approvalReqs.length === 1 && (
|
||||
<ApprovalRouter request={approvalReqs[0]} onApprove={onApprove} onDeny={onDeny} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ApprovalRouter (replaces old ApprovalBar default export)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ApprovalRouterProps {
|
||||
request: ApprovalRequest;
|
||||
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (requestId: string, message?: string) => void;
|
||||
}
|
||||
|
||||
export const ApprovalRouter: React.FC<ApprovalRouterProps> = (props) => {
|
||||
if (props.request.tool_name === 'AskUserQuestion') {
|
||||
return <ToolQuestion {...props} />;
|
||||
}
|
||||
return <ToolApproval {...props} />;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolkit export (empty — approvals are standalone, not thread tool renderers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const approvalToolkit: Partial<Toolkit> = {};
|
||||
|
||||
+93
-40
@@ -1,13 +1,38 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { ToolDefinition } from '@/shared/state/toolsSlice';
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { MetadataItem } from '@/components/tool-ui/approval-card';
|
||||
import {
|
||||
Terminal, FileText, FilePen, Search,
|
||||
MessageCircleQuestion, Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IntegrationMeta {
|
||||
label: string;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
export interface ParsedTool {
|
||||
isMcp: boolean;
|
||||
serverSlug: string;
|
||||
actionName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface McpToolMeta {
|
||||
integration: IntegrationMeta | null;
|
||||
description: string;
|
||||
serverLabel: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration metadata (ported from approvalUtils.tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GoogleIcon = (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
||||
@@ -30,12 +55,9 @@ export const INTEGRATION_META: Record<string, IntegrationMeta> = {
|
||||
'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon },
|
||||
};
|
||||
|
||||
export interface ParsedTool {
|
||||
isMcp: boolean;
|
||||
serverSlug: string;
|
||||
actionName: string;
|
||||
displayName: string;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse / sanitize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseMcpToolName(rawName: string): ParsedTool {
|
||||
const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/);
|
||||
@@ -54,35 +76,9 @@ export function sanitizeServerName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export interface McpToolMeta {
|
||||
integration: IntegrationMeta | null;
|
||||
description: string;
|
||||
serverLabel: string;
|
||||
}
|
||||
|
||||
export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
|
||||
const toolItems = useAppSelector((s) => s.tools.items);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!parsed.isMcp) {
|
||||
return { integration: null, description: '', serverLabel: '' };
|
||||
}
|
||||
|
||||
const toolDef: ToolDefinition | undefined = Object.values(toolItems).find(
|
||||
(t) => t.mcp_config && Object.keys(t.mcp_config).length > 0 && sanitizeServerName(t.name) === parsed.serverSlug
|
||||
);
|
||||
|
||||
if (!toolDef) {
|
||||
return { integration: null, description: '', serverLabel: parsed.serverSlug };
|
||||
}
|
||||
|
||||
const description = toolDef.tool_permissions?._tool_descriptions?.[parsed.actionName] || '';
|
||||
const integration = INTEGRATION_META[toolDef.name] || null;
|
||||
const serverLabel = toolDef.name;
|
||||
|
||||
return { integration, description, serverLabel };
|
||||
}, [parsed, toolItems]);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP input summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getMcpInputSummary(actionName: string, toolInput: Record<string, any>): string {
|
||||
const lower = actionName.toLowerCase();
|
||||
@@ -131,9 +127,66 @@ export function getMcpInputSummary(actionName: string, toolInput: Record<string,
|
||||
if (stringVals.length >= 2) break;
|
||||
}
|
||||
if (stringVals.length > 0) {
|
||||
const joined = stringVals.join(' -- ');
|
||||
const joined = stringVals.join(' — ');
|
||||
return joined.length > 100 ? joined.slice(0, 97) + '...' : joined;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOL_ICON_MAP: Record<string, string> = {
|
||||
Bash: 'terminal',
|
||||
Read: 'file-text',
|
||||
Write: 'file-pen',
|
||||
Edit: 'file-pen',
|
||||
Grep: 'search',
|
||||
Glob: 'search',
|
||||
AskUserQuestion: 'message-circle-question',
|
||||
};
|
||||
|
||||
export function getToolIconName(toolName: string): string {
|
||||
return TOOL_ICON_MAP[toolName] ?? 'wrench';
|
||||
}
|
||||
|
||||
/** Backward-compatible JSX icon for external consumers (DynamicIsland, etc.) */
|
||||
export function getToolIcon(toolName: string): ReactNode {
|
||||
const size = 16;
|
||||
switch (toolName) {
|
||||
case 'Bash': return <Terminal size={size} />;
|
||||
case 'Read': return <FileText size={size} />;
|
||||
case 'Write': case 'Edit': return <FilePen size={size} />;
|
||||
case 'Grep': case 'Glob': return <Search size={size} />;
|
||||
case 'AskUserQuestion': return <MessageCircleQuestion size={size} />;
|
||||
default: return <Wrench size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata / danger helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMetadata(toolInput: Record<string, any>): MetadataItem[] {
|
||||
return Object.entries(toolInput)
|
||||
.filter(([, v]) => v != null)
|
||||
.slice(0, 5)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string'
|
||||
? value.slice(0, 200)
|
||||
: JSON.stringify(value).slice(0, 200),
|
||||
}));
|
||||
}
|
||||
|
||||
const DANGEROUS_PATTERNS = /\b(rm\s|rmdir|del\s|delete|drop\s|truncate|format)\b/i;
|
||||
|
||||
export function isDangerous(toolName: string, toolInput: Record<string, any>): boolean {
|
||||
if (toolName === 'Bash') {
|
||||
const cmd = toolInput.command || '';
|
||||
return DANGEROUS_PATTERNS.test(cmd);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user