mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 12:34:50 +02:00
[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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+90
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -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";
|
||||
|
||||
+3
-3
@@ -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
-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),
|
||||
+3
-3
@@ -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 {
|
||||
+53
@@ -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 '';
|
||||
}
|
||||
+63
@@ -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;
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user