[Haik]: refactor: break apart approval-tools.tsx, approval-question.tsx, and approval-utils.tsx into a modular approvalToolkit/ directory with ApprovalRouter, BatchApprovalWrapper, ToolApproval, ToolQuestion, getMcpInputSummary, and useMcpToolMeta as separate files; relocate tool-ui components (ApprovalCard, option-list, question-flow) into co-located component folders; inline getToolIcon into CompactActionablePill using Lucide icons; replace toolkit/index.ts with toolkit.ts; update all consumer imports across AgentChat, ExpandedCard, CompactActionablePill, and Dashboard AgentCard components

This commit is contained in:
haikdc
2026-04-18 16:44:07 -07:00
parent 5c876493c0
commit a8370d0cce
31 changed files with 385 additions and 392 deletions
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import type { ReactNode } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -7,11 +8,24 @@ import CloseIcon from '@mui/icons-material/Close';
import CheckIcon from '@mui/icons-material/Check';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { motion } from 'framer-motion';
import { parseMcpToolName, getToolIcon } from '@/app/pages/AgentChat/toolkit/approval-utils';
import { useMcpToolMeta } from '@/app/pages/AgentChat/toolkit/approval-tools';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approvalToolkit/utils';
import { useMcpToolMeta } from '@/app/pages/AgentChat/toolkit/approvalToolkit/components/useMcpToolMeta';
import { SPRING_BOUNCE } from './islandTypes';
import type { ClaudeTokens } from './islandTypes';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
import { Terminal, FileText, FilePen, Search, MessageCircleQuestion, Wrench } from 'lucide-react';
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} />;
}
}
export const CompactActionablePill: React.FC<{
c: ClaudeTokens;
@@ -4,7 +4,8 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { motion } from 'framer-motion';
import { ApprovalRouter, BatchApprovalWrapper } from '@/app/pages/AgentChat/toolkit/approval-tools';
import { ApprovalRouter } from '@/app/pages/AgentChat/toolkit/approvalToolkit/ApprovalRouter';
import { BatchApprovalWrapper } from '@/app/pages/AgentChat/toolkit/approvalToolkit/BatchApprovalWrapper';
import { AgentStatusRow } from './AgentStatusRow';
import { CompletedAgentsList } from './CompletedAgentsList';
import type { ClaudeTokens, SessionApprovalGroup, TrackedAgent } from './islandTypes';
@@ -2,7 +2,8 @@ import React, { useCallback, useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { AssistantRuntimeProvider, useAui, Tools } from '@assistant-ui/react';
import { ApprovalRouter, BatchApprovalWrapper } from './toolkit/approval-tools';
import { ApprovalRouter } from './toolkit/approvalToolkit/ApprovalRouter';
import { BatchApprovalWrapper } from './toolkit/approvalToolkit/BatchApprovalWrapper';
import ChatHeader from './ChatHeader';
import MessageQueue from './MessageQueue';
import OpenSwarmThread from './OpenSwarmThread/OpenSwarmThread';
@@ -1,170 +0,0 @@
import React, { useMemo, useCallback } from 'react';
import type { Toolkit } from '@assistant-ui/react';
import { ApprovalCard } from '@/components/tool-ui/ApprovalCard/ApprovalCard';
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';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
interface ToolApprovalProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
}
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
// ---------------------------------------------------------------------------
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> = {};
@@ -1,193 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
// TODO: what is this even supposed to try and import/use ???
import type { MetadataItem } from '@/components/tool-ui/ApprovalCard/schema';
import {
Terminal, FileText, FilePen, Search,
MessageCircleQuestion, Wrench,
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IntegrationMeta {
label: string;
color: string;
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"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
const RedditIcon = (
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="12" fill="#FF4500"/>
<path d="M19.5 12c0-.6-.5-1.1-1.1-1.1-.3 0-.6.1-.8.3-1-.7-2.3-1.1-3.7-1.1l.6-3 2.1.5c0 .6.5 1.1 1.1 1.1.6 0 1.1-.5 1.1-1.1 0-.6-.5-1.1-1.1-1.1-.4 0-.8.3-1 .6l-2.3-.5c-.1 0-.2 0-.2.1l-.7 3.3c-1.4 0-2.7.4-3.7 1.1-.2-.2-.5-.3-.8-.3-.6 0-1.1.5-1.1 1.1 0 .4.2.8.6 1-.1.3-.1.6-.1.9 0 2.3 2.6 4.1 5.8 4.1s5.8-1.8 5.8-4.1c0-.3 0-.6-.1-.9.4-.2.6-.6.6-1zm-9.8 1.1c0-.6.5-1.1 1.1-1.1.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1-.6 0-1.1-.5-1.1-1.1zm6.2 2.9c-.8.8-2 .9-2.9.9s-2.1-.1-2.9-.9c-.1-.1-.1-.3 0-.4.1-.1.3-.1.4 0 .6.6 1.6.8 2.5.8s1.9-.2 2.5-.8c.1-.1.3-.1.4 0 .1.1.1.3 0 .4zm-.2-1.8c-.6 0-1.1-.5-1.1-1.1 0-.6.5-1.1 1.1-1.1.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1z" fill="#fff"/>
</svg>
);
export const INTEGRATION_META: Record<string, IntegrationMeta> = {
'Google Workspace': { label: 'Google Workspace', color: '#4285F4', icon: GoogleIcon },
'xbird': { label: 'X / Twitter', color: '#1DA1F2', icon: <span style={{ fontSize: 14, fontWeight: 700 }}>𝕏</span> },
'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon },
};
// ---------------------------------------------------------------------------
// Parse / sanitize
// ---------------------------------------------------------------------------
export function parseMcpToolName(rawName: string): ParsedTool {
const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/);
if (!m) {
return { isMcp: false, serverSlug: '', actionName: rawName, displayName: rawName };
}
const serverSlug = m[1];
const actionName = m[2];
const displayName = actionName
.replace(/_/g, ' ')
.replace(/\b\w/g, (ch) => ch.toUpperCase());
return { isMcp: true, serverSlug, actionName, displayName };
}
export function sanitizeServerName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
// ---------------------------------------------------------------------------
// MCP input summary
// ---------------------------------------------------------------------------
export function getMcpInputSummary(actionName: string, toolInput: Record<string, any>): string {
const lower = actionName.toLowerCase();
if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) {
const query = toolInput.query || toolInput.search_query || toolInput.q || '';
const to = toolInput.to || toolInput.recipient || '';
const subject = toolInput.subject || '';
if (query) return `Search: "${query}"`;
if (to && subject) return `To ${to} — ${subject}`;
if (to) return `To ${to}`;
if (subject) return `Subject: ${subject}`;
}
if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) {
const summary = toolInput.summary || toolInput.title || toolInput.event_name || '';
const start = toolInput.start || toolInput.start_time || toolInput.date || '';
if (summary && start) return `${summary} — ${start}`;
if (summary) return summary;
if (start) return `Date: ${start}`;
}
if (lower.includes('drive') || lower.includes('doc') || lower.includes('sheet') || lower.includes('slide')) {
const name = toolInput.name || toolInput.title || toolInput.filename || toolInput.file_name || '';
const query = toolInput.query || toolInput.q || '';
if (name) return name;
if (query) return `Search: "${query}"`;
}
if (lower.includes('tweet') || lower.includes('post') || lower.includes('send') || lower.includes('reply')) {
const text = toolInput.text || toolInput.content || toolInput.body || toolInput.message || '';
if (text) return text.length > 80 ? text.slice(0, 77) + '...' : text;
}
if (lower.includes('search') || lower.includes('find') || lower.includes('query') || lower.includes('list')) {
const query = toolInput.query || toolInput.q || toolInput.search_query || toolInput.keyword || toolInput.term || '';
if (query) return `"${query}"`;
}
const stringVals: string[] = [];
for (const [key, val] of Object.entries(toolInput)) {
if (key.startsWith('_')) continue;
if (typeof val === 'string' && val.trim()) {
stringVals.push(val.trim());
}
if (stringVals.length >= 2) break;
}
if (stringVals.length > 0) {
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;
}
@@ -0,0 +1,17 @@
import React from 'react';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
import { ToolQuestion } from './components/ToolQuestion/ToolQuestion';
import { ToolApproval } from './components/ToolApproval';
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} />;
};
@@ -0,0 +1,73 @@
import React, { useMemo, useCallback } from 'react';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
import { ToolQuestion } from './components/ToolQuestion/ToolQuestion';
import { ToolApproval } from './components/ToolApproval';
import { ApprovalRouter } from './ApprovalRouter';
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>
);
};
@@ -0,0 +1,90 @@
import React, { useMemo } from 'react';
import { ApprovalCard } from './ApprovalCard/ApprovalCard';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useMcpToolMeta } from '../useMcpToolMeta';
import {
parseMcpToolName,
} from '../../utils';
import { getMcpInputSummary } from '../getMcpInputSummary';
// TODO: what is this even supposed to try and import/use ???
import type { MetadataItem } from '@/components/tool-ui/ApprovalCard/schema';
// ---------------------------------------------------------------------------
// 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',
};
function getToolIconName(toolName: string): string {
return TOOL_ICON_MAP[toolName] ?? 'wrench';
}
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;
function isDangerous(toolName: string, toolInput: Record<string, any>): boolean {
if (toolName === 'Bash') {
const cmd = toolInput.command || '';
return DANGEROUS_PATTERNS.test(cmd);
}
return false;
}
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
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)}
/>
);
};
@@ -18,9 +18,9 @@ import {
normalizeSelectionForOptions,
parseSelectionToIdSet,
} from "./selection";
import { ActionButtons } from "../shared/action-buttons";
import { normalizeActionsConfig } from "../shared/actions-config";
import type { Action } from "../shared/schema";
import { ActionButtons } from "@/components/tool-ui/shared/action-buttons";
import { normalizeActionsConfig } from "@/components/tool-ui/shared/actions-config";
import type { Action } from "@/components/tool-ui/shared/schema";
import { cn, Button, Separator } from "./_adapter";
import { Check } from "lucide-react";
@@ -1,14 +1,14 @@
import { z } from "zod";
import type { ReactNode } from "react";
import type { ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import type { ActionsProp } from "@/components/tool-ui/shared/actions-config";
import type { EmbeddedActionsProps } from "@/components/tool-ui/shared/embedded-actions";
import {
ActionSchema,
SerializableActionsConfigSchema,
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
} from "@/components/tool-ui/shared/schema";
export const OptionListOptionSchema = z.object({
id: z.string().min(1),
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { ReactNode } from "react";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
import { ToolUIIdSchema, ToolUIRoleSchema } from "@/components/tool-ui/shared/schema";
export const QuestionFlowOptionSchema = z.object({
id: z.string().min(1),
@@ -1,7 +1,7 @@
import React, { useMemo, useState, useCallback } from 'react';
import { OptionList } from '@/components/tool-ui/option-list/option-list';
import type { OptionListSelection } from '@/components/tool-ui/option-list/schema';
import { QuestionFlow } from '@/components/tool-ui/question-flow/question-flow';
import { OptionList } from './OptionList/OptionList';
import type { OptionListSelection } from './OptionList/schema';
import { QuestionFlow } from './QuestionFlow/QuestionFlow';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
function optionKey(opt: any): string {
@@ -0,0 +1,53 @@
export function getMcpInputSummary(actionName: string, toolInput: Record<string, any>): string {
const lower = actionName.toLowerCase();
if (lower.includes('gmail') || lower.includes('email') || lower.includes('mail')) {
const query = toolInput.query || toolInput.search_query || toolInput.q || '';
const to = toolInput.to || toolInput.recipient || '';
const subject = toolInput.subject || '';
if (query) return `Search: "${query}"`;
if (to && subject) return `To ${to} — ${subject}`;
if (to) return `To ${to}`;
if (subject) return `Subject: ${subject}`;
}
if (lower.includes('calendar') || lower.includes('event') || lower.includes('freebusy')) {
const summary = toolInput.summary || toolInput.title || toolInput.event_name || '';
const start = toolInput.start || toolInput.start_time || toolInput.date || '';
if (summary && start) return `${summary} — ${start}`;
if (summary) return summary;
if (start) return `Date: ${start}`;
}
if (lower.includes('drive') || lower.includes('doc') || lower.includes('sheet') || lower.includes('slide')) {
const name = toolInput.name || toolInput.title || toolInput.filename || toolInput.file_name || '';
const query = toolInput.query || toolInput.q || '';
if (name) return name;
if (query) return `Search: "${query}"`;
}
if (lower.includes('tweet') || lower.includes('post') || lower.includes('send') || lower.includes('reply')) {
const text = toolInput.text || toolInput.content || toolInput.body || toolInput.message || '';
if (text) return text.length > 80 ? text.slice(0, 77) + '...' : text;
}
if (lower.includes('search') || lower.includes('find') || lower.includes('query') || lower.includes('list')) {
const query = toolInput.query || toolInput.q || toolInput.search_query || toolInput.keyword || toolInput.term || '';
if (query) return `"${query}"`;
}
const stringVals: string[] = [];
for (const [key, val] of Object.entries(toolInput)) {
if (key.startsWith('_')) continue;
if (typeof val === 'string' && val.trim()) {
stringVals.push(val.trim());
}
if (stringVals.length >= 2) break;
}
if (stringVals.length > 0) {
const joined = stringVals.join(' — ');
return joined.length > 100 ? joined.slice(0, 97) + '...' : joined;
}
return '';
}
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { useAppSelector } from '@/shared/hooks';
import type { ToolDefinition } from '@/shared/state/toolsSlice';
import {
type ParsedTool, type McpToolMeta, type IntegrationMeta,
} from '../utils';
// ---------------------------------------------------------------------------
// 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"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
const RedditIcon = (
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="12" fill="#FF4500"/>
<path d="M19.5 12c0-.6-.5-1.1-1.1-1.1-.3 0-.6.1-.8.3-1-.7-2.3-1.1-3.7-1.1l.6-3 2.1.5c0 .6.5 1.1 1.1 1.1.6 0 1.1-.5 1.1-1.1 0-.6-.5-1.1-1.1-1.1-.4 0-.8.3-1 .6l-2.3-.5c-.1 0-.2 0-.2.1l-.7 3.3c-1.4 0-2.7.4-3.7 1.1-.2-.2-.5-.3-.8-.3-.6 0-1.1.5-1.1 1.1 0 .4.2.8.6 1-.1.3-.1.6-.1.9 0 2.3 2.6 4.1 5.8 4.1s5.8-1.8 5.8-4.1c0-.3 0-.6-.1-.9.4-.2.6-.6.6-1zm-9.8 1.1c0-.6.5-1.1 1.1-1.1.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1-.6 0-1.1-.5-1.1-1.1zm6.2 2.9c-.8.8-2 .9-2.9.9s-2.1-.1-2.9-.9c-.1-.1-.1-.3 0-.4.1-.1.3-.1.4 0 .6.6 1.6.8 2.5.8s1.9-.2 2.5-.8c.1-.1.3-.1.4 0 .1.1.1.3 0 .4zm-.2-1.8c-.6 0-1.1-.5-1.1-1.1 0-.6.5-1.1 1.1-1.1.6 0 1.1.5 1.1 1.1 0 .6-.5 1.1-1.1 1.1z" fill="#fff"/>
</svg>
);
const INTEGRATION_META: Record<string, IntegrationMeta> = {
'Google Workspace': { label: 'Google Workspace', color: '#4285F4', icon: GoogleIcon },
'xbird': { label: 'X / Twitter', color: '#1DA1F2', icon: <span style={{ fontSize: 14, fontWeight: 700 }}>𝕏</span> },
'Reddit': { label: 'Reddit', color: '#FF4500', icon: RedditIcon },
};
function sanitizeServerName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
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]);
}
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface IntegrationMeta {
label: string;
color: string;
icon: ReactNode;
}
export interface ParsedTool {
isMcp: boolean;
serverSlug: string;
actionName: string;
displayName: string;
}
export interface McpToolMeta {
integration: IntegrationMeta | null;
description: string;
serverLabel: string;
}
// ---------------------------------------------------------------------------
// Parse / sanitize
// ---------------------------------------------------------------------------
export function parseMcpToolName(rawName: string): ParsedTool {
const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/);
if (!m) {
return { isMcp: false, serverSlug: '', actionName: rawName, displayName: rawName };
}
const serverSlug = m[1];
const actionName = m[2];
const displayName = actionName
.replace(/_/g, ' ')
.replace(/\b\w/g, (ch) => ch.toUpperCase());
return { isMcp: true, serverSlug, actionName, displayName };
}
@@ -1,10 +0,0 @@
import type { Toolkit } from '@assistant-ui/react';
import { nativeToolkit } from './native-tools';
import { approvalToolkit } from './approval-tools';
import { customToolkit } from './customToolkit/customToolkit';
export const toolkit = {
...nativeToolkit,
...approvalToolkit,
...customToolkit,
} as Toolkit;
@@ -0,0 +1,11 @@
import type { Toolkit } from '@assistant-ui/react';
import { nativeToolkit } from './nativeToolkit/nativeToolkit';
import { customToolkit } from './customToolkit/customToolkit';
// import { approvalToolkit } from './approval-tools'; // Not needed bc is empty LEGACY had...
// export const approvalToolkit: Partial<Toolkit> = {};
export const toolkit = {
...nativeToolkit,
...{},
...customToolkit,
} as Toolkit;
@@ -12,8 +12,8 @@ import TerminalIcon from '@mui/icons-material/Terminal';
import { AgentSession } from '@/shared/state/agentsSlice';
import { HANDLE_APPROVAL } from '@/shared/backend-bridge/apps/agents';
import { useAppDispatch } from '@/shared/hooks';
import { ToolQuestion } from '@/app/pages/AgentChat/toolkit/approval-question';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approval-utils';
import { ToolQuestion } from '@/app/pages/AgentChat/toolkit/approvalToolkit/components/ToolQuestion/ToolQuestion';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approvalToolkit/utils';
import GoogleServiceIcon from './components/GoogleServiceIcon';
import { summarizeToolInput } from './components/summarizeToolInput';
import { getToolDisplayName } from './components/getToolDisplayName';
@@ -1,4 +1,4 @@
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approval-utils';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approvalToolkit/utils';
export function getToolDisplayName(toolName: string): string {
const mcp = parseMcpToolName(toolName);
@@ -1,4 +1,4 @@
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approval-utils';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approvalToolkit/utils';
export function summarizeToolInput(toolName: string, toolInput: Record<string, any>): string {
const mcp = parseMcpToolName(toolName);
@@ -1,5 +1,5 @@
import { AgentSession } from '@/shared/state/agentsSlice';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approval-utils';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approvalToolkit/utils';
export function formatDuration(createdAt: string, closedAt?: string | null, status?: string): string {
const start = new Date(createdAt).getTime();