From 8ccc3b4c5b3b0ff0a0313e0185feeab65c1f8057 Mon Sep 17 00:00:00 2001 From: haikdc Date: Mon, 30 Mar 2026 16:39:06 -0700 Subject: [PATCH] [Haik]: Agentic Refactor 5, Tool Ui Approvals and Questions --- .../src/app/pages/AgentChat/ApprovalBar.tsx | 205 +-------------- .../app/pages/AgentChat/BatchApprovalBar.tsx | 179 ------------- .../src/app/pages/AgentChat/QuestionForm.tsx | 238 ------------------ .../src/app/pages/AgentChat/ToolPreview.tsx | 140 ----------- .../AgentChat/toolkit/approval-question.tsx | 211 ++++++++++++++++ .../AgentChat/toolkit/approval-tools.tsx | 179 +++++++++++++ .../approval-utils.tsx} | 133 +++++++--- frontend/tsconfig.tsbuildinfo | 2 +- 8 files changed, 494 insertions(+), 793 deletions(-) delete mode 100644 frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx delete mode 100644 frontend/src/app/pages/AgentChat/QuestionForm.tsx delete mode 100644 frontend/src/app/pages/AgentChat/ToolPreview.tsx create mode 100644 frontend/src/app/pages/AgentChat/toolkit/approval-question.tsx rename frontend/src/app/pages/AgentChat/{approvalUtils.tsx => toolkit/approval-utils.tsx} (63%) diff --git a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx index 4fee5b2c..86f7cf59 100644 --- a/frontend/src/app/pages/AgentChat/ApprovalBar.tsx +++ b/frontend/src/app/pages/AgentChat/ApprovalBar.tsx @@ -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) => void; - onDeny: (requestId: string, message?: string) => void; -} - -const GenericApprovalBar: React.FC = ({ 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 ( - - - - {getToolIcon(request.tool_name)} - - - Permission Required - - - - - - - {showDenyInput && ( - 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 }, - }, - }} /> - )} - - - {showDenyInput ? ( - - ) : ( - - )} - - - ); - } - - return ( - - - - {meta.integration?.icon || } - - - - - {parsed.displayName} - - - - {meta.description && ( - - {meta.description} - - )} - - - - - {summary && ( - setDetailsExpanded((v) => !v)}> - - {summary} - - - {detailsExpanded ? : } - - - )} - - - {JSON.stringify(request.tool_input, null, 2)} - - - - - {showDenyInput && ( - - 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 }, - }, - }} /> - - )} - - - - {showDenyInput ? ( - - ) : ( - - )} - - - ); -}; - -const ApprovalBar: React.FC = (props) => { - if (props.request.tool_name === 'AskUserQuestion') { - return ; - } - return ; -}; - -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'; diff --git a/frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx b/frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx deleted file mode 100644 index 5a53e4af..00000000 --- a/frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx +++ /dev/null @@ -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) => void; - onDeny: (requestId: string, message?: string) => void; -} - -export const BatchApprovalBar: React.FC = ({ requests, onApprove, onDeny }) => { - const c = useClaudeTokens(); - const [expandedGroup, setExpandedGroup] = useState(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(); - 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 ( - - {questions.map((req) => ( - - ))} - - {nonQuestions.length > 1 && ( - - - - {nonQuestions.length} pending approvals - - - - - - {groups.map((group) => ( - setExpandedGroup((prev) => prev === group.toolName ? null : group.toolName)} - onApprove={onApprove} - onDeny={onDeny} - onApproveGroup={() => handleApproveGroup(group)} - onDenyGroup={() => handleDenyGroup(group)} - /> - ))} - - )} - - {nonQuestions.length === 1 && ( - - )} - - ); -}; - -interface GroupRowProps { - group: ToolGroup; - expanded: boolean; - onToggle: () => void; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; - onApproveGroup: () => void; - onDenyGroup: () => void; -} - -const GroupRow: React.FC = ({ group, expanded, onToggle, onApprove, onDeny, onApproveGroup, onDenyGroup }) => { - const c = useClaudeTokens(); - const meta = useMcpToolMeta(group.parsed); - const accentColor = meta.integration?.color || c.status.warning; - - return ( - - - - {group.parsed.isMcp - ? (meta.integration?.icon || ) - : getToolIcon(group.toolName)} - - - - {group.parsed.isMcp ? group.parsed.displayName : group.toolName} - - - - - {group.requests.length > 1 && ( - <> - - - - )} - - - {expanded ? : } - - - - - - {group.requests.map((req) => ( - - ))} - - - - ); -}; diff --git a/frontend/src/app/pages/AgentChat/QuestionForm.tsx b/frontend/src/app/pages/AgentChat/QuestionForm.tsx deleted file mode 100644 index 9805a841..00000000 --- a/frontend/src/app/pages/AgentChat/QuestionForm.tsx +++ /dev/null @@ -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; - -export interface QuestionFormProps { - request: ApprovalRequest; - onApprove: (requestId: string, updatedInput?: Record) => void; - onDeny: (requestId: string, message?: string) => void; - compact?: boolean; -} - -const OTHER_KEY = '__other__'; - -export const QuestionForm: React.FC = ({ request, onApprove, onDeny, compact }) => { - const c = useClaudeTokens(); - const questions: any[] = request.tool_input.questions || []; - const [answers, setAnswers] = useState(() => { - const init: Answers = {}; - questions.forEach((q: any, i: number) => { - init[i] = q.multiSelect ? [] : ''; - }); - return init; - }); - const [otherActive, setOtherActive] = useState>({}); - const [otherText, setOtherText] = useState>({}); - - const toggleOption = useCallback((qIdx: number, key: string, multi: boolean) => { - setAnswers((prev) => { - const copy = { ...prev }; - if (multi) { - const arr = Array.isArray(copy[qIdx]) ? [...(copy[qIdx] as string[])] : []; - const idx = arr.indexOf(key); - if (idx >= 0) arr.splice(idx, 1); - else arr.push(key); - copy[qIdx] = arr; - } else { - copy[qIdx] = copy[qIdx] === key ? '' : key; - } - return copy; - }); - if (key !== OTHER_KEY) { - if (!multi) { - setOtherActive((prev) => ({ ...prev, [qIdx]: false })); - setOtherText((prev) => ({ ...prev, [qIdx]: '' })); - } - } - }, []); - - const toggleOther = useCallback((qIdx: number, multi: boolean) => { - setOtherActive((prev) => { - const wasActive = !!prev[qIdx]; - if (wasActive) { - setOtherText((p) => ({ ...p, [qIdx]: '' })); - } - if (!multi && !wasActive) { - setAnswers((p) => ({ ...p, [qIdx]: '' })); - } - return { ...prev, [qIdx]: !wasActive }; - }); - }, []); - - const setTextAnswer = useCallback((qIdx: number, text: string) => { - setAnswers((prev) => ({ ...prev, [qIdx]: text })); - }, []); - - const handleSubmit = () => { - const answersDict: Record = {}; - questions.forEach((q: any, i: number) => { - const questionText = q.question || q.prompt || q.text || ''; - const hasOptions = Array.isArray(q.options) && q.options.length > 0; - let answer = answers[i]; - if (hasOptions && otherActive[i] && otherText[i]) { - if (q.multiSelect) { - const arr = Array.isArray(answer) ? [...answer] : []; - arr.push(otherText[i]); - answer = arr; - } else { - answer = otherText[i]; - } - } - if (Array.isArray(answer)) { - answersDict[questionText] = answer.join(', '); - } else { - answersDict[questionText] = answer || ''; - } - }); - onApprove(request.id, { ...request.tool_input, questions, answers: answersDict }); - }; - - const isSelected = (qIdx: number, key: string): boolean => { - const val = answers[qIdx]; - if (Array.isArray(val)) return val.includes(key); - return val === key; - }; - - return ( - - - - - - - Agent has a question - - - - - {questions.map((q: any, i: number) => { - const hasOptions = Array.isArray(q.options) && q.options.length > 0; - const multi = !!q.multiSelect; - const isOtherActive = !!otherActive[i]; - return ( - - {q.header && ( - - {q.header} - - )} - - {q.question || q.prompt || q.text || '(question)'} - - {hasOptions ? ( - - - {q.options.map((opt: any) => { - const key = getOptionKey(opt); - const selected = isSelected(i, key); - return ( - toggleOption(i, key, multi)} - sx={{ - fontSize: '0.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, - }, - }} - /> - ); - })} - 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, - }, - }} - /> - - {isOtherActive && ( - 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 }, - }, - }} - /> - )} - - ) : ( - 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 }, - }, - }} - /> - )} - - ); - })} - - - - - - - - ); -}; diff --git a/frontend/src/app/pages/AgentChat/ToolPreview.tsx b/frontend/src/app/pages/AgentChat/ToolPreview.tsx deleted file mode 100644 index 5fd82c33..00000000 --- a/frontend/src/app/pages/AgentChat/ToolPreview.tsx +++ /dev/null @@ -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 ; - case 'Read': return ; - case 'Write': case 'Edit': return ; - case 'Grep': case 'Glob': return ; - case 'AskUserQuestion': return ; - default: return ; - } -} - -interface ToolPreviewProps { - request: ApprovalRequest; - tokens: ReturnType; -} - -export const CodeBlock: React.FC<{ tokens: ReturnType; children: React.ReactNode }> = ({ tokens: c, children }) => ( - - {children} - -); - -const ToolPreview: React.FC = ({ request, tokens: c }) => { - const { tool_name, tool_input } = request; - - switch (tool_name) { - case 'Bash': { - return ( - - {tool_input.description && ( - - {tool_input.description} - - )} - {tool_input.command || '(empty command)'} - - ); - } - - case 'Read': - return ( - - - - {tool_input.file_path || tool_input.path || JSON.stringify(tool_input)} - - - ); - - 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 ( - - - - - {path} - - - {content && {typeof content === 'string' ? content : JSON.stringify(content, null, 2)}} - - ); - } - - 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 ( - - - - {path && ( - - in {path} - - )} - - - ); - } - - case 'AskUserQuestion': - return null; - - default: { - const preview = tool_input.command || tool_input.file_path || tool_input.path || tool_input.query || null; - if (preview) { - return {preview}; - } - return {JSON.stringify(tool_input, null, 2)}; - } - } -}; - -export default ToolPreview; diff --git a/frontend/src/app/pages/AgentChat/toolkit/approval-question.tsx b/frontend/src/app/pages/AgentChat/toolkit/approval-question.tsx new file mode 100644 index 00000000..21a6ed45 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/toolkit/approval-question.tsx @@ -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 ( +
+
+ {header && ( + + {header} + + )} +

{question}

+