[Haik]: manually removed hella shit that was just not being used at all (the knip linter is clutch)

This commit is contained in:
haikdc
2026-03-31 00:57:33 -07:00
parent 30a0dc5a6f
commit 63af322ab7
97 changed files with 103 additions and 6114 deletions
@@ -5,7 +5,7 @@ import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import RefreshIcon from '@mui/icons-material/Refresh';
import DifferenceIcon from '@mui/icons-material/Difference';
import { CodeDiff } from '@/components/tool-ui/code-diff';
import { CodeDiff } from '@/components/tool-ui/code-diff/code-diff';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
@@ -1,171 +0,0 @@
import React, { useState, useCallback, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import TerminalIcon from '@mui/icons-material/Terminal';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import BlockIcon from '@mui/icons-material/Block';
import SearchIcon from '@mui/icons-material/Search';
import GoogleServiceIcon from '@/app/components/GoogleServiceIcon';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useTermColors, colorizeInput, colorizeOutput } from './toolCallColors';
import { ElapsedTimer } from './ElapsedTimer';
import { BrowserFeedTracker, renderParsedMcpData } from './toolkit/mcp-tools';
import { InvokeAgentBubble, CreateAgentBubble } from './AgentToolBubble';
import {
ToolCallBubbleProps, ensureToolCallKeyframes, getToolData, parseMcpToolName,
getMcpShortAction, getInputSummary, formatInputDisplay, parseToolResult,
getResultSummary, getPromptPrefix, formatElapsed,
isBrowserAgentTool, isInvokeAgentTool, isCreateAgentTool,
} from './toolCallUtils';
export { parseMcpToolName, getMcpShortAction, getResultSummary } from './toolCallUtils';
export type { ToolPair, McpToolInfo } from './toolCallUtils';
const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
({ call, result = null, isPending = false, isStreaming = false, mcpCompact = false, sessionId }) => {
ensureToolCallKeyframes();
const c = useClaudeTokens();
const tc = useTermColors();
const [expanded, setExpanded] = useState(false);
const { toolName, input, isDenied } = getToolData(call);
const mcpInfo = useMemo(() => parseMcpToolName(toolName), [toolName]);
const inputSummary = getInputSummary(toolName, input);
const formattedInput = useMemo(() => formatInputDisplay(toolName, input), [toolName, input]);
const showTimer = isPending && !isDenied && !isStreaming;
const isBrowserAgent = isBrowserAgentTool(toolName);
const browserAgentAutoExpand = isBrowserAgent && isPending && !isStreaming;
const showBody = expanded || isStreaming || browserAgentAutoExpand;
const resultContent = result?.content;
const hasStructuredResult = resultContent && typeof resultContent === 'object' && 'text' in resultContent;
const resultRawText: string = hasStructuredResult ? resultContent.text : typeof resultContent === 'string' ? resultContent : resultContent ? JSON.stringify(resultContent, null, 2) : '';
const resultElapsedMs: number | null = hasStructuredResult ? resultContent.elapsed_ms ?? null : null;
const parsedResult = useMemo(() => (result ? parseToolResult(toolName, resultRawText) : null), [result, toolName, resultRawText]);
const resultSummary = result ? getResultSummary(toolName, resultRawText) : null;
const isError = resultSummary?.startsWith('✗') || (parsedResult?.type === 'bash' && parsedResult.exitCode !== null && parsedResult.exitCode !== 0) || (parsedResult?.type === 'text' && parsedResult.isError);
const toggle = useCallback(() => { if (!isStreaming) setExpanded((v) => !v); }, [isStreaming]);
const accentRgb = c.accent.primary.replace('#', '').match(/.{2}/g)?.map((h) => parseInt(h, 16)).join(', ') || '189, 100, 57';
const promptPrefix = getPromptPrefix(toolName);
const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName;
const serviceLabel = mcpInfo.isMcp && mcpInfo.service ? mcpInfo.service.charAt(0).toUpperCase() + mcpInfo.service.slice(1) : shortAction;
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service ? <GoogleServiceIcon service={mcpInfo.service} size={14} /> : null;
const selectAttrs = { 'data-select-type': 'tool-call' as const, 'data-select-id': call.id, 'data-select-meta': JSON.stringify({ tool: toolName, inputSummary }) };
if (isInvokeAgentTool(toolName)) return <InvokeAgentBubble call={call} result={result} isPending={isPending} isStreaming={isStreaming} sessionId={sessionId} />;
if (isCreateAgentTool(toolName)) return <CreateAgentBubble call={call} result={result} isPending={isPending} isStreaming={isStreaming} sessionId={sessionId} />;
if (mcpCompact && mcpInfo.isMcp) {
return (
<Box {...selectAttrs} sx={{ my: 0 }}>
<Box onClick={toggle} sx={{ display: 'flex', alignItems: showBody ? 'flex-start' : 'center', gap: 0.75, px: 1.5, py: 0.6, cursor: 'pointer', borderBottom: showBody ? `1px solid ${c.border.subtle}` : 'none', '&:hover': { bgcolor: 'rgba(0,0,0,0.02)' } }}>
{ServiceIcon}
<Typography sx={{ color: c.accent.primary, fontSize: '0.78rem', fontWeight: 600, flexShrink: 0 }}>{serviceLabel}</Typography>
{resultSummary && !isError && (
<Typography sx={{ color: c.text.secondary, fontSize: '0.74rem', flex: 1, minWidth: 0, ...(showBody ? { whiteSpace: 'normal', wordBreak: 'break-word' } : { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }) }}>{resultSummary}</Typography>
)}
{!resultSummary && !showTimer && <Box sx={{ flex: 1 }} />}
{showTimer && <><Box sx={{ flex: 1 }} /><ElapsedTimer startTime={call.timestamp} /></>}
{isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}>
<BlockIcon sx={{ fontSize: 12, color: c.status.error }} />
<Typography sx={{ color: c.status.error, fontSize: '0.68rem', fontWeight: 500 }}>denied</Typography>
</Box>
)}
{result && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.4 }}>
{isError ? <ErrorOutlineIcon sx={{ fontSize: 12, color: c.status.error }} /> : <CheckCircleOutlineIcon sx={{ fontSize: 12, color: c.status.success }} />}
{resultElapsedMs != null && <Typography sx={{ fontSize: '0.63rem', fontFamily: c.font.mono, color: c.text.tertiary }}>{formatElapsed(resultElapsedMs)}</Typography>}
</Box>
)}
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15, flexShrink: 0 }}>
{showBody ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
</Box>
<Collapse in={showBody}>
<Box sx={{ bgcolor: tc.TERM_BG, maxHeight: '60vh', overflowY: 'auto', overflowX: 'hidden', '&::-webkit-scrollbar': { width: 5 }, '&::-webkit-scrollbar-track': { background: 'transparent' }, '&::-webkit-scrollbar-thumb': { background: tc.SCROLLBAR_THUMB, borderRadius: 3 } }}>
{isBrowserAgent && sessionId && <BrowserFeedTracker parentSessionId={sessionId} browserId={input?.browser_id} />}
{parsedResult && parsedResult.type === 'mcp' ? (
renderParsedMcpData(parsedResult.service, parsedResult.action, parsedResult.data, call.id)
) : parsedResult ? (
<pre style={{ margin: 0, padding: '8px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: c.font.mono, fontSize: '0.73rem', lineHeight: 1.5, color: tc.OUTPUT_COLOR }}>{parsedResult.type === 'text' ? parsedResult.content : ''}</pre>
) : null}
{!parsedResult && isPending && !isStreaming && !isBrowserAgent && (
<Box sx={{ px: 1.5, py: 1 }}><Box sx={{ width: 8, height: 2, bgcolor: tc.PROMPT_COLOR, animation: 'tool-pulse 1s ease-in-out infinite', borderRadius: 1 }} /></Box>
)}
</Box>
</Collapse>
</Box>
);
}
return (
<Box {...selectAttrs} sx={{ maxWidth: mcpCompact ? '100%' : '85%', my: mcpCompact ? 0 : 0.5 }}>
<Box sx={{ '--glow-rgb': accentRgb, bgcolor: mcpCompact ? 'transparent' : c.bg.elevated, border: mcpCompact ? 'none' : `1px solid ${isPending || isStreaming ? c.accent.primary : isDenied ? c.status.error + '60' : c.border.subtle}`, borderRadius: mcpCompact ? 0 : 2, overflow: 'hidden', animation: (isPending || isStreaming) && !mcpCompact ? 'border-glow 2s ease-in-out infinite' : 'none', transition: 'border-color 0.3s, box-shadow 0.3s' } as any}>
<Box onClick={toggle} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: mcpCompact ? 0.6 : 0.75, cursor: isStreaming ? 'default' : 'pointer', borderBottom: mcpCompact && showBody ? `1px solid ${c.border.subtle}` : 'none', '&:hover': isStreaming ? {} : { bgcolor: 'rgba(0,0,0,0.02)' } }}>
{mcpInfo.isMcp && mcpInfo.service
? <GoogleServiceIcon service={mcpInfo.service} size={mcpCompact ? 14 : 15} />
: (() => { const n = toolName.toLowerCase(); if (n.includes('search') || n === 'grep' || n === 'glob') return <SearchIcon sx={{ fontSize: mcpCompact ? 14 : 15, color: c.accent.primary, flexShrink: 0 }} />; return <TerminalIcon sx={{ fontSize: mcpCompact ? 14 : 15, color: c.accent.primary, flexShrink: 0 }} />; })()}
<Typography sx={{ color: c.accent.primary, fontSize: mcpCompact ? '0.78rem' : '0.8rem', fontWeight: 600, flexShrink: 0 }}>{mcpInfo.isMcp ? mcpInfo.displayName : toolName}</Typography>
{mcpInfo.isMcp && <Typography sx={{ color: c.text.tertiary, fontSize: '0.65rem', opacity: 0.7, flexShrink: 0 }}>{mcpInfo.serverSlug}</Typography>}
{inputSummary && !isStreaming && <Typography noWrap sx={{ color: c.text.tertiary, fontSize: '0.75rem', fontFamily: c.font.mono, flex: 1, minWidth: 0 }}>{inputSummary}</Typography>}
{!inputSummary && <Box sx={{ flex: 1 }} />}
{isStreaming && <Box sx={{ flex: 1 }} />}
{isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3 }}>
<BlockIcon sx={{ fontSize: 13, color: c.status.error }} />
<Typography sx={{ color: c.status.error, fontSize: '0.7rem', fontWeight: 500 }}>denied</Typography>
</Box>
)}
{result && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError ? <ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} /> : <CheckCircleOutlineIcon sx={{ fontSize: 13, color: c.status.success }} />}
<Typography sx={{ color: isError ? c.status.error : c.status.success, fontSize: '0.7rem', fontWeight: 500 }}>{resultSummary}</Typography>
{resultElapsedMs != null && <Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>{formatElapsed(resultElapsedMs)}</Typography>}
</Box>
)}
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
{!isStreaming && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: mcpCompact ? 0.15 : 0.25, flexShrink: 0 }}>
{showBody ? <ExpandLessIcon sx={{ fontSize: mcpCompact ? 16 : 18 }} /> : <ExpandMoreIcon sx={{ fontSize: mcpCompact ? 16 : 18 }} />}
</IconButton>
)}
</Box>
<Collapse in={showBody}>
<Box sx={{ bgcolor: tc.TERM_BG, borderTop: `1px solid ${tc.TERM_BORDER}`, maxHeight: 500, overflow: 'auto', '&::-webkit-scrollbar': { width: 5 }, '&::-webkit-scrollbar-track': { background: 'transparent' }, '&::-webkit-scrollbar-thumb': { background: tc.SCROLLBAR_THUMB, borderRadius: 3 } }}>
<pre style={{ margin: 0, padding: '8px 12px 0', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: c.font.mono, fontSize: '0.73rem', lineHeight: 1.5 }}>
<span style={{ color: tc.PROMPT_COLOR, fontWeight: 600, userSelect: 'none' }}>{promptPrefix}</span>
{isStreaming ? <span style={{ color: tc.CMD_COLOR }}>{call.content?.input ?? ''}</span> : colorizeInput(toolName, formattedInput, tc)}
{isStreaming && <span style={{ display: 'inline-block', width: 2, height: '1em', background: c.accent.primary, marginLeft: 2, verticalAlign: 'text-bottom', animation: 'blink-cursor 0.8s step-end infinite' }} />}
</pre>
{isBrowserAgent && sessionId && <BrowserFeedTracker parentSessionId={sessionId} browserId={input?.browser_id} />}
{parsedResult && parsedResult.type === 'mcp' ? (
renderParsedMcpData(parsedResult.service, parsedResult.action, parsedResult.data, call.id)
) : parsedResult ? (
<pre style={{ margin: 0, padding: '4px 12px 8px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: c.font.mono, fontSize: '0.73rem', lineHeight: 1.5 }}>
{parsedResult.type === 'bash' ? (
<>
{parsedResult.stdout.trim() && colorizeOutput(toolName, parsedResult.stdout, tc)}
{parsedResult.stderr.trim() && <>{parsedResult.stdout.trim() && '\n'}<span style={{ color: tc.STDERR_COLOR }}>{parsedResult.stderr}</span></>}
{!parsedResult.stdout.trim() && !parsedResult.stderr.trim() && <span style={{ color: tc.DIM_COLOR, fontStyle: 'italic' }}>(no output)</span>}
</>
) : (
<>{parsedResult.isError ? <span style={{ color: tc.STDERR_COLOR }}>{parsedResult.content || '(empty)'}</span> : colorizeOutput(toolName, parsedResult.content, tc)}</>
)}
</pre>
) : null}
{!parsedResult && isPending && !isStreaming && !isBrowserAgent && (
<Box sx={{ px: 1.5, pb: 1, pt: 0.5 }}><Box sx={{ width: 8, height: 2, bgcolor: tc.PROMPT_COLOR, animation: 'tool-pulse 1s ease-in-out infinite', borderRadius: 1 }} /></Box>
)}
</Box>
</Collapse>
</Box>
</Box>
);
}
);
export default ToolCallBubble;
@@ -1,211 +0,0 @@
import React from 'react';
import { useThemeMode } from '@/shared/styles/ThemeContext';
import { parseMcpToolName, isBashTool } from './toolCallUtils';
export interface TermColors {
TERM_BG: string; TERM_BORDER: string; PROMPT_COLOR: string; CMD_COLOR: string;
OUTPUT_COLOR: string; PATH_COLOR: string; ADD_COLOR: string; DEL_COLOR: string;
STDERR_COLOR: string; WARN_COLOR: string; NUM_COLOR: string; DIM_COLOR: string;
DIFF_HEADER_COLOR: string; SCROLLBAR_THUMB: string;
}
const darkTermColors: TermColors = {
TERM_BG: '#131520', TERM_BORDER: '#1e2030', PROMPT_COLOR: '#7ec699', CMD_COLOR: '#e8ecf4',
OUTPUT_COLOR: '#a0aab8', PATH_COLOR: '#82aaff', ADD_COLOR: '#7ec699', DEL_COLOR: '#ff8787',
STDERR_COLOR: '#ff8787', WARN_COLOR: '#ffcb6b', NUM_COLOR: '#f78c6c', DIM_COLOR: '#555b6e',
DIFF_HEADER_COLOR: '#c792ea', SCROLLBAR_THUMB: '#2a2d3e',
};
const lightTermColors: TermColors = {
TERM_BG: '#f4f3ee', TERM_BORDER: '#e2e0d8', PROMPT_COLOR: '#2d7a3e', CMD_COLOR: '#2a2a28',
OUTPUT_COLOR: '#555550', PATH_COLOR: '#3060a8', ADD_COLOR: '#2d7a3e', DEL_COLOR: '#c03030',
STDERR_COLOR: '#c03030', WARN_COLOR: '#8a6518', NUM_COLOR: '#c05020', DIM_COLOR: '#9e9c95',
DIFF_HEADER_COLOR: '#7c4daa', SCROLLBAR_THUMB: '#ccc9c0',
};
export function useTermColors(): TermColors {
const { mode } = useThemeMode();
return mode === 'dark' ? darkTermColors : lightTermColors;
}
export interface CardColors {
TC_BG: string; TC_BORDER: string; TC_HOVER: string; TC_HEADING: string; TC_BODY: string;
TC_MUTED: string; TC_DIM: string; TC_ACCENT: string; TC_SUCCESS: string; TC_WARNING: string;
}
const darkCardColors: CardColors = {
TC_BG: 'rgba(255,255,255,0.03)', TC_BORDER: 'rgba(255,255,255,0.06)',
TC_HOVER: 'rgba(255,255,255,0.05)', TC_HEADING: '#C2C0B6', TC_BODY: '#9C9A92',
TC_MUTED: '#85837C', TC_DIM: 'rgba(156,154,146,0.5)',
TC_ACCENT: '#c4633a', TC_SUCCESS: '#7AB948', TC_WARNING: '#D1A041',
};
const lightCardColors: CardColors = {
TC_BG: 'rgba(0,0,0,0.03)', TC_BORDER: 'rgba(0,0,0,0.08)',
TC_HOVER: 'rgba(0,0,0,0.05)', TC_HEADING: '#3D3D3A', TC_BODY: '#555550',
TC_MUTED: '#73726C', TC_DIM: 'rgba(115,114,108,0.5)',
TC_ACCENT: '#ae5630', TC_SUCCESS: '#265B19', TC_WARNING: '#805C1F',
};
export function useCardColors(): CardColors {
const { mode } = useThemeMode();
return mode === 'dark' ? darkCardColors : lightCardColors;
}
export function colorizeInput(toolName: string, text: string, tc: TermColors): React.ReactNode {
const n = toolName.toLowerCase();
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) {
const lines = text.split('\n');
return (
<>
{lines.map((line, i) => {
const nl = i < lines.length - 1 ? '\n' : '';
const colonIdx = line.indexOf(':');
if (colonIdx > 0 && colonIdx < 30) {
return (
<span key={i}>
<span style={{ color: tc.DIM_COLOR }}>{line.slice(0, colonIdx + 1)}</span>
<span style={{ color: tc.CMD_COLOR }}>{line.slice(colonIdx + 1)}</span>
{nl}
</span>
);
}
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
if (isBashTool(toolName)) return <span style={{ color: tc.CMD_COLOR }}>{text}</span>;
if (n === 'edit' || n === 'strreplace' || n === 'multiedit') {
const lines = text.split('\n');
return (
<>
{lines.map((line, i) => {
const nl = i < lines.length - 1 ? '\n' : '';
if (i === 0 && (line.startsWith('/') || line.includes('.')))
return <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
if (line.startsWith('+ '))
return <span key={i} style={{ color: tc.ADD_COLOR }}>{line}{nl}</span>;
if (line.startsWith('- '))
return <span key={i} style={{ color: tc.DEL_COLOR }}>{line}{nl}</span>;
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
if (n === 'write') {
const lines = text.split('\n');
return (
<>
{lines.map((line, i) => {
const nl = i < lines.length - 1 ? '\n' : '';
if (i === 0 && (line.startsWith('/') || line.includes('.')))
return <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
return <span key={i} style={{ color: tc.CMD_COLOR, opacity: 0.7 }}>{line}{nl}</span>;
})}
</>
);
}
if (n === 'read' || n === 'glob' || n === 'webfetch') {
if (/^\//.test(text) || text.includes('/'))
return <span style={{ color: tc.PATH_COLOR }}>{text}</span>;
}
if (n === 'grep' || n === 'ripgrep') {
const lines = text.split('\n');
return (
<>
{lines.map((line, i) => {
const nl = i < lines.length - 1 ? '\n' : '';
if (line.startsWith('pattern:'))
return (
<span key={i}>
<span style={{ color: tc.DIM_COLOR }}>pattern: </span>
<span style={{ color: tc.WARN_COLOR }}>{line.slice(9)}</span>
{nl}
</span>
);
if (line.startsWith('path:'))
return (
<span key={i}>
<span style={{ color: tc.DIM_COLOR }}>path: </span>
<span style={{ color: tc.PATH_COLOR }}>{line.slice(6)}</span>
{nl}
</span>
);
return <span key={i} style={{ color: tc.CMD_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
return <span style={{ color: tc.CMD_COLOR }}>{text}</span>;
}
export function colorizeOutput(toolName: string, text: string, tc: TermColors): React.ReactNode {
if (!text) return <span style={{ color: tc.DIM_COLOR, fontStyle: 'italic' }}>(empty)</span>;
const lines = text.split('\n');
const n = toolName.toLowerCase();
return (
<>
{lines.map((line, i) => {
const nl = i < lines.length - 1 ? '\n' : '';
const trimmed = line.trimStart();
if (/^\/\S+/.test(trimmed))
return <span key={i} style={{ color: tc.PATH_COLOR }}>{line}{nl}</span>;
if (n === 'grep' || n === 'ripgrep') {
const grepMatch = line.match(/^(\S+?:\d+[:-])/);
if (grepMatch) {
return (
<span key={i}>
<span style={{ color: tc.PATH_COLOR }}>{grepMatch[1]}</span>
<span style={{ color: tc.OUTPUT_COLOR }}>{line.slice(grepMatch[1].length)}</span>
{nl}
</span>
);
}
const fileHeader = line.match(/^(\S+\.\w+)$/);
if (fileHeader)
return <span key={i} style={{ color: tc.PATH_COLOR, fontWeight: 600 }}>{line}{nl}</span>;
}
if (line.startsWith('@@') && line.includes('@@'))
return <span key={i} style={{ color: tc.DIFF_HEADER_COLOR }}>{line}{nl}</span>;
if (line.startsWith('+'))
return <span key={i} style={{ color: tc.ADD_COLOR }}>{line}{nl}</span>;
if (line.startsWith('-'))
return <span key={i} style={{ color: tc.DEL_COLOR }}>{line}{nl}</span>;
if (/\b[Ee]rror\b/.test(line))
return <span key={i} style={{ color: tc.STDERR_COLOR }}>{line}{nl}</span>;
if (/\b[Ww]arning\b/.test(line))
return <span key={i} style={{ color: tc.WARN_COLOR }}>{line}{nl}</span>;
if (n === 'read') {
const lineNumMatch = line.match(/^(\s*\d+\s*[|:])/);
if (lineNumMatch) {
return (
<span key={i}>
<span style={{ color: tc.NUM_COLOR, opacity: 0.6 }}>{lineNumMatch[1]}</span>
<span style={{ color: tc.OUTPUT_COLOR }}>{line.slice(lineNumMatch[1].length)}</span>
{nl}
</span>
);
}
}
return <span key={i} style={{ color: tc.OUTPUT_COLOR }}>{line}{nl}</span>;
})}
</>
);
}
+19 -111
View File
@@ -1,16 +1,18 @@
import { AgentMessage } from '@/shared/state/agentsSlice';
export interface ToolPair { type: 'tool_pair'; id: string; call: AgentMessage; result: AgentMessage | null; }
export interface McpToolInfo { isMcp: boolean; serverSlug: string; action: string; service: string; displayName: string; }
export interface ParsedBashResult { type: 'bash'; stdout: string; stderr: string; exitCode: number | null; }
export interface ParsedTextResult { type: 'text'; content: string; isError?: boolean; }
export interface ParsedMcpResult { type: 'mcp'; service: string; action: string; data: Record<string, any>; rawText: string; }
export type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult;
export interface ToolCallBubbleProps {
call: AgentMessage; result?: AgentMessage | null; isPending?: boolean;
isStreaming?: boolean; mcpCompact?: boolean; sessionId?: string;
}
export interface InvokeAgentParsed { agentName: string; sessionId: string | null; cost: string | null; response: string; }
interface McpToolInfo { isMcp: boolean; serverSlug: string; action: string; service: string; displayName: string; }
interface ParsedBashResult { type: 'bash'; stdout: string; stderr: string; exitCode: number | null; }
interface ParsedTextResult { type: 'text'; content: string; isError?: boolean; }
interface ParsedMcpResult { type: 'mcp'; service: string; action: string; data: Record<string, any>; rawText: string; }
type ParsedResult = ParsedBashResult | ParsedTextResult | ParsedMcpResult;
interface InvokeAgentParsed { agentName: string; sessionId: string | null; cost: string | null; response: string; }
let toolCallKeyframesInjected = false;
export function ensureToolCallKeyframes() {
@@ -32,9 +34,9 @@ export function getToolData(call: AgentMessage) {
const content = typeof call.content === 'object' ? call.content : {};
return { toolName: content.tool || 'Unknown', input: content.input || {}, isDenied: content.approved === false, toolId: content.id };
}
export function isBashTool(name: string) { return name === 'Bash' || name === 'bash'; }
function isBashTool(name: string) { return name === 'Bash' || name === 'bash'; }
export function parseMcpToolName(rawName: string): McpToolInfo {
function parseMcpToolName(rawName: string): McpToolInfo {
const m = rawName.match(/^mcp__([^_]+(?:-[^_]+)*)__(.+)$/);
if (!m) return { isMcp: false, serverSlug: '', action: '', service: '', displayName: rawName };
const serverSlug = m[1], action = m[2];
@@ -50,77 +52,6 @@ export function parseMcpToolName(rawName: string): McpToolInfo {
return { isMcp: true, serverSlug, action, service, displayName: display };
}
export function getInputSummary(toolName: string, input: any): string {
try {
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) {
if (!input || typeof input !== 'object') return '';
const keys = Object.keys(input);
if (keys.length === 0) return '';
if (keys.length === 1) { const v = input[keys[0]], s = typeof v === 'string' ? v : JSON.stringify(v); return s.length > 60 ? s.slice(0, 60) + '…' : s; }
return keys.slice(0, 3).map((k) => { const v = input[k], s = typeof v === 'string' ? v : JSON.stringify(v); return `${k}: ${s.length > 30 ? s.slice(0, 30) + '…' : s}`; }).join(' ');
}
const n = toolName.toLowerCase();
if (isBashTool(toolName)) { const cmd = input.command || ''; return `$ ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}`; }
if (n === 'read' || n === 'write') return input.file_path || input.path || '';
if (n === 'edit' || n === 'multiedit' || n === 'strreplace') return input.file_path || input.path || '';
if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || '';
if (n === 'grep' || n === 'ripgrep') {
const pat = input.pattern || input.regex || '', path = input.path || input.directory || '';
return path ? `/${pat}/ in ${path}` : `/${pat}/`;
}
if (n === 'websearch') return input.query || input.search_term || '';
if (n === 'webfetch') return input.url || '';
if (n === 'todoread' || n === 'todowrite') return 'todos';
if (n === 'ls') return input.path || '.';
return '';
} catch { return ''; }
}
export function formatInputDisplay(toolName: string, input: any): string {
try {
const mcp = parseMcpToolName(toolName);
if (mcp.isMcp) {
if (!input || typeof input !== 'object') return String(input ?? '');
return Object.entries(input).map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v, null, 2)}`).join('\n');
}
const n = toolName.toLowerCase();
if (isBashTool(toolName)) return input.command || '';
if (n === 'read') {
const p = input.file_path || input.path || '', parts = [p];
if (input.offset) parts.push(`offset: ${input.offset}`);
if (input.limit) parts.push(`limit: ${input.limit}`);
return parts.join(' ');
}
if (n === 'write') {
const p = input.file_path || input.path || '', c = input.content || '';
return `${p}\n\n${c.length > 300 ? c.slice(0, 300) + '\n…' : c}`;
}
if (n === 'edit' || n === 'strreplace') {
const p = input.file_path || input.path || '', old = input.old_string || input.old_text || '', nw = input.new_string || input.new_text || '';
const lines = [p, ''];
if (old) { const o = old.length > 200 ? old.slice(0, 200) + '…' : old; lines.push(`- ${o.split('\n').join('\n- ')}`); }
if (nw) { const n2 = nw.length > 200 ? nw.slice(0, 200) + '…' : nw; lines.push(`+ ${n2.split('\n').join('\n+ ')}`); }
return lines.join('\n');
}
if (n === 'multiedit') {
const edits = input.edits || [], lines = [input.file_path || input.path || ''];
for (const e of edits.slice(0, 3)) lines.push(` - ${(e.old_string || e.old_text || '').split('\n')[0].slice(0, 60)}`);
if (edits.length > 3) lines.push(` … +${edits.length - 3} more edits`);
return lines.join('\n');
}
if (n === 'glob') return input.pattern || input.glob || input.glob_pattern || '';
if (n === 'grep' || n === 'ripgrep') {
const pat = input.pattern || input.regex || '', path = input.path || input.directory || '';
const parts = [`pattern: ${pat}`];
if (path) parts.push(`path: ${path}`);
if (input.include) parts.push(`include: ${input.include}`);
return parts.join('\n');
}
if (n === 'websearch') return input.query || input.search_term || '';
if (n === 'webfetch') return input.url || '';
} catch {}
return typeof input === 'string' ? input : JSON.stringify(input, null, 2);
}
export function parseToolResult(toolName: string, rawText: string): ParsedResult {
if (isBashTool(toolName)) {
try {
@@ -155,13 +86,14 @@ export function parseToolResult(toolName: string, rawText: string): ParsedResult
} catch {}
return { type: 'text', content: rawText };
}
export function getMcpShortAction(mcpInfo: McpToolInfo): string {
let short = mcpInfo.action;
if (mcpInfo.service && short.toLowerCase().startsWith(mcpInfo.service.toLowerCase() + '_')) short = short.slice(mcpInfo.service.length + 1);
return short.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase());
interface GmailHeaderSource {
payload?: { headers?: Array<{ name: string; value: string }> };
headers?: Record<string, string>;
[key: string]: unknown;
}
export function getGmailHeader(msg: any, name: string): string {
if (msg.payload?.headers && Array.isArray(msg.payload.headers)) { const h = msg.payload.headers.find((hdr: any) => (hdr.name || '').toLowerCase() === name.toLowerCase()); if (h) return h.value || ''; }
function getGmailHeader(msg: GmailHeaderSource, name: string): string {
if (msg.payload?.headers && Array.isArray(msg.payload.headers)) { const h = msg.payload.headers.find((hdr) => (hdr.name || '').toLowerCase() === name.toLowerCase()); if (h) return h.value || ''; }
if (msg.headers && typeof msg.headers === 'object' && !Array.isArray(msg.headers)) return msg.headers[name] || msg.headers[name.toLowerCase()] || '';
return '';
}
@@ -194,31 +126,7 @@ export function getResultSummary(toolName: string, rawText: string): string {
} catch {}
return `${lc} line${lc !== 1 ? 's' : ''}`;
}
export function getPromptPrefix(toolName: string): string {
if (isBashTool(toolName)) return '$ ';
const mcp = parseMcpToolName(toolName);
return mcp.isMcp ? ` ${mcp.displayName} ` : ` ${toolName} `;
}
export function formatTimestamp(ts: string | number | undefined): string {
if (!ts) return '';
try {
const d = typeof ts === 'number' ? new Date(ts) : new Date(ts);
if (isNaN(d.getTime())) return String(ts);
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
} catch { return String(ts); }
}
export function stripHtml(html: string): string {
const tmp = document.createElement('div'); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || '';
}
export function isBrowserAgentTool(name: string): boolean {
if (name === 'CreateBrowserAgent' || name === 'BrowserAgent' || name === 'BrowserAgents') return true;
const m = parseMcpToolName(name); return m.isMcp && m.serverSlug === 'openswarm-browser-agent';
}
export function isInvokeAgentTool(name: string): boolean {
if (name === 'InvokeAgent') return true;
const m = parseMcpToolName(name); return m.isMcp && m.serverSlug === 'openswarm-invoke-agent';
}
export function isCreateAgentTool(name: string): boolean { return name === 'Agent'; }
export function parseInvokedSessionId(rawText: string): string | null { return rawText.match(/\(forked session:\s*([a-f0-9]+)\)/)?.[1] || null; }
export function parseCreateAgentResult(rawText: string): string {
if (!rawText) return '';
@@ -1,7 +1,7 @@
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 { 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 type { ApprovalRequest } from '@/shared/state/agentsSlice';
function optionKey(opt: any): string {
@@ -71,7 +71,7 @@ const FreeTextQuestion: React.FC<{
// ToolQuestion
// ---------------------------------------------------------------------------
export interface ToolQuestionProps {
interface ToolQuestionProps {
request: ApprovalRequest;
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
@@ -1,6 +1,6 @@
import React, { useMemo, useCallback } from 'react';
import type { Toolkit } from '@assistant-ui/react';
import { ApprovalCard } from '@/components/tool-ui/approval-card';
import { ApprovalCard } from '@/components/tool-ui/approval-card/approval-card';
import type { ApprovalRequest } from '@/shared/state/agentsSlice';
import { useAppSelector } from '@/shared/hooks';
import type { ToolDefinition } from '@/shared/state/toolsSlice';
@@ -14,15 +14,10 @@ import { ToolQuestion } from './approval-question';
// Re-exports so external consumers can import everything from this file
export {
parseMcpToolName, sanitizeServerName, getMcpInputSummary,
getToolIcon, getToolIconName, buildMetadata, isDangerous,
INTEGRATION_META,
} from './approval-utils';
export type {
IntegrationMeta, ParsedTool, McpToolMeta,
parseMcpToolName,
getToolIcon,
} from './approval-utils';
export { ToolQuestion } from './approval-question';
export type { ToolQuestionProps } from './approval-question';
// ---------------------------------------------------------------------------
// useMcpToolMeta (React hook — lives here alongside other component code)
@@ -57,13 +52,13 @@ export function useMcpToolMeta(parsed: ParsedTool): McpToolMeta {
// ToolApproval
// ---------------------------------------------------------------------------
export interface ToolApprovalProps {
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 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
@@ -90,7 +85,7 @@ export const ToolApproval: React.FC<ToolApprovalProps> = ({ request, onApprove,
// BatchApprovalWrapper
// ---------------------------------------------------------------------------
export interface BatchApprovalWrapperProps {
interface BatchApprovalWrapperProps {
requests: ApprovalRequest[];
onApprove: (requestId: string, updatedInput?: Record<string, any>) => void;
onDeny: (requestId: string, message?: string) => void;
@@ -1,12 +1,10 @@
import type { Toolkit } from '@assistant-ui/react';
import { nativeToolkit } from './native-tools';
import { approvalToolkit } from './approval-tools';
import { mcpToolkit } from './mcp-tools';
import { customToolkit } from './custom-tools';
export const toolkit: Toolkit = {
export const toolkit = {
...nativeToolkit,
...approvalToolkit,
...mcpToolkit,
...customToolkit,
};
} as Toolkit;
@@ -1,173 +0,0 @@
import React, { useMemo, useEffect, useRef } from 'react';
import { createSelector } from '@reduxjs/toolkit';
import { ProgressTracker } from '@/components/tool-ui/progress-tracker';
import type { ProgressStep } from '@/components/tool-ui/progress-tracker';
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
import type { AgentSession, AgentMessage } from '@/shared/state/agentsSlice';
import { fetchBrowserAgentChildren } from '@/shared/state/agentsSlice';
import type { RootState } from '@/shared/state/store';
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
const selectBrowserSessions = createSelector(
[
(state: RootState) => state.agents.sessions,
(_: RootState, parentSessionId: string) => parentSessionId,
(_: RootState, __: string, browserId?: string) => browserId,
],
(sessions, parentSessionId, browserId) =>
Object.values(sessions).filter(
(s): s is AgentSession =>
s.mode === 'browser-agent' &&
s.parent_session_id === parentSessionId &&
(!browserId || s.browser_id === browserId),
),
);
// ---------------------------------------------------------------------------
// Message → step conversion
// ---------------------------------------------------------------------------
function formatBrowserAction(content: any): { label: string; description: string } {
const tool = content?.tool || content?.name || '?';
const input = content?.input || {};
switch (tool) {
case 'BrowserNavigate':
return { label: 'Navigate', description: input.url || '...' };
case 'BrowserClick':
return { label: 'Click', description: input.selector || '...' };
case 'BrowserType': {
const txt = (input.text || '').slice(0, 40);
const ellipsis = (input.text || '').length > 40 ? '…' : '';
return { label: 'Type', description: `"${txt}${ellipsis}" → ${input.selector || '...'}` };
}
case 'BrowserScreenshot':
return { label: 'Screenshot', description: 'Capture page' };
case 'BrowserGetText':
return { label: 'Read text', description: 'Get page content' };
case 'BrowserGetElements':
return { label: 'Inspect', description: input.selector ? `Elements (${input.selector})` : 'Elements' };
case 'BrowserEvaluate':
return { label: 'Execute JS', description: 'Run script' };
default:
return { label: tool, description: JSON.stringify(input).slice(0, 60) };
}
}
function messagesToSteps(messages: AgentMessage[]): ProgressStep[] {
const steps: ProgressStep[] = [];
const pendingCalls = new Map<string, number>();
for (const msg of messages) {
if (msg.role === 'tool_call') {
const content =
typeof msg.content === 'string'
? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })()
: msg.content;
const { label, description } = formatBrowserAction(content);
const stepId = content?.id || `step-${steps.length}`;
steps.push({ id: stepId, label, description, status: 'in-progress' });
if (content?.id) pendingCalls.set(content.id, steps.length - 1);
} else if (msg.role === 'tool_result') {
const content =
typeof msg.content === 'string'
? (() => { try { return JSON.parse(msg.content); } catch { return { text: msg.content }; } })()
: msg.content;
const callId = content?.tool_call_id || content?.id;
if (callId && pendingCalls.has(callId)) {
const idx = pendingCalls.get(callId)!;
steps[idx] = {
...steps[idx],
status: content?.is_error || content?.error ? 'failed' : 'completed',
};
pendingCalls.delete(callId);
} else {
const lastIdx = [...pendingCalls.values()].pop();
if (lastIdx !== undefined) {
steps[lastIdx] = {
...steps[lastIdx],
status: content?.is_error || content?.error ? 'failed' : 'completed',
};
for (const [k, v] of pendingCalls) {
if (v === lastIdx) { pendingCalls.delete(k); break; }
}
}
}
}
}
return steps;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface BrowserFeedTrackerProps {
parentSessionId: string;
browserId?: string;
}
export const BrowserFeedTracker: React.FC<BrowserFeedTrackerProps> = ({
parentSessionId,
browserId,
}) => {
const dispatch = useAppDispatch();
const fetchedRef = useRef<string | null>(null);
const browserSessions = useAppSelector((state) =>
selectBrowserSessions(state, parentSessionId, browserId),
);
useEffect(() => {
if (browserSessions.length === 0 && fetchedRef.current !== parentSessionId) {
fetchedRef.current = parentSessionId;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedRef.current = null; });
}
}, [browserSessions.length, parentSessionId, dispatch]);
const allSteps = useMemo(() => {
const raw: ProgressStep[] = [];
for (const session of browserSessions) {
raw.push(...messagesToSteps(session.messages));
}
if (raw.length === 0) return raw;
const seen = new Set<string>();
return raw.map((step, i) => {
let id = step.id;
if (seen.has(id)) id = `${id}-${i}`;
seen.add(id);
return { ...step, id };
});
}, [browserSessions]);
if (browserSessions.length === 0 || allSteps.length === 0) return null;
const allDone = allSteps.every((s) => s.status === 'completed' || s.status === 'failed');
const hasFailed = allSteps.some((s) => s.status === 'failed');
return (
<ProgressTracker
id={`browser-feed-${parentSessionId}`}
steps={allSteps}
choice={
allDone
? {
outcome: hasFailed ? 'partial' as const : 'success' as const,
summary: hasFailed ? 'Completed with errors' : 'All steps completed',
at: new Date().toISOString(),
}
: undefined
}
/>
);
};
@@ -1,177 +0,0 @@
import type { ReactNode } from 'react';
import type { Toolkit } from '@assistant-ui/react';
import { MessageDraft } from '@/components/tool-ui/message-draft';
import { DataTable } from '@/components/tool-ui/data-table/data-table';
import {
getGmailHeader, formatTimestamp, stripHtml,
} from '../toolCallUtils';
export { BrowserFeedTracker } from './mcp-browser-feed';
// -- Helpers ----------------------------------------------------------------
function extractEmailFields(msg: any) {
const subject = msg.subject || getGmailHeader(msg, 'Subject') || '(no subject)';
const from = msg.from || msg.sender || getGmailHeader(msg, 'From') || '';
const to = msg.to || msg.recipient || getGmailHeader(msg, 'To') || '';
const rawDate = msg.date || msg.internalDate || msg.receivedAt || getGmailHeader(msg, 'Date') || '';
const date = formatTimestamp(rawDate);
const snippet = msg.snippet || '';
const body = msg.body || msg.text || msg.textBody || '';
const htmlBody = msg.htmlBody || msg.html || '';
const bodyPreview = body || (htmlBody ? stripHtml(htmlBody) : '');
return { subject, from, to, date, snippet, bodyPreview };
}
// -- Gmail → MessageDraft / DataTable ---------------------------------------
function renderGmailSingle(data: Record<string, any>, toolCallId: string): ReactNode {
const email = extractEmailFields(data);
const raw = email.to || '';
const toArray = typeof raw === 'string'
? raw.split(',').map((s: string) => s.trim()).filter(Boolean)
: Array.isArray(raw) ? raw : [];
return (
<MessageDraft
id={`gmail-${toolCallId}`}
channel="email"
subject={email.subject || '(no subject)'}
from={email.from || undefined}
to={toArray.length > 0 ? toArray : ['(unknown)']}
body={email.bodyPreview || email.snippet || '(empty)'}
outcome="sent"
/>
);
}
function renderGmailList(messages: any[], toolCallId: string): ReactNode {
return (
<DataTable
id={`gmail-list-${toolCallId}`}
rowIdKey="id"
columns={[
{ key: 'from', label: 'From', priority: 'primary' as const },
{ key: 'subject', label: 'Subject' },
{ key: 'date', label: 'Date', format: { kind: 'date' as const, dateFormat: 'relative' as const } },
{ key: 'snippet', label: 'Preview', truncate: true },
]}
data={messages.map((msg, i) => {
const f = extractEmailFields(msg);
return {
id: String(i),
from: f.from,
subject: f.subject,
date: f.date,
snippet: (f.snippet || f.bodyPreview || '').slice(0, 120),
};
})}
/>
);
}
function renderGmailResult(data: Record<string, any>, action: string, toolCallId: string): ReactNode {
const isSearch = action.includes('search') || action.includes('list');
const messages: any[] = data.messages || (isSearch && data.results ? data.results : []);
if (messages.length > 0) return renderGmailList(messages, toolCallId);
return renderGmailSingle(data, toolCallId);
}
// -- Calendar → DataTable ---------------------------------------------------
function renderCalendarResult(data: Record<string, any>, toolCallId: string): ReactNode {
const items: any[] = data.items || (Array.isArray(data) ? data : []);
const single = !items.length && (data.summary || data.start) ? data : null;
const rows = single ? [single] : items;
if (rows.length === 0) return null;
return (
<DataTable
id={`calendar-${toolCallId}`}
rowIdKey="id"
columns={[
{ key: 'summary', label: 'Event', priority: 'primary' as const },
{ key: 'start', label: 'Start', format: { kind: 'date' as const, dateFormat: 'short' as const } },
{ key: 'end', label: 'End', format: { kind: 'date' as const, dateFormat: 'short' as const } },
{ key: 'location', label: 'Location' },
]}
data={rows.map((item, i) => ({
id: String(i),
summary: item.summary || '(no title)',
start: item.start?.dateTime || item.start?.date || item.start || '',
end: item.end?.dateTime || item.end?.date || item.end || '',
location: item.location || '',
}))}
/>
);
}
// -- Drive → DataTable ------------------------------------------------------
function renderDriveResult(data: Record<string, any>, toolCallId: string): ReactNode {
const files: any[] = data.files || (Array.isArray(data) ? data : []);
const single = !files.length && data.name ? data : null;
const rows = single ? [single] : files;
if (rows.length === 0) return null;
return (
<DataTable
id={`drive-${toolCallId}`}
rowIdKey="id"
columns={[
{ key: 'name', label: 'File', priority: 'primary' as const },
{ key: 'mimeType', label: 'Type' },
]}
data={rows.map((f, i) => ({
id: String(i),
name: f.name || f.id || '',
mimeType: f.mimeType?.split('/').pop() || '',
}))}
/>
);
}
// -- Generic MCP fallback → DataTable (key / value) -------------------------
function renderGenericMcp(data: Record<string, any>, toolCallId: string): ReactNode {
const entries = Object.entries(data).filter(([, v]) => v != null);
if (entries.length === 0) return null;
return (
<DataTable
id={`mcp-generic-${toolCallId}`}
rowIdKey="id"
columns={[
{ key: 'field', label: 'Field', priority: 'primary' as const },
{ key: 'value', label: 'Value' },
]}
data={entries.slice(0, 20).map(([key, val], i) => ({
id: String(i),
field: key,
value: typeof val === 'object'
? JSON.stringify(val, null, 2).slice(0, 500)
: String(val).slice(0, 500),
}))}
/>
);
}
// -- MCP result dispatch ----------------------------------------------------
/** Render already-parsed MCP data (used by ToolCallBubble). */
export function renderParsedMcpData(
service: string, action: string, data: Record<string, any>, toolCallId: string,
): ReactNode | null {
if (data.error || data.is_error) return null;
switch (service) {
case 'gmail': return renderGmailResult(data, action, toolCallId);
case 'calendar': return renderCalendarResult(data, toolCallId);
case 'drive': case 'sheets': return renderDriveResult(data, toolCallId);
default:
return Object.keys(data).length > 0 ? renderGenericMcp(data, toolCallId) : null;
}
}
// -- Exported toolkit (empty — MCP tool names are dynamic; Agent 7 wires) ---
export const mcpToolkit: Partial<Toolkit> = {};
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
import type { Toolkit } from '@assistant-ui/react';
import { Terminal } from '@/components/tool-ui/terminal';
import { CodeBlock } from '@/components/tool-ui/code-block';
import { CodeDiff } from '@/components/tool-ui/code-diff';
import { Terminal } from '@/components/tool-ui/terminal/terminal';
import { CodeBlock } from '@/components/tool-ui/code-block/code-block';
import { CodeDiff } from '@/components/tool-ui/code-diff/code-diff';
// -- Helpers ----------------------------------------------------------------
@@ -107,7 +107,7 @@ const readRenderer = be(({ args: raw, result, status, toolCallId }) => {
// -- Write → CodeBlock (shows written content from args) --------------------
const writeRenderer = be(({ args: raw, result, status, toolCallId }) => {
const writeRenderer = be(({ args: raw, toolCallId }) => {
const a = toArgs(raw);
const filePath = s(a, 'file_path', 'path');
return (
@@ -121,7 +121,7 @@ const writeRenderer = be(({ args: raw, result, status, toolCallId }) => {
// -- Edit / StrReplace → CodeDiff -------------------------------------------
const editRenderer = be(({ args: raw, result, status, toolCallId }) => {
const editRenderer = be(({ args: raw, result, toolCallId }) => {
const a = toArgs(raw);
const filePath = s(a, 'file_path', 'path');
const oldCode = s(a, 'old_string', 'old_text');
@@ -144,7 +144,7 @@ const editRenderer = be(({ args: raw, result, status, toolCallId }) => {
// -- MultiEdit → CodeDiff (first edit) --------------------------------------
const multiEditRenderer = be(({ args: raw, result, status, toolCallId }) => {
const multiEditRenderer = be(({ args: raw, result, toolCallId }) => {
const a = toArgs(raw);
const filePath = s(a, 'file_path', 'path');
const edits = Array.isArray(a.edits) ? a.edits : [];
@@ -1,77 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { PixelChartProps } from './pixelChartTypes';
import { usePixelChart } from './usePixelChart';
const PixelChart: React.FC<PixelChartProps> = ({
data,
palette = 'salmon',
height = 140,
pixelSize = 6,
formatValue,
glow = true,
showXLabels = true,
showYScale = true,
mode = 'bar',
}) => {
const {
canvasRef, containerRef, tooltipRef,
xLabels, Y_LABEL_WIDTH,
handleMouseMove, handleMouseLeave, c,
} = usePixelChart({ data, palette, height, pixelSize, formatValue, glow, showYScale, mode });
return (
<Box ref={containerRef} sx={{ position: 'relative', width: '100%' }}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }}
/>
{showXLabels && data.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5, pl: `${Y_LABEL_WIDTH}px` }}>
{xLabels.map((xl) => (
<Typography
key={xl.idx}
sx={{
color: c.text.ghost,
fontSize: '0.58rem',
fontFamily: c.font.mono,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 60,
}}
>
{xl.label}
</Typography>
))}
</Box>
)}
<Box
ref={tooltipRef}
sx={{
position: 'absolute',
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 0.12s',
bgcolor: c.bg.inverse,
color: c.text.inverse,
fontSize: '0.7rem',
fontFamily: c.font.mono,
fontWeight: 500,
px: 1,
py: 0.35,
borderRadius: 0.75,
whiteSpace: 'nowrap',
transform: 'translateX(-50%)',
zIndex: 10,
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
/>
</Box>
);
};
export default PixelChart;
@@ -1,187 +0,0 @@
import { ChartDrawParams } from './pixelChartTypes';
export function drawYAxis(
ctx: CanvasRenderingContext2D,
yTicks: number[],
effectiveMax: number,
h: number,
px: number,
yLabelWidth: number,
totalW: number,
formatValue: ((v: number) => string) | undefined,
borderSubtle: string,
textGhost: string,
) {
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const tick of yTicks) {
const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0;
const yPx = h - yNorm * (h - px);
ctx.strokeStyle = borderSubtle;
ctx.lineWidth = 0.5;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(yLabelWidth, yPx);
ctx.lineTo(totalW, yPx);
ctx.stroke();
ctx.setLineDash([]);
const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1));
ctx.fillStyle = textGhost;
ctx.fillText(label, yLabelWidth - 8, yPx);
}
}
export function drawGridDots(
ctx: CanvasRenderingContext2D,
gridRows: number,
gridCols: number,
px: number,
yLabelWidth: number,
borderSubtle: string,
) {
ctx.fillStyle = borderSubtle;
for (let gy = 0; gy < gridRows; gy += 5) {
for (let gx = 0; gx < gridCols; gx += 5) {
ctx.fillRect(yLabelWidth + gx * px, gy * px, 1, 1);
}
}
}
export function drawAreaChart(p: ChartDrawParams) {
const { ctx, data, h, px, chartW, effectiveMax, yLabelWidth, progress, hoverIdx, colors, glow } = p;
const usableH = h - px * 2;
const points: { x: number; y: number }[] = [];
for (let i = 0; i < data.length; i++) {
const val = data[i].value;
const norm = effectiveMax > 0 ? val / effectiveMax : 0;
const x = yLabelWidth + (i / Math.max(data.length - 1, 1)) * chartW;
const y = h - px - norm * usableH * progress;
points.push({ x, y });
}
if (points.length === 0) return;
const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, colors[colors.length - 1] + '60');
gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30');
gradient.addColorStop(1, colors[0] + '08');
ctx.beginPath();
ctx.moveTo(points[0].x, h);
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.lineTo(points[i].x, points[i].y);
} else {
const prev = points[i - 1];
const curr = points[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
}
ctx.lineTo(points[points.length - 1].x, h);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.moveTo(points[i].x, points[i].y);
} else {
const prev = points[i - 1];
const curr = points[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
}
ctx.strokeStyle = colors[colors.length - 1];
ctx.lineWidth = 2;
ctx.stroke();
if (glow) {
ctx.shadowColor = colors[colors.length - 1];
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
}
for (let i = 0; i < points.length; i++) {
if (data[i].value > 0) {
const isHov = i === hoverIdx;
ctx.beginPath();
ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2);
ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)];
ctx.fill();
if (isHov) {
ctx.strokeStyle = colors[colors.length - 1];
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const steps = Math.ceil((p2.x - p1.x) / px);
for (let s = 0; s < steps; s++) {
const t = s / steps;
const x = p1.x + t * (p2.x - p1.x);
const lineY = p1.y + t * (p2.y - p1.y);
for (let py = lineY + px * 2; py < h - px; py += px * 2) {
if (Math.random() > 0.65) {
const depth = (py - lineY) / (h - lineY);
const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1)));
ctx.globalAlpha = 0.15 + (1 - depth) * 0.2;
ctx.fillStyle = colors[ci];
ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1);
}
}
}
}
ctx.globalAlpha = 1;
}
export function drawBarChart(p: ChartDrawParams) {
const { ctx, data, gridRows, gridCols, effectiveMax, yLabelWidth, px, progress, hoverIdx, colors, glow } = p;
const barSlots = data.length;
const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots));
const barW = Math.max(1, totalBarPx - 1);
for (let i = 0; i < data.length; i++) {
const val = data[i].value;
const normalised = effectiveMax > 0 ? val / effectiveMax : 0;
const usableRows = gridRows - 2;
const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows));
const barH = Math.round(targetH * progress);
const barX = i * totalBarPx;
const isHovered = i === hoverIdx;
for (let row = 0; row < barH; row++) {
const y = gridRows - 1 - row;
const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1)));
const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx];
for (let col = 0; col < barW; col++) {
ctx.fillStyle = baseColor;
ctx.fillRect(yLabelWidth + (barX + col) * px, y * px, px - 1, px - 1);
}
}
if (glow && barH > 0) {
const topY = (gridRows - 1 - barH + 1) * px;
ctx.shadowColor = colors[colors.length - 1];
ctx.shadowBlur = 6;
ctx.fillStyle = colors[colors.length - 1];
for (let col = 0; col < barW; col++) {
ctx.fillRect(yLabelWidth + (barX + col) * px, topY, px - 1, px - 1);
}
ctx.shadowBlur = 0;
}
}
}
@@ -1,37 +0,0 @@
export const PALETTES = {
salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'],
blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'],
coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'],
green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'],
purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'],
} as const;
export type PaletteKey = keyof typeof PALETTES;
export interface PixelChartProps {
data: { label: string; value: number }[];
palette?: PaletteKey;
height?: number;
pixelSize?: number;
formatValue?: (v: number) => string;
glow?: boolean;
showXLabels?: boolean;
showYScale?: boolean;
mode?: 'bar' | 'area';
}
export interface ChartDrawParams {
ctx: CanvasRenderingContext2D;
data: { label: string; value: number }[];
h: number;
px: number;
gridCols: number;
gridRows: number;
chartW: number;
effectiveMax: number;
yLabelWidth: number;
progress: number;
hoverIdx: number;
colors: readonly string[];
glow: boolean;
}
@@ -1,166 +0,0 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { PALETTES, PixelChartProps } from './pixelChartTypes';
import { drawYAxis, drawGridDots, drawAreaChart, drawBarChart } from './pixelChartRenderers';
export function computeYTicks(maxVal: number): number[] {
if (maxVal <= 0) return [0];
const rawStep = maxVal / 3;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalised = rawStep / magnitude;
let niceStep: number;
if (normalised <= 1) niceStep = magnitude;
else if (normalised <= 2) niceStep = 2 * magnitude;
else if (normalised <= 5) niceStep = 5 * magnitude;
else niceStep = 10 * magnitude;
const ticks: number[] = [];
for (let v = 0; v <= maxVal * 1.1; v += niceStep) {
ticks.push(v);
}
if (ticks.length < 2) ticks.push(niceStep);
return ticks;
}
export function computeXLabels(data: { label: string; value: number }[]): { idx: number; label: string }[] {
if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label }));
if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label }));
const result: { idx: number; label: string }[] = [];
result.push({ idx: 0, label: data[0].label });
const step = Math.floor(data.length / 4);
for (let i = 1; i <= 3; i++) {
const idx = Math.min(i * step, data.length - 2);
if (idx > 0 && idx < data.length - 1) {
result.push({ idx, label: data[idx].label });
}
}
result.push({ idx: data.length - 1, label: data[data.length - 1].label });
return result;
}
type UsePixelChartProps = Required<Pick<PixelChartProps, 'data'>> &
Pick<PixelChartProps, 'palette' | 'height' | 'pixelSize' | 'formatValue' | 'glow' | 'showYScale' | 'mode'>;
export function usePixelChart({
data,
palette = 'salmon',
height = 140,
pixelSize = 6,
formatValue,
glow = true,
showYScale = true,
mode = 'bar',
}: UsePixelChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animRef = useRef(0);
const progressRef = useRef(0);
const hoverIdxRef = useRef(-1);
const tooltipRef = useRef<HTMLDivElement>(null);
const c = useClaudeTokens();
const colors = PALETTES[palette];
const maxVal = Math.max(...data.map((d) => d.value), 0.001);
const yTicks = computeYTicks(maxVal);
const xLabels = computeXLabels(data);
const Y_LABEL_WIDTH = showYScale ? 80 : 0;
const draw = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const totalW = container.clientWidth;
const chartW = totalW - Y_LABEL_WIDTH;
const h = height;
canvas.width = totalW * dpr;
canvas.height = h * dpr;
canvas.style.width = `${totalW}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
const px = pixelSize;
const gridCols = Math.floor(chartW / px);
const gridRows = Math.floor(h / px);
const effectiveMax = yTicks[yTicks.length - 1] || maxVal;
ctx.clearRect(0, 0, totalW, h);
if (showYScale) {
drawYAxis(ctx, yTicks, effectiveMax, h, px, Y_LABEL_WIDTH, totalW, formatValue, c.border.subtle, c.text.ghost);
}
drawGridDots(ctx, gridRows, gridCols, px, Y_LABEL_WIDTH, c.border.subtle);
const progress = Math.min(progressRef.current, 1);
const hoverIdx = hoverIdxRef.current;
const params = { ctx, data, h, px, gridCols, gridRows, chartW, effectiveMax, yLabelWidth: Y_LABEL_WIDTH, progress, hoverIdx, colors, glow };
if (mode === 'area') {
drawAreaChart(params);
} else {
drawBarChart(params);
}
}, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]);
useEffect(() => {
progressRef.current = 0;
let start: number | null = null;
const animate = (ts: number) => {
if (!start) start = ts;
progressRef.current = Math.min(1, (ts - start) / 600);
draw();
if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate);
};
animRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animRef.current);
}, [data, draw]);
useEffect(() => {
const handleResize = () => draw();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
const tooltip = tooltipRef.current;
if (!canvas || !tooltip || data.length === 0) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left - Y_LABEL_WIDTH;
if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; }
const chartW = rect.width - Y_LABEL_WIDTH;
const gridCols = Math.floor(chartW / pixelSize);
const totalBarPx = Math.max(1, Math.floor(gridCols / data.length));
const idx = Math.floor(mx / (totalBarPx * pixelSize));
if (idx >= 0 && idx < data.length) {
hoverIdxRef.current = idx;
const d = data[idx];
const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2);
tooltip.textContent = `${d.label}: ${valStr}`;
tooltip.style.opacity = '1';
tooltip.style.left = `${e.clientX - rect.left}px`;
tooltip.style.top = `${e.clientY - rect.top - 28}px`;
} else {
hoverIdxRef.current = -1;
tooltip.style.opacity = '0';
}
draw();
},
[data, pixelSize, draw, formatValue, Y_LABEL_WIDTH],
);
const handleMouseLeave = useCallback(() => {
hoverIdxRef.current = -1;
if (tooltipRef.current) tooltipRef.current.style.opacity = '0';
draw();
}, [draw]);
return { canvasRef, containerRef, tooltipRef, xLabels, Y_LABEL_WIDTH, handleMouseMove, handleMouseLeave, c };
}
@@ -20,5 +20,3 @@ export const CommandsContent: React.FC = () => {
</Box>
);
};
export default CommandsContent;
@@ -12,7 +12,7 @@ import TerminalIcon from '@mui/icons-material/Terminal';
import { AgentSession, handleApproval } from '@/shared/state/agentsSlice';
import { useAppDispatch } from '@/shared/hooks';
import { ToolQuestion as QuestionForm } from '@/app/pages/AgentChat/toolkit/approval-tools';
import { parseMcpToolName } from '@/app/pages/AgentChat/ToolCallBubble';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approval-utils';
import GoogleServiceIcon from '@/app/components/GoogleServiceIcon';
import { summarizeToolInput, getToolDisplayName } from './agentCardUtils';
@@ -15,7 +15,6 @@ import BrowserTabBar from './BrowserTabBar';
import BrowserNavBar from './BrowserNavBar';
import BrowserActionOverlay from './BrowserActionOverlay';
export type { TabLocalState, WebviewElement };
const MIN_W = 400, MIN_H = 300;
interface Props {
@@ -1,60 +0,0 @@
import React from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface Props {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}
const CloseAgentDialog: React.FC<Props> = ({ open, onCancel, onConfirm }) => {
const c = useClaudeTokens();
return (
<Dialog
open={open}
onClose={onCancel}
PaperProps={{
sx: {
bgcolor: c.bg.surface,
borderRadius: 4,
border: `1px solid ${c.border.subtle}`,
minWidth: 380,
},
}}
>
<DialogTitle sx={{ color: c.status.warning, fontWeight: 700, fontSize: '1rem', pb: 0.5 }}>
Agent still running
</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: c.text.muted, fontSize: '0.875rem' }}>
This agent is still running. Closing it will pause the agent.
You can resume it later from the chat history.
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onCancel} sx={{ color: c.text.tertiary }}>
Cancel
</Button>
<Button
onClick={onConfirm}
variant="contained"
sx={{
bgcolor: c.status.warning,
'&:hover': { bgcolor: '#6b4a18' },
fontWeight: 600,
}}
>
Close &amp; Pause
</Button>
</DialogActions>
</Dialog>
);
};
export default CloseAgentDialog;
@@ -16,7 +16,7 @@ import type { CanvasActions } from './useCanvasControls';
const TETHER_FADE_MS = 2500;
export interface DashboardCanvasProps {
interface DashboardCanvasProps {
panX: number; panY: number; zoom: number;
isPanning: boolean; spaceHeld: boolean; cmdHeld: boolean;
viewportRef: React.RefObject<HTMLDivElement>; contentRef: React.RefObject<HTMLDivElement>;
@@ -1,5 +1,5 @@
import { AgentSession } from '@/shared/state/agentsSlice';
import { parseMcpToolName } from '@/app/pages/AgentChat/ToolCallBubble';
import { parseMcpToolName } from '@/app/pages/AgentChat/toolkit/approval-utils';
export function formatDuration(createdAt: string, closedAt?: string | null, status?: string): string {
const start = new Date(createdAt).getTime();
@@ -1,7 +1,7 @@
export type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
export const EDGE_THICKNESS = 6;
export const CORNER_SIZE = 14;
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
export const DRAG_THRESHOLD = 3;
export const CURSOR_MAP: Record<ResizeDir, string> = {
@@ -3,12 +3,12 @@ import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shar
export type CardType = 'agent' | 'view' | 'browser';
export interface SelectedCard {
interface SelectedCard {
id: string;
type: CardType;
}
export interface MarqueeRect {
interface MarqueeRect {
x: number;
y: number;
width: number;
@@ -2,8 +2,8 @@ import type { Output } from '@/shared/state/outputsSlice';
export type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
export const EDGE_THICKNESS = 6;
export const CORNER_SIZE = 14;
const EDGE_THICKNESS = 6;
const CORNER_SIZE = 14;
export const MIN_W = 320;
export const MIN_H = 200;
@@ -9,7 +9,7 @@ import MoreVertIcon from '@mui/icons-material/MoreVert';
import { Dashboard } from '@/shared/state/dashboardsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export function formatRelativeTime(dateStr: string | null): string {
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return '';
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
@@ -1,112 +0,0 @@
import React, { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const CopilotAuthButton: React.FC = () => {
const c = useClaudeTokens();
const [status, setStatus] = useState<'idle' | 'waiting' | 'connected' | 'error'>('idle');
const [userCode, setUserCode] = useState('');
const [username, setUsername] = useState('');
const [error, setError] = useState('');
useEffect(() => {
fetch(`${API_BASE}/agents/copilot/models`)
.then(r => r.json())
.then(d => {
if (d.models && d.models.length > 0) setStatus('connected');
})
.catch(() => {});
}, []);
const startAuth = async () => {
setStatus('waiting');
setError('');
try {
const resp = await fetch(`${API_BASE}/agents/copilot/start-auth`, { method: 'POST' });
const data = await resp.json();
setUserCode(data.user_code);
window.open(data.verification_uri, '_blank');
const deviceCode = data.device_code;
const poll = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/agents/copilot/poll-auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code: deviceCode }),
});
const d = await r.json();
if (d.status === 'connected') {
clearInterval(poll);
setStatus('connected');
setUsername(d.username || '');
}
} catch {}
}, 5000);
setTimeout(() => { clearInterval(poll); if (status === 'waiting') { setStatus('error'); setError('Auth timed out'); } }, 300000);
} catch (e: any) {
setStatus('error');
setError(e.message || 'Failed to start auth');
}
};
const disconnect = async () => {
await fetch(`${API_BASE}/agents/copilot/disconnect`, { method: 'POST' });
setStatus('idle');
setUsername('');
};
if (status === 'connected') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.status.success, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
Connected{username ? ` as @${username}` : ''}
</Typography>
<Typography
onClick={disconnect}
sx={{ fontSize: '0.72rem', color: c.text.tertiary, cursor: 'pointer', ml: 'auto', '&:hover': { color: c.status.error } }}
>
Disconnect
</Typography>
</Box>
);
}
if (status === 'waiting') {
return (
<Box>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, mb: 0.5 }}>
Enter code <strong style={{ fontFamily: 'monospace', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{userCode}</strong> at github.com/login/device
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary }}>Waiting for authorization...</Typography>
</Box>
);
}
return (
<Box>
<Button
onClick={startAuth}
variant="outlined"
size="small"
sx={{
textTransform: 'none',
fontSize: '0.78rem',
color: c.text.primary,
borderColor: c.border.medium,
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
Sign in with GitHub
</Button>
{error && <Typography sx={{ fontSize: '0.7rem', color: c.status.error, mt: 0.5 }}>{error}</Typography>}
</Box>
);
};
export default CopilotAuthButton;
@@ -9,7 +9,7 @@ export const SUBSCRIPTION_PROVIDERS = [
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
];
export type SubscriptionProvider = typeof SUBSCRIPTION_PROVIDERS[0];
type SubscriptionProvider = typeof SUBSCRIPTION_PROVIDERS[0];
interface SubscriptionCardProps {
provider: SubscriptionProvider;
@@ -6,30 +6,6 @@ import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
import { fetchModes } from '@/shared/state/modesSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
export const API_KEY_STEPS = [
{
title: 'Open the Anthropic Console',
detail: 'Visit console.anthropic.com — create a free account if you don\'t have one yet.',
link: 'https://console.anthropic.com',
},
{
title: 'Navigate to API Keys',
detail: 'In the dashboard, click "Settings" in the left sidebar, then select "API Keys".',
},
{
title: 'Create a new key',
detail: 'Click the "Create Key" button. Name it anything you like (e.g. "OpenSwarm").',
},
{
title: 'Copy your key',
detail: 'Click the copy icon next to your new key. It will start with sk-ant-api03-…',
},
{
title: 'Paste it above & save',
detail: 'Paste the key into the field above, then hit Save. You\'re all set!',
},
];
export function useSettings() {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -12,7 +12,7 @@ import LinkIcon from '@mui/icons-material/Link';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { Integration } from './integrations';
export interface CredentialsDialogProps {
interface CredentialsDialogProps {
open: boolean;
onClose: () => void;
integration: Integration | null;
@@ -17,7 +17,7 @@ import { McpServer } from '@/shared/state/mcpRegistrySlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { cleanServerName } from './toolUtils';
export interface McpConfigDialogProps {
interface McpConfigDialogProps {
open: boolean;
onClose: () => void;
mcpConfigServer: McpServer | null;
@@ -32,7 +32,7 @@ import { ToolDefinition } from '@/shared/state/toolsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { cleanServerName } from './toolUtils';
export interface RegistryBrowserDialogProps {
interface RegistryBrowserDialogProps {
open: boolean;
onClose: () => void;
regQuery: string;
@@ -9,7 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { PermToggle } from './PermToggle';
export function toDisplayName(name: string, serviceName?: string): string {
function toDisplayName(name: string, serviceName?: string): string {
let display = name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
if (serviceName) {
const svcLower = serviceName.toLowerCase();
@@ -28,7 +28,7 @@ export function firstSentence(desc: string): string {
return match ? match[1].trim() : desc.substring(0, 100);
}
export function getGroupPolicy(names: string[], perms: Record<string, string>): string {
function getGroupPolicy(names: string[], perms: Record<string, string>): string {
if (names.length === 0) return 'ask';
const policies = names.map((n) => perms[n] || 'ask');
if (policies.every((p) => p === 'always_allow')) return 'always_allow';
@@ -82,7 +82,7 @@ const ToolRow = ({ name, serviceName, desc, schema, schemaKey, perms, devMode, e
);
};
const ToolGroup = ({ label, icon, iconColor, names, serviceName, descriptions, schemas, perms, devMode, expandedSchema, setExpandedSchema, toolId, onPermissionChange, onGroupPermissionChange, c }: any) => {
const ToolGroup = ({ label, icon, names, serviceName, descriptions, schemas, perms, devMode, expandedSchema, setExpandedSchema, toolId, onPermissionChange, onGroupPermissionChange, c }: any) => {
if (!names || names.length === 0) return null;
const gp = getGroupPolicy(names, perms);
return (
+2 -4
View File
@@ -20,7 +20,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { CATEGORY_ORDER } from './integrations';
import { PermToggle } from './PermToggle';
export interface ToolSectionProps {
interface ToolSectionProps {
label: string;
icon: React.ReactElement;
count: number;
@@ -42,7 +42,7 @@ export interface ToolSectionProps {
export const ToolSection: React.FC<ToolSectionProps> = ({
label, icon, count, open, onToggle,
grouped, collapsedCategories, toggleCategory,
expandedBuiltin, toggleBuiltinExpand, deferred,
deferred,
builtinPermissions, onPermissionChange, onCategoryPermissionChange,
enabled, onEnabledChange,
}) => {
@@ -66,8 +66,6 @@ export const ToolSection: React.FC<ToolSectionProps> = ({
return 'mixed';
};
const allSectionTools = CATEGORY_ORDER.filter((cat) => grouped[cat]).flatMap((cat) => grouped[cat]);
const categoryCount = CATEGORY_ORDER.filter((cat) => grouped[cat]).length;
const sectionDescription = deferred
? 'On-demand actions loaded via ToolSearch for planning, scheduling, and extended operations'
: 'Built-in Claude Agent SDK actions for file operations, shell commands, and search';
+1 -1
View File
@@ -8,7 +8,7 @@ import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { LogEntry } from './LogEntry';
export interface AutoRunLogProps {
interface AutoRunLogProps {
messages: AgentMessage[];
status: string | null;
logEndRef: React.RefObject<HTMLDivElement | null>;
@@ -16,7 +16,7 @@ export interface ConsoleEntry {
running?: boolean;
}
export interface ConsolePanelProps {
interface ConsolePanelProps {
entry: ConsoleEntry | null;
c: ReturnType<typeof useClaudeTokens>;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export interface LogEntryProps {
interface LogEntryProps {
msg: AgentMessage;
c: ReturnType<typeof useClaudeTokens>;
}
@@ -1,10 +1,9 @@
"use client";
import { PropsWithChildren, useEffect, useState, type FC } from "react";
import { XIcon, PlusIcon, FileText } from "lucide-react";
import { XIcon, FileText } from "lucide-react";
import {
AttachmentPrimitive,
ComposerPrimitive,
MessagePrimitive,
useAuiState,
useAui,
@@ -193,31 +192,4 @@ export const UserMessageAttachments: FC = () => {
</MessagePrimitive.Attachments>
</div>
);
};
export const ComposerAttachments: FC = () => {
return (
<div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden">
<ComposerPrimitive.Attachments>
{() => <AttachmentUI />}
</ComposerPrimitive.Attachments>
</div>
);
};
export const ComposerAddAttachment: FC = () => {
return (
<ComposerPrimitive.AddAttachment asChild>
<TooltipIconButton
tooltip="Add Attachment"
side="bottom"
variant="ghost"
size="icon"
className="aui-composer-add-attachment size-8 rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
aria-label="Add Attachment"
>
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
</TooltipIconButton>
</ComposerPrimitive.AddAttachment>
);
};
};
@@ -1,367 +0,0 @@
import {
ComposerAddAttachment,
ComposerAttachments,
UserMessageAttachments,
} from "@/components/assistant-ui/attachment";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
ActionBarMorePrimitive,
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
SuggestionPrimitive,
ThreadPrimitive,
useAuiState,
} from "@assistant-ui/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
MoreHorizontalIcon,
PencilIcon,
RefreshCwIcon,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
export const Thread: FC = () => {
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col bg-background"
style={{
["--thread-max-width" as string]: "44rem",
["--composer-radius" as string]: "24px",
["--composer-padding" as string]: "10px",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
>
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome />
</AuiIf>
<ThreadPrimitive.Messages>
{() => <ThreadMessage />}
</ThreadPrimitive.Messages>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-(--composer-radius) bg-background pb-4 md:pb-6">
<ThreadScrollToBottom />
<Composer />
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
);
};
const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing);
if (isEditing) return <EditComposer />;
if (role === "user") return <UserMessage />;
return <AssistantMessage />;
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible dark:border-border dark:bg-background dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
const ThreadWelcome: FC = () => {
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
Hello there!
</h1>
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
How can I help you today?
</p>
</div>
</div>
<ThreadSuggestions />
</div>
);
};
const ThreadSuggestions: FC = () => {
return (
<div className="aui-thread-welcome-suggestions grid w-full @md:grid-cols-2 gap-2 pb-4">
<ThreadPrimitive.Suggestions>
{() => <ThreadSuggestionItem />}
</ThreadPrimitive.Suggestions>
</div>
);
};
const ThreadSuggestionItem: FC = () => {
return (
<div className="aui-thread-welcome-suggestion-display fade-in slide-in-from-bottom-2 @md:nth-[n+3]:block nth-[n+3]:hidden animate-in fill-mode-both duration-200">
<SuggestionPrimitive.Trigger send asChild>
<Button
variant="ghost"
className="aui-thread-welcome-suggestion h-auto w-full @md:flex-col flex-wrap items-start justify-start gap-1 rounded-3xl border bg-background px-4 py-3 text-left text-sm transition-colors hover:bg-muted"
>
<SuggestionPrimitive.Title className="aui-thread-welcome-suggestion-text-1 font-medium" />
<SuggestionPrimitive.Description className="aui-thread-welcome-suggestion-text-2 text-muted-foreground empty:hidden" />
</Button>
</SuggestionPrimitive.Trigger>
</div>
);
};
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone asChild>
<div
data-slot="composer-shell"
className="flex w-full flex-col gap-2 rounded-(--composer-radius) border bg-background p-(--composer-padding) transition-shadow focus-within:border-ring/75 focus-within:ring-2 focus-within:ring-ring/20 data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50"
>
<ComposerAttachments />
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none placeholder:text-muted-foreground/80"
rows={1}
autoFocus
aria-label="Message input"
/>
<ComposerAction />
</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
const ComposerAction: FC = () => {
return (
<div className="aui-composer-action-wrapper relative flex items-center justify-between">
<ComposerAddAttachment />
<AuiIf condition={(s) => !s.thread.isRunning}>
<ComposerPrimitive.Send asChild>
<TooltipIconButton
tooltip="Send message"
side="bottom"
type="button"
variant="default"
size="icon"
className="aui-composer-send size-8 rounded-full"
aria-label="Send message"
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
</div>
);
};
const MessageError: FC = () => {
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "text") return <MarkdownText />;
if (part.type === "tool-call")
return part.toolUI ?? <ToolFallback {...part} />;
return null;
}}
</MessagePrimitive.Parts>
<MessageError />
</div>
<div className="aui-assistant-message-footer mt-1 ml-2 flex min-h-6 items-center">
<BranchPicker />
<AssistantActionBar />
</div>
</MessagePrimitive.Root>
);
};
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<ActionBarMorePrimitive.Root>
<ActionBarMorePrimitive.Trigger asChild>
<TooltipIconButton
tooltip="More"
className="data-[state=open]:bg-accent"
>
<MoreHorizontalIcon />
</TooltipIconButton>
</ActionBarMorePrimitive.Trigger>
<ActionBarMorePrimitive.Content
side="bottom"
align="start"
className="aui-action-bar-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
<ActionBarPrimitive.ExportMarkdown asChild>
<ActionBarMorePrimitive.Item className="aui-action-bar-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
<DownloadIcon className="size-4" />
Export as Markdown
</ActionBarMorePrimitive.Item>
</ActionBarPrimitive.ExportMarkdown>
</ActionBarMorePrimitive.Content>
</ActionBarMorePrimitive.Root>
</ActionBarPrimitive.Root>
);
};
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
data-role="user"
>
<UserMessageAttachments />
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
<div className="aui-user-message-content wrap-break-word peer rounded-2xl bg-muted px-4 py-2.5 text-foreground empty:hidden">
<MessagePrimitive.Parts />
</div>
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2 peer-empty:hidden">
<UserActionBar />
</div>
</div>
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
</MessagePrimitive.Root>
);
};
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
};
const EditComposer: FC = () => {
return (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
autoFocus
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost" size="sm">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm">Update</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
};
const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({
className,
...rest
}) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-muted-foreground text-xs",
className,
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};
@@ -22,7 +22,7 @@ import { cn } from "@/lib/utils";
const ANIMATION_DURATION = 200;
export type ToolFallbackRootProps = Omit<
type ToolFallbackRootProps = Omit<
React.ComponentProps<typeof Collapsible>,
"open" | "onOpenChange"
> & {
@@ -315,10 +315,4 @@ ToolFallback.Error = ToolFallbackError;
export {
ToolFallback,
ToolFallbackRoot,
ToolFallbackTrigger,
ToolFallbackContent,
ToolFallbackArgs,
ToolFallbackResult,
ToolFallbackError,
};
@@ -11,7 +11,7 @@ import {
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
@@ -1,7 +0,0 @@
export { ApprovalCard } from "./approval-card";
export {
type SerializableApprovalCard,
type ApprovalCardProps,
type ApprovalDecision,
type MetadataItem,
} from "./schema";
@@ -1,14 +1,11 @@
import { z } from "zod";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const MetadataItemSchema = z.object({
const MetadataItemSchema = z.object({
key: z.string().min(1),
value: z.string(),
});
export type MetadataItem = z.infer<typeof MetadataItemSchema>;
export const ApprovalDecisionSchema = z.enum(["approved", "denied"]);
export type ApprovalDecision = z.infer<typeof ApprovalDecisionSchema>;
@@ -34,19 +31,6 @@ export type SerializableApprovalCard = z.infer<
typeof SerializableApprovalCardSchema
>;
const SerializableApprovalCardSchemaContract = defineToolUiContract(
"ApprovalCard",
SerializableApprovalCardSchema,
);
export const parseSerializableApprovalCard: (
input: unknown,
) => SerializableApprovalCard = SerializableApprovalCardSchemaContract.parse;
export const safeParseSerializableApprovalCard: (
input: unknown,
) => SerializableApprovalCard | null =
SerializableApprovalCardSchemaContract.safeParse;
export interface ApprovalCardProps extends SerializableApprovalCard {
className?: string;
onConfirm?: () => void | Promise<void>;
@@ -142,7 +142,7 @@ function useResolvedTheme(): "light" | "dark" {
return theme;
}
export type CodeBlockRootProps = CodeBlockProps & {
type CodeBlockRootProps = CodeBlockProps & {
children: ReactNode;
expanded?: boolean;
defaultExpanded?: boolean;
@@ -348,7 +348,7 @@ function CodeBlockRoot({
);
}
export type CodeBlockSectionProps = {
type CodeBlockSectionProps = {
className?: string;
};
@@ -440,7 +440,7 @@ function CodeBlockCollapseToggle({ className }: CodeBlockSectionProps) {
);
}
export type CodeBlockComposedProps = Omit<CodeBlockRootProps, "children">;
type CodeBlockComposedProps = Omit<CodeBlockRootProps, "children">;
function CodeBlockComposed(props: CodeBlockComposedProps) {
return (
@@ -1,11 +0,0 @@
export { CodeBlock } from "./code-block";
export type {
CodeBlockRootProps,
CodeBlockComposedProps,
CodeBlockSectionProps,
} from "./code-block";
export type {
CodeBlockProps,
CodeBlockLineNumbersMode,
SerializableCodeBlock,
} from "./schema";
@@ -1,5 +1,4 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
@@ -20,24 +19,4 @@ export const CodeBlockPropsSchema = z.object({
});
export type CodeBlockProps = z.infer<typeof CodeBlockPropsSchema>;
export type CodeBlockLineNumbersMode = CodeBlockProps["lineNumbers"];
export const SerializableCodeBlockSchema = CodeBlockPropsSchema.omit({
className: true,
});
export type SerializableCodeBlock = z.infer<typeof SerializableCodeBlockSchema>;
const SerializableCodeBlockSchemaContract = defineToolUiContract(
"CodeBlock",
SerializableCodeBlockSchema,
);
export const parseSerializableCodeBlock: (
input: unknown,
) => SerializableCodeBlock = SerializableCodeBlockSchemaContract.parse;
export const safeParseSerializableCodeBlock: (
input: unknown,
) => SerializableCodeBlock | null =
SerializableCodeBlockSchemaContract.safeParse;
export type CodeBlockLineNumbersMode = CodeBlockProps["lineNumbers"];
@@ -148,7 +148,7 @@ function useCodeDiff(): CodeDiffSharedState {
/* ── Subcomponents ──────────────────────────────────────────────── */
export type CodeDiffRootProps = CodeDiffProps & {
type CodeDiffRootProps = CodeDiffProps & {
children: ReactNode;
expanded?: boolean;
defaultExpanded?: boolean;
@@ -295,7 +295,7 @@ function CodeDiffRoot({
);
}
export type CodeDiffSectionProps = {
type CodeDiffSectionProps = {
className?: string;
};
@@ -434,7 +434,7 @@ function CodeDiffCollapseToggle({ className }: CodeDiffSectionProps) {
/* ── Composed preset (callable as a flat component) ─────────────── */
export type CodeDiffComposedProps = Omit<CodeDiffRootProps, "children">;
type CodeDiffComposedProps = Omit<CodeDiffRootProps, "children">;
function CodeDiffComposed(props: CodeDiffComposedProps) {
return (
@@ -1,7 +0,0 @@
export { CodeDiff } from "./code-diff";
export type {
CodeDiffRootProps,
CodeDiffComposedProps,
CodeDiffSectionProps,
} from "./code-diff";
export type { CodeDiffProps, SerializableCodeDiff } from "./schema";
@@ -1,5 +1,4 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
@@ -50,22 +49,3 @@ export const CodeDiffPropsSchema = CodeDiffPropsSchemaBase.superRefine(
);
export type CodeDiffProps = z.infer<typeof CodeDiffPropsSchema>;
export const SerializableCodeDiffSchema = CodeDiffPropsSchemaBase.omit({
className: true,
}).superRefine(validateCodeDiffInputMode);
export type SerializableCodeDiff = z.infer<typeof SerializableCodeDiffSchema>;
const SerializableCodeDiffSchemaContract = defineToolUiContract(
"CodeDiff",
SerializableCodeDiffSchema,
);
export const parseSerializableCodeDiff: (
input: unknown,
) => SerializableCodeDiff = SerializableCodeDiffSchemaContract.parse;
export const safeParseSerializableCodeDiff: (
input: unknown,
) => SerializableCodeDiff | null = SerializableCodeDiffSchemaContract.safeParse;
@@ -1,19 +0,0 @@
# Data Table
Implementation for the "data-table" Tool UI surface.
## Files
- public exports: components/tool-ui/data-table/index.tsx
- serializable schema + parse helpers: components/tool-ui/data-table/schema.ts
## Companion assets
- Docs page: app/docs/data-table/content.mdx
- Preset payload: lib/presets/data-table.ts
## Quick check
Run this after edits:
pnpm test
@@ -1,23 +0,0 @@
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export { Badge } from "@/components/ui/badge";
export {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
@@ -1,936 +0,0 @@
"use client";
import * as React from "react";
import {
cn,
Table,
TableBody,
TableRow,
TableCell,
TableHeader,
TableHead,
Button,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./_adapter";
import {
sortData,
createDataTableRowKeys,
getDataTableMobileDescriptionId,
} from "./utilities";
import { renderFormattedValue } from "./formatters";
import type {
DataTableProps,
DataTableContextValue,
RowData,
DataTableRowData,
ColumnKey,
Column,
} from "./types";
import type { FormatConfig } from "./formatters";
const DEFAULT_LOCALE = "en-US" as const;
function isNumericFormat(format?: FormatConfig): boolean {
const kind = format?.kind;
return (
kind === "number" ||
kind === "currency" ||
kind === "percent" ||
kind === "delta"
);
}
function getAlignmentClass(
align?: "left" | "right" | "center",
): string | undefined {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return undefined;
}
const DataTableContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataTableContextValue<any> | undefined
>(undefined);
function useDataTable<T extends object = RowData>() {
const context = React.useContext(DataTableContext) as
| DataTableContextValue<T>
| undefined;
if (!context) {
throw new Error("useDataTable must be used within <DataTable.Provider />");
}
return context;
}
type DataTableLayout = "auto" | "table" | "cards";
type DataTableBaseProps<T extends object = RowData> = DataTableProps<T> & {
layout: DataTableLayout;
};
type DataTableProviderProps<T extends object = RowData> = Pick<
DataTableProps<T>,
| "columns"
| "data"
| "rowIdKey"
| "defaultSort"
| "sort"
| "onSortChange"
| "id"
| "locale"
> & {
children: React.ReactNode;
};
function DataTableProvider<T extends object = RowData>({
columns,
data: rawData,
rowIdKey,
defaultSort,
sort: controlledSort,
id,
onSortChange,
locale,
children,
}: DataTableProviderProps<T>) {
// Default locale avoids SSR/client formatting mismatches.
const resolvedLocale = locale ?? DEFAULT_LOCALE;
const [internalSortBy, setInternalSortBy] = React.useState<
ColumnKey<T> | undefined
>(defaultSort?.by);
const [internalSortDirection, setInternalSortDirection] = React.useState<
"asc" | "desc" | undefined
>(defaultSort?.direction);
const sortBy = controlledSort?.by ?? internalSortBy;
const sortDirection = controlledSort?.direction ?? internalSortDirection;
const data = React.useMemo(() => {
if (!sortBy || !sortDirection) return rawData;
return sortData(rawData, sortBy, sortDirection, resolvedLocale);
}, [rawData, sortBy, sortDirection, resolvedLocale]);
const handleSort = React.useCallback(
(key: ColumnKey<T>) => {
let newDirection: "asc" | "desc" | undefined;
if (sortBy === key) {
if (sortDirection === "asc") {
newDirection = "desc";
} else if (sortDirection === "desc") {
newDirection = undefined;
} else {
newDirection = "asc";
}
} else {
newDirection = "asc";
}
const next = {
by: newDirection ? key : undefined,
direction: newDirection,
} as const;
if (controlledSort) {
onSortChange?.(next);
} else {
setInternalSortBy(next.by);
setInternalSortDirection(next.direction);
}
},
[sortBy, sortDirection, controlledSort, onSortChange],
);
const contextValue: DataTableContextValue<T> = {
columns,
data,
rowIdKey,
sortBy,
sortDirection,
toggleSort: handleSort,
id,
locale: resolvedLocale,
};
return (
<DataTableContext.Provider value={contextValue}>
{children}
</DataTableContext.Provider>
);
}
interface DataTableLayoutProps {
layout: DataTableLayout;
emptyMessage: string;
maxHeight?: string;
className?: string;
}
function DataTableLayout({
layout,
emptyMessage,
maxHeight,
className,
}: DataTableLayoutProps) {
const { columns, data, rowIdKey, sortBy, sortDirection, id } = useDataTable();
const rowKeys = React.useMemo(
() =>
createDataTableRowKeys(
data as Array<Record<string, unknown>>,
rowIdKey ? String(rowIdKey) : undefined,
),
[data, rowIdKey],
);
const mobileDescriptionId = React.useMemo(
() => getDataTableMobileDescriptionId(String(id ?? "data-table")),
[id],
);
const sortAnnouncement = React.useMemo(() => {
const col = columns.find((c) => c.key === sortBy);
const label = col?.label ?? sortBy;
return sortBy && sortDirection
? `Sorted by ${label}, ${sortDirection === "asc" ? "ascending" : "descending"}`
: "";
}, [columns, sortBy, sortDirection]);
return (
<div
className={cn("@container w-full min-w-80", className)}
data-tool-ui-id={id}
data-slot="data-table"
data-layout={layout}
>
<div
className={cn(
layout === "table"
? "block"
: layout === "cards"
? "hidden"
: "hidden @md:block",
)}
>
<div className="relative">
<div
className={cn(
"bg-card relative w-full overflow-clip overflow-y-auto rounded-lg border",
"touch-pan-x",
maxHeight && "max-h-[--max-height]",
)}
style={
maxHeight
? ({ "--max-height": maxHeight } as React.CSSProperties)
: undefined
}
>
<Table>
{columns.length > 0 && (
<colgroup>
{columns.map((col) => (
<col
key={String(col.key)}
style={col.width ? { width: col.width } : undefined}
/>
))}
</colgroup>
)}
{data.length === 0 ? (
<DataTableEmpty message={emptyMessage} />
) : (
<DataTableContent />
)}
</Table>
</div>
</div>
</div>
<div
className={cn(
layout === "cards"
? ""
: layout === "table"
? "hidden"
: "@md:hidden",
)}
role="list"
aria-label="Data table (mobile card view)"
aria-describedby={mobileDescriptionId}
>
<div id={mobileDescriptionId} className="sr-only">
Table data shown as expandable cards. Each card represents one row.
{columns.length > 0 &&
` Columns: ${columns.map((c) => c.label).join(", ")}.`}
</div>
{data.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
{emptyMessage}
</div>
) : (
<div className="bg-card flex flex-col overflow-hidden rounded-2xl border shadow-xs">
{data.map((row, i) => {
const rowKey = rowKeys[i];
return (
<DataTableAccordionCard
key={rowKey}
row={row as unknown as DataTableRowData}
index={i}
rowKey={rowKey}
isFirst={i === 0}
/>
);
})}
</div>
)}
</div>
{sortAnnouncement && (
<div className="sr-only" aria-live="polite">
{sortAnnouncement}
</div>
)}
</div>
);
}
function DataTableBase<T extends object = RowData>(
props: DataTableBaseProps<T>,
) {
const {
columns,
data,
rowIdKey,
defaultSort,
sort,
onSortChange,
id,
locale,
layout,
emptyMessage = "No data available",
maxHeight,
className,
} = props;
return (
<DataTableProvider
columns={columns}
data={data}
rowIdKey={rowIdKey}
defaultSort={defaultSort}
sort={sort}
onSortChange={onSortChange}
id={id}
locale={locale}
>
<DataTableLayout
layout={layout}
emptyMessage={emptyMessage}
maxHeight={maxHeight}
className={className}
/>
</DataTableProvider>
);
}
function DataTableRoot<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="auto" />;
}
function DataTableTable<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="table" />;
}
function DataTableCards<T extends object = RowData>(props: DataTableProps<T>) {
return <DataTableBase {...props} layout="cards" />;
}
type DataTableComponent = {
<T extends object = RowData>(props: DataTableProps<T>): React.ReactElement;
Table: typeof DataTableTable;
Cards: typeof DataTableCards;
Provider: typeof DataTableProvider;
};
export const DataTable = Object.assign(DataTableRoot, {
Table: DataTableTable,
Cards: DataTableCards,
Provider: DataTableProvider,
}) as DataTableComponent;
function DataTableContent() {
return (
<>
<DataTableHeader />
<DataTableBody />
</>
);
}
function DataTableEmpty({ message }: { message: string }) {
const { columns } = useDataTable();
return (
<TableBody>
<TableRow className="bg-card h-24 text-center">
<TableCell colSpan={columns.length} role="status" aria-live="polite">
{message}
</TableCell>
</TableRow>
</TableBody>
);
}
function SortIcon({ state }: { state?: "asc" | "desc" }) {
let char = "⇅";
let className = "opacity-20";
if (state === "asc") {
char = "↑";
className = "";
}
if (state === "desc") {
char = "↓";
className = "";
}
return (
<span aria-hidden className={cn("min-w-4 shrink-0 text-center", className)}>
{char}
</span>
);
}
function DataTableHeader() {
const { columns } = useDataTable();
return (
<TooltipProvider delayDuration={300}>
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((column, columnIndex) => (
<DataTableHead
key={column.key}
column={column}
columnIndex={columnIndex}
totalColumns={columns.length}
/>
))}
</TableRow>
</TableHeader>
</TooltipProvider>
);
}
interface DataTableHeadProps {
column: Column;
columnIndex?: number;
totalColumns?: number;
}
function DataTableHead({
column,
columnIndex = 0,
totalColumns = 1,
}: DataTableHeadProps) {
const { sortBy, sortDirection, toggleSort } = useDataTable();
const isFirstColumn = columnIndex === 0;
const isLastColumn = columnIndex === totalColumns - 1;
const isSortable = column.sortable !== false;
const isSorted = sortBy === column.key;
const direction = isSorted ? sortDirection : undefined;
const isDisabled = !isSortable;
const handleClick = () => {
if (!isDisabled && toggleSort) {
toggleSort(column.key);
}
};
const displayText = column.abbr || column.label;
const shouldShowTooltip = column.abbr || displayText.length > 15;
const isNumericKind = isNumericFormat(column.format);
const align =
column.align ??
(columnIndex === 0 ? "left" : isNumericKind ? "right" : "left");
const alignClass = getAlignmentClass(align);
const buttonAlignClass = cn(
"min-w-0 gap-1 font-normal",
align === "right" && "text-right",
align === "center" && "text-center",
align === "left" && "text-left",
);
const labelAlignClass =
align === "right"
? "text-right"
: align === "center"
? "text-center"
: "text-left";
return (
<TableHead
scope="col"
className={cn(
alignClass,
isFirstColumn && "pl-1",
isLastColumn && "pr-1",
)}
style={column.width ? { width: column.width } : undefined}
aria-sort={
isSorted
? direction === "asc"
? "ascending"
: "descending"
: undefined
}
>
<Button
type="button"
size="sm"
onClick={handleClick}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
}}
disabled={isDisabled}
variant="ghost"
className={cn(
buttonAlignClass,
"w-fit min-w-10",
isFirstColumn && "pl-4",
isLastColumn && "pr-4",
)}
aria-label={
`Sort by ${column.label}` +
(isSorted && direction
? ` (${direction === "asc" ? "ascending" : "descending"})`
: "")
}
aria-disabled={isDisabled || undefined}
>
{shouldShowTooltip ? (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", labelAlignClass)}>
{column.abbr ? (
<abbr
title={column.label}
className={cn(
"cursor-help border-b border-dotted border-current no-underline",
labelAlignClass,
)}
>
{column.abbr}
</abbr>
) : (
<span className={labelAlignClass}>{column.label}</span>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{column.label}</p>
</TooltipContent>
</Tooltip>
) : (
<span className={cn("truncate", labelAlignClass)}>
{column.label}
</span>
)}
{isSortable && <SortIcon state={direction} />}
</Button>
</TableHead>
);
}
function DataTableBody() {
const { data, rowIdKey } = useDataTable<DataTableRowData>();
const rowKeys = React.useMemo(
() =>
createDataTableRowKeys(
data as Array<Record<string, unknown>>,
rowIdKey ? String(rowIdKey) : undefined,
),
[data, rowIdKey],
);
const hasWarnedRowKeyRef = React.useRef(false);
React.useEffect(() => {
if (hasWarnedRowKeyRef.current) return;
if (process.env.NODE_ENV !== "production" && !rowIdKey && data.length > 0) {
hasWarnedRowKeyRef.current = true;
console.warn(
"[DataTable] Missing `rowIdKey` prop. Falling back to inferred/content-derived row keys. " +
"Strongly recommended: Pass a `rowIdKey` prop that points to a unique identifier in your row data (e.g., 'id', 'uuid', 'symbol').\n" +
'Example: <DataTable rowIdKey="id" columns={...} data={...} />',
);
}
}, [rowIdKey, data.length]);
return (
<TableBody>
{data.map((row, index) => {
const rowKey = rowKeys[index];
return <DataTableRow key={rowKey} row={row} />;
})}
</TableBody>
);
}
interface DataTableRowProps {
row: DataTableRowData;
className?: string;
}
function DataTableRow({ row, className }: DataTableRowProps) {
const { columns } = useDataTable();
return (
<TableRow className={className}>
{columns.map((column, columnIndex) => (
<DataTableCell
key={column.key}
value={row[column.key]}
column={column}
row={row}
columnIndex={columnIndex}
/>
))}
</TableRow>
);
}
interface DataTableCellProps {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: Column;
row: DataTableRowData;
className?: string;
columnIndex?: number;
}
function DataTableCell({
value,
column,
row,
className,
columnIndex = 0,
}: DataTableCellProps) {
const { locale } = useDataTable();
const isNumericKind = isNumericFormat(column.format);
const isNumericValue = typeof value === "number";
const displayValue = renderFormattedValue({ value, column, row, locale });
const align =
column.align ??
(columnIndex === 0
? "left"
: isNumericKind || isNumericValue
? "right"
: "left");
const alignClass = getAlignmentClass(align);
return (
<TableCell className={cn("px-5 py-3", alignClass, className)}>
{displayValue}
</TableCell>
);
}
function categorizeColumns(columns: Column[]) {
const primary: Column[] = [];
const secondary: Column[] = [];
let visibleColumnCount = 0;
columns.forEach((col) => {
if (col.hideOnMobile) return;
if (col.priority === "primary") {
primary.push(col);
} else if (col.priority === "secondary") {
secondary.push(col);
} else if (col.priority === "tertiary") {
return;
} else {
if (visibleColumnCount < 2) {
primary.push(col);
} else {
secondary.push(col);
}
visibleColumnCount++;
}
});
return { primary, secondary };
}
interface DataTableAccordionCardProps {
row: DataTableRowData;
index: number;
rowKey: string;
isFirst?: boolean;
}
function getDataTableRowDomId(rowKey: string): string {
return encodeURIComponent(rowKey).replace(/%/g, "_");
}
function DataTableAccordionCard({
row,
index,
rowKey,
isFirst = false,
}: DataTableAccordionCardProps) {
const { columns, locale } = useDataTable();
const { primary, secondary } = React.useMemo(
() => categorizeColumns(columns),
[columns],
);
if (secondary.length === 0) {
return (
<SimpleCard
row={row}
columns={primary}
index={index}
rowKey={rowKey}
isFirst={isFirst}
/>
);
}
const primaryColumn = primary[0];
const remainingPrimaryColumns = primary.slice(1);
const stableRowId = getDataTableRowDomId(rowKey);
const headingId = `row-${stableRowId}-heading`;
const detailsId = `row-${stableRowId}-details`;
const remainingPrimaryDataIds = remainingPrimaryColumns.map(
(col) => `row-${stableRowId}-${String(col.key)}`,
);
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
const accordionItemId = `row-${stableRowId}`;
return (
<Accordion
type="single"
collapsible
className={cn(!isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
<AccordionItem value={accordionItemId} className="group border-0">
<AccordionTrigger
className="group-data-[state=closed]:hover:bg-accent/50 active:bg-accent/50 group-data-[state=open]:bg-muted w-full rounded-none px-4 py-3 hover:no-underline"
aria-controls={detailsId}
aria-label={`${rowLabel}. ${secondary.length > 0 ? "Expand for details" : ""}`}
>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{primaryColumn && (
<div
id={headingId}
role="heading"
aria-level={3}
className="truncate"
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{remainingPrimaryColumns.length > 0 && (
<div
className="text-muted-foreground flex w-full flex-wrap gap-x-4 gap-y-0.5"
role="group"
aria-label="Summary information"
>
{remainingPrimaryColumns.map((col, idx) => (
<span
key={col.key}
id={remainingPrimaryDataIds[idx]}
className="flex min-w-0 gap-1 font-normal"
role="cell"
aria-label={`${col.label}: ${row[col.key]}`}
>
<span className="sr-only">{col.label}:</span>
<span aria-hidden="true">{col.label}:</span>
<span className="truncate">
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</span>
))}
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent
className={"flex flex-col gap-4 px-4 pb-4"}
id={detailsId}
role="region"
aria-labelledby={headingId}
>
{secondary.length > 0 && (
<dl
className={cn(
"flex flex-col gap-2 pt-4",
"motion-safe:group-data-[state=open]:animate-in motion-safe:group-data-[state=open]:fade-in-0",
"motion-safe:group-data-[state=open]:slide-in-from-top-1",
"motion-safe:group-data-[state=closed]:animate-out motion-safe:group-data-[state=closed]:fade-out-0",
"motion-safe:group-data-[state=closed]:slide-out-to-top-1",
"duration-150",
)}
role="list"
aria-label="Additional data"
>
{secondary.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="listitem"
>
<dt
className="text-muted-foreground shrink-0"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}
</dt>
<dd
className={cn(
"text-foreground min-w-0 text-pretty wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</dd>
</div>
))}
</dl>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
/**
* Simple card with no accordion, for when there are only primary columns
*/
function SimpleCard({
row,
columns,
index,
rowKey,
isFirst = false,
}: {
row: DataTableRowData;
columns: Column[];
index: number;
rowKey: string;
isFirst?: boolean;
}) {
const { locale } = useDataTable();
const primaryColumn = columns[0];
const otherColumns = columns.slice(1);
const stableRowId = getDataTableRowDomId(rowKey);
const primaryValue = primaryColumn
? String(row[primaryColumn.key] ?? "")
: "";
const rowLabel = `Row ${index + 1}: ${primaryValue}`;
return (
<div
className={cn("flex flex-col gap-2 p-4", !isFirst && "border-t")}
role="listitem"
aria-label={rowLabel}
>
{primaryColumn && (
<div
role="heading"
aria-level={3}
aria-label={`${primaryColumn.label}: ${row[primaryColumn.key]}`}
>
{renderFormattedValue({
value: row[primaryColumn.key],
column: primaryColumn,
row,
locale,
})}
</div>
)}
{otherColumns.map((col) => (
<div
key={col.key}
className="flex items-start justify-between gap-4"
role="group"
>
<span
className="text-muted-foreground"
id={`row-${stableRowId}-${String(col.key)}-label`}
>
{col.label}:
</span>
<span
className={cn(
"min-w-0 wrap-break-word",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
)}
role="cell"
aria-labelledby={`row-${stableRowId}-${String(col.key)}-label`}
>
{renderFormattedValue({
value: row[col.key],
column: col,
row,
locale,
})}
</span>
</div>
))}
</div>
);
}
@@ -1,514 +0,0 @@
"use client";
import * as React from "react";
import { cn, Badge, Tooltip, TooltipContent, TooltipTrigger } from "./_adapter";
function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
const candidate = href.trim();
if (!candidate) return undefined;
if (
candidate.startsWith("/") ||
candidate.startsWith("./") ||
candidate.startsWith("../") ||
candidate.startsWith("?") ||
candidate.startsWith("#")
) {
if (candidate.startsWith("//")) return undefined;
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
return candidate;
}
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}
function resolveSafeNavigationHref(
...candidates: Array<string | null | undefined>
): string | undefined {
for (const candidate of candidates) {
const safeHref = sanitizeHref(candidate ?? undefined);
if (safeHref) {
return safeHref;
}
}
return undefined;
}
type Tone = "success" | "warning" | "danger" | "info" | "neutral";
export type FormatConfig =
| { kind: "text" }
| {
kind: "number";
decimals?: number;
unit?: string;
compact?: boolean;
showSign?: boolean;
}
| { kind: "currency"; currency: string; decimals?: number }
| {
kind: "percent";
decimals?: number;
showSign?: boolean;
basis?: "fraction" | "unit";
}
| { kind: "date"; dateFormat?: "short" | "long" | "relative" }
| {
kind: "delta";
decimals?: number;
upIsPositive?: boolean;
showSign?: boolean;
}
| {
kind: "status";
statusMap: Record<string, { tone: Tone; label?: string }>;
}
| { kind: "boolean"; labels?: { true: string; false: string } }
| { kind: "link"; hrefKey?: string; external?: boolean }
| { kind: "badge"; colorMap?: Record<string, Tone> }
| { kind: "array"; maxVisible?: number };
interface DeltaValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "delta" }>;
locale?: string;
}
function DeltaValue({ value, options, locale }: DeltaValueProps) {
const decimals = options?.decimals ?? 2;
const upIsPositive = options?.upIsPositive ?? true;
const showSign = options?.showSign ?? true;
const isPositive = value > 0;
const isNegative = value < 0;
const isNeutral = value === 0;
const isGood = upIsPositive ? isPositive : isNegative;
const isBad = upIsPositive ? isNegative : isPositive;
const colorClass = isGood
? "text-green-700 dark:text-green-500"
: isBad
? "text-destructive"
: "text-muted-foreground";
const absValue = Math.abs(value);
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(absValue);
const display =
showSign && !isNeutral
? isNegative
? `-${formatted}`
: `+${formatted}`
: formatted;
const arrow = isPositive ? "↑" : isNegative ? "↓" : "";
return (
<span className={cn("tabular-nums", colorClass)}>
{display}
{!isNeutral && <span className="ml-0.5">{arrow}</span>}
</span>
);
}
interface StatusBadgeProps {
value: string;
options?: Extract<FormatConfig, { kind: "status" }>;
}
function StatusBadge({ value, options }: StatusBadgeProps) {
const config = options?.statusMap?.[value] ?? {
tone: "neutral" as Tone,
label: value,
};
const label = config.label ?? value;
const variant =
config.tone === "danger"
? "destructive"
: config.tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
config.tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
config.tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
config.tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
config.tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{label}
</Badge>
);
}
interface CurrencyValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "currency" }>;
locale?: string;
}
function CurrencyValue({ value, options, locale }: CurrencyValueProps) {
const currency = options?.currency ?? "USD";
const decimals = options?.decimals ?? 2;
const formatted = new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
return <span className="tabular-nums">{formatted}</span>;
}
interface PercentValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "percent" }>;
locale?: string;
}
function PercentValue({ value, options, locale }: PercentValueProps) {
const decimals = options?.decimals ?? 2;
const showSign = options?.showSign ?? false;
const basis = options?.basis ?? "fraction";
const numeric = basis === "fraction" ? value : value / 100;
const formatted = new Intl.NumberFormat(locale, {
style: "percent",
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
signDisplay: showSign ? "always" : "auto",
}).format(numeric);
return <span className="tabular-nums">{formatted}</span>;
}
interface DateValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "date" }>;
locale?: string;
}
function DateValue({ value, options, locale }: DateValueProps) {
const dateFormat = options?.dateFormat ?? "short";
const date = new Date(value);
if (isNaN(date.getTime())) {
return <span className="text-muted-foreground">{value}</span>;
}
let formatted: string;
if (dateFormat === "relative") {
formatted = getRelativeTime(date, locale);
} else if (dateFormat === "long") {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
} else {
formatted = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
const title = new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
return (
<span className="tabular-nums" title={title}>
{formatted}
</span>
);
}
function getRelativeTime(date: Date, locale?: string): string {
const now = new Date();
const diffInSeconds = Math.trunc((date.getTime() - now.getTime()) / 1000);
const absDiffInSeconds = Math.abs(diffInSeconds);
if (absDiffInSeconds < 60) return "just now";
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
if (absDiffInSeconds < 3600) {
const mins = Math.trunc(diffInSeconds / 60);
return rtf.format(mins, "minute");
}
if (absDiffInSeconds < 86400) {
const hours = Math.trunc(diffInSeconds / 3600);
return rtf.format(hours, "hour");
}
if (absDiffInSeconds < 604800) {
const days = Math.trunc(diffInSeconds / 86400);
return rtf.format(days, "day");
}
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
interface BooleanValueProps {
value: boolean;
options?: Extract<FormatConfig, { kind: "boolean" }>;
}
function BooleanValue({ value, options }: BooleanValueProps) {
const labels = options?.labels ?? { true: "Yes", false: "No" };
const label = value ? labels.true : labels.false;
const variant = value ? "secondary" : "outline";
return <Badge variant={variant}>{label}</Badge>;
}
interface LinkValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "link" }>;
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
}
function LinkValue({ value, options, row }: LinkValueProps) {
const rawHref =
options?.hrefKey && row ? String(row[options.hrefKey] ?? "") : value;
const href = resolveSafeNavigationHref(rawHref);
const external = options?.external ?? false;
if (!href) {
return <span>{value}</span>;
}
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className="text-accent-foreground inline-block max-w-full break-words underline underline-offset-2 hover:opacity-90"
aria-label={external ? `${value} (opens in a new tab)` : undefined}
onClick={(e) => e.stopPropagation()}
>
{value}
{external && (
<span className="ml-1 inline-block" aria-label="Opens in new tab">
</span>
)}
</a>
);
}
interface NumberValueProps {
value: number;
options?: Extract<FormatConfig, { kind: "number" }>;
locale?: string;
}
function NumberValue({ value, options, locale }: NumberValueProps) {
const decimals = options?.decimals ?? 0;
const unit = options?.unit ?? "";
const compact = options?.compact ?? false;
const showSign = options?.showSign ?? false;
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
notation: compact ? "compact" : "standard",
}).format(value);
const display = showSign && value > 0 ? `+${formatted}` : formatted;
return (
<span className="tabular-nums">
{display}
{unit}
</span>
);
}
interface BadgeValueProps {
value: string;
options?: Extract<FormatConfig, { kind: "badge" }>;
}
function BadgeValue({ value, options }: BadgeValueProps) {
const tone = options?.colorMap?.[value] ?? "neutral";
const variant =
tone === "danger"
? "destructive"
: tone === "neutral"
? "outline"
: "secondary";
return (
<Badge
variant={variant}
className={cn(
"border",
tone === "warning" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-100",
tone === "success" &&
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-100",
tone === "info" &&
"bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-100",
tone === "danger" &&
"bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-100",
)}
>
{value}
</Badge>
);
}
interface ArrayValueProps {
value: (string | number | boolean | null)[] | string;
options?: Extract<FormatConfig, { kind: "array" }>;
}
function ArrayValue({ value, options }: ArrayValueProps) {
const maxVisible = options?.maxVisible ?? 3;
const items: (string | number | boolean | null)[] = Array.isArray(value)
? value
: typeof value === "string"
? value.split(",").map((s) => s.trim())
: [];
if (items.length === 0) {
return <span className="text-muted"></span>;
}
const visible = items.slice(0, maxVisible);
const remaining = items.length - maxVisible;
const hidden = items.slice(maxVisible);
return (
<span className="inline-flex flex-wrap items-center gap-1">
{visible.map((item, i) => (
<span
key={i}
className="bg-muted text-muted-foreground inline-flex items-center rounded-md px-2 py-0.5"
>
{item === null ? "null" : String(item)}
</span>
))}
{remaining > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">
+{remaining} more
</span>
</TooltipTrigger>
<TooltipContent>
{hidden
.map((item) => (item === null ? "null" : String(item)))
.join(", ")}
</TooltipContent>
</Tooltip>
)}
</span>
);
}
interface RenderFormattedValueParams {
value:
| string
| number
| boolean
| null
| (string | number | boolean | null)[];
column: { format?: FormatConfig };
row?: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>;
locale?: string;
}
export function renderFormattedValue({
value,
column,
row,
locale,
}: RenderFormattedValueParams): React.ReactNode {
if (value == null || value === "") {
return <span className="text-muted"></span>;
}
const fmt = column.format;
switch (fmt?.kind) {
case "delta":
return <DeltaValue value={Number(value)} options={fmt} locale={locale} />;
case "status":
return <StatusBadge value={String(value)} options={fmt} />;
case "currency":
return (
<CurrencyValue value={Number(value)} options={fmt} locale={locale} />
);
case "percent":
return (
<PercentValue value={Number(value)} options={fmt} locale={locale} />
);
case "date":
return <DateValue value={String(value)} options={fmt} locale={locale} />;
case "boolean":
return <BooleanValue value={Boolean(value)} options={fmt} />;
case "link":
return <LinkValue value={String(value)} options={fmt} row={row} />;
case "number":
return (
<NumberValue value={Number(value)} options={fmt} locale={locale} />
);
case "badge":
return <BadgeValue value={String(value)} options={fmt} />;
case "array":
return (
<ArrayValue
value={Array.isArray(value) ? value : String(value)}
options={fmt}
/>
);
case "text":
default:
return String(value);
}
}
@@ -1,262 +0,0 @@
import type { ToolUIId, ToolUIReceipt, ToolUIRole } from "../shared/schema";
import type { FormatConfig } from "./formatters";
/**
* JSON primitive type that can be serialized.
*/
type JsonPrimitive = string | number | boolean | null;
/**
* Valid row value types for serializable DataTable data.
*
* Supports:
* - Primitives: string, number, boolean, null
* - Arrays of primitives: string[], number[], boolean[], or mixed primitive arrays
*
* For complex data (objects with href/label, etc.), use column format configs
* instead of putting objects in row data.
*
* @example
* ```ts
* // 👍 Good: Use primitives and primitive arrays
* const row = {
* name: "Widget",
* price: 29.99,
* tags: ["electronics", "featured"],
* metrics: [1.2, 3.4, 5.6]
* }
*
* // 🚫 Bad: Don't put objects in row data
* const row = {
* link: { href: "/path", label: "Click" } // Use format: { kind: 'link' } instead
* }
* ```
*/
export type RowPrimitive = JsonPrimitive | JsonPrimitive[];
export type DataTableRowData = Record<string, RowPrimitive>;
export type RowData = Record<string, unknown>;
export type ColumnKey<T extends object> = Extract<keyof T, string>;
export type FormatFor<V> = V extends number
? Extract<FormatConfig, { kind: "number" | "currency" | "percent" | "delta" }>
: V extends boolean
? Extract<FormatConfig, { kind: "boolean" | "status" | "badge" }>
: V extends (string | number | boolean | null)[]
? Extract<FormatConfig, { kind: "array" }>
: V extends string
? Extract<
FormatConfig,
{ kind: "text" | "link" | "date" | "badge" | "status" }
>
: Extract<FormatConfig, { kind: "text" }>;
/**
* Column definition for DataTable
*
* @remarks
* **Important:** Columns are sortable by default (opt-out pattern).
* Set `sortable: false` explicitly to disable sorting for specific columns.
*/
export interface Column<
T extends object = DataTableRowData,
K extends ColumnKey<T> = ColumnKey<T>,
> {
/** Unique identifier that maps to a key in the row data */
key: K;
/** Display text for the column header */
label: string;
/** Abbreviated label for narrow viewports */
abbr?: string;
/** Whether column is sortable. Default: true (opt-out pattern) */
sortable?: boolean;
/** Text alignment for column cells */
align?: "left" | "right" | "center";
/** Optional fixed width (CSS value) */
width?: string;
/** Enable text truncation with ellipsis */
truncate?: boolean;
/** Mobile display priority (primary = always visible, secondary = expandable, tertiary = hidden) */
priority?: "primary" | "secondary" | "tertiary";
/** Completely hide column on mobile viewports */
hideOnMobile?: boolean;
/** Formatting configuration for cell values */
format?: FormatFor<T[K]>;
}
/**
* Serializable props that can come from LLM tool calls or be JSON-serialized.
*
* These props contain only primitive values, arrays, and plain objects -
* no functions, class instances, or other non-serializable values.
*
* @example
* ```tsx
* const serializableProps: DataTableSerializableProps = {
* columns: [...],
* data: [...],
* rowIdKey: "id",
* defaultSort: { by: "price", direction: "desc" }
* }
* ```
*/
export interface DataTableSerializableProps<T extends object = RowData> {
/**
* Unique identifier for this tool UI instance in the conversation.
*
* Used for:
* - Assistant referencing ("the table above")
* - Receipt generation (linking actions to their source)
* - Narration context
*
* Should be stable across re-renders, meaningful, and unique within the conversation.
*
* @example "data-table-expenses-q3", "search-results-repos"
*/
id: ToolUIId;
/** Optional surface role metadata (serializable) */
role?: ToolUIRole;
/** Optional receipt metadata for consequential outcomes (serializable) */
receipt?: ToolUIReceipt;
/** Column definitions */
columns: Column<T>[];
/** Row data (primitives only - no functions or class instances) */
data: T[];
/**
* Key in row data to use as unique identifier for React keys
*
* **Strongly recommended:** Always provide this for dynamic data to prevent
* reconciliation issues (focus traps, animation glitches, incorrect state preservation)
* when data reorders. Falls back to array index if omitted (only acceptable for static mock data).
*
* @example rowIdKey="id" or rowIdKey="uuid"
*/
rowIdKey?: ColumnKey<T>;
/**
* Uncontrolled initial sort state (table manages its own sort state internally)
*
* **Sorting cycle:** Clicking column headers cycles through tri-state:
* 1. none (unsorted) → 2. asc → 3. desc → 4. none (back to unsorted)
*
* @example
* ```tsx
* // Start with descending price sort
* <DataTable defaultSort={{ by: "price", direction: "desc" }} />
* ```
*/
defaultSort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/**
* Controlled sort state (use with onSortChange from client props)
*
* When provided, you must also provide `onSortChange` to handle sort updates.
* The table will cycle through: none → asc → desc → none.
*
* @example
* ```tsx
* const [sort, setSort] = useState({ by: "price", direction: "desc" })
* <DataTable sort={sort} onSortChange={setSort} />
* ```
*/
sort?: { by?: ColumnKey<T>; direction?: "asc" | "desc" };
/** Empty state message */
emptyMessage?: string;
/** Max table height with vertical scroll (CSS value) */
maxHeight?: string;
/**
* BCP47 locale for formatting and sorting (e.g., 'en-US', 'de-DE', 'ja-JP')
*
* Defaults to 'en-US' to ensure consistent server/client rendering.
* Pass explicit locale for internationalization.
*
* @example
* ```tsx
* <DataTable locale="de-DE" /> // German formatting
* <DataTable locale="ja-JP" /> // Japanese formatting
* <DataTable /> // Uses 'en-US' default
* ```
*/
locale?: string;
}
/**
* Client-side React-only props that cannot be serialized.
*
* These props contain functions, component state, or other React-specific values
* that must be provided by your React code (not from LLM tool calls).
*
* @example
* ```tsx
* const clientProps: DataTableClientProps = {
* className: "my-table",
* onSortChange: (next) => setSort(next),
* // Compose local/decision actions externally via LocalActions/DecisionActions
* }
* ```
*/
export interface DataTableClientProps<T extends object = RowData> {
/** Additional CSS classes */
className?: string;
/**
* Sort change handler for controlled mode (required if sort is provided)
*
* **Tri-state cycle behavior:**
* - Click unsorted column: `{ by: "column", direction: "asc" }`
* - Click asc column: `{ by: "column", direction: "desc" }`
* - Click desc column: `{ by: "column", direction: undefined }` (returns to unsorted)
* - Click different column: `{ by: "newColumn", direction: "asc" }`
*
* @example
* ```tsx
* const [sort, setSort] = useState<{ by?: string; direction?: "asc" | "desc" }>({})
*
* <DataTable
* sort={sort}
* onSortChange={(next) => {
* console.log("Sort changed:", next)
* setSort(next)
* }}
* />
* ```
*/
onSortChange?: (next: {
by?: ColumnKey<T>;
direction?: "asc" | "desc";
}) => void;
}
/**
* Complete props for the DataTable component.
*
* Combines serializable props (can come from LLM tool calls) with client-side
* React-only props. This separation makes the boundary explicit and prevents
* accidental serialization of non-serializable values.
*
* @see {@link DataTableSerializableProps} for props that can be JSON-serialized
* @see {@link DataTableClientProps} for React-only props
* @see {@link parseSerializableDataTable} for parsing LLM tool call results
*
* @example
* ```tsx
* // From LLM tool call
* const serializableProps = parseSerializableDataTable(llmResult)
*
* // Combine with React-specific props
* <DataTable
* {...serializableProps}
* onSortChange={setSort}
* // Render sibling LocalActions / DecisionActions where needed
* />
* ```
*/
export interface DataTableProps<T extends object = RowData>
extends DataTableSerializableProps<T>, DataTableClientProps<T> {}
export interface DataTableContextValue<T extends object = RowData> {
columns: Column<T>[];
data: T[];
rowIdKey?: ColumnKey<T>;
sortBy?: ColumnKey<T>;
sortDirection?: "asc" | "desc";
toggleSort?: (key: ColumnKey<T>) => void;
id?: string;
locale?: string;
}
@@ -1,296 +0,0 @@
export function sortData<T, K extends Extract<keyof T, string>>(
data: T[],
key: K,
direction: "asc" | "desc",
locale?: string,
): T[] {
const get = (obj: T, k: K): unknown => (obj as Record<string, unknown>)[k];
const collator = new Intl.Collator(locale, {
numeric: true,
sensitivity: "base",
});
return [...data].sort((a, b) => {
const aVal = get(a, key);
const bVal = get(b, key);
// Handle nulls
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Type-specific comparison
// Numbers
if (typeof aVal === "number" && typeof bVal === "number") {
return direction === "asc" ? aVal - bVal : bVal - aVal;
}
// Dates (Date instances)
if (aVal instanceof Date && bVal instanceof Date) {
const diff = aVal.getTime() - bVal.getTime();
return direction === "asc" ? diff : -diff;
}
// Booleans: false < true
if (typeof aVal === "boolean" && typeof bVal === "boolean") {
const diff = aVal === bVal ? 0 : aVal ? 1 : -1;
return direction === "asc" ? diff : -diff;
}
// Arrays: compare length
if (Array.isArray(aVal) && Array.isArray(bVal)) {
const diff = aVal.length - bVal.length;
return direction === "asc" ? diff : -diff;
}
// Strings that look like numbers -> numeric compare
if (typeof aVal === "string" && typeof bVal === "string") {
const numA = parseNumericLike(aVal);
const numB = parseNumericLike(bVal);
if (numA != null && numB != null) {
const diff = numA - numB;
return direction === "asc" ? diff : -diff;
}
// ISO-like date strings
if (/^\d{4}-\d{2}-\d{2}/.test(aVal) && /^\d{4}-\d{2}-\d{2}/.test(bVal)) {
const da = new Date(aVal).getTime();
const db = new Date(bVal).getTime();
const diff = da - db;
return direction === "asc" ? diff : -diff;
}
}
// Fallback: locale-aware string compare with numeric collation
const aStr = String(aVal);
const bStr = String(bVal);
const comparison = collator.compare(aStr, bStr);
return direction === "asc" ? comparison : -comparison;
});
}
/**
* Return a human-friendly identifier for a row using common keys
*
* Accepts any JSON-serializable primitive or array of primitives.
* Arrays are converted to comma-separated strings.
*/
function getRowIdentifier(
row: Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>,
identifierKey?: string,
): string {
const candidate =
(identifierKey ? row[identifierKey] : undefined) ??
(row as Record<string, unknown>).name ??
(row as Record<string, unknown>).title ??
(row as Record<string, unknown>).id;
if (candidate == null) {
return "";
}
// Handle arrays by joining them
if (Array.isArray(candidate)) {
return candidate.map((v) => (v === null ? "null" : String(v))).join(", ");
}
return String(candidate).trim();
}
function stableStringify(value: unknown): string {
if (value == null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(
([a], [b]) => a.localeCompare(b),
);
return `{${entries
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`;
}
return JSON.stringify(String(value));
}
function hashString(value: string): string {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = (hash * 33) ^ value.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
/**
* Create deterministic, reorder-stable React keys for DataTable rows.
*
* - Uses `identifierKey` or common identifier fields as the primary base.
* - Falls back to stable content fingerprints when no identifier exists.
* - Disambiguates duplicates without relying on array index.
*/
export function createDataTableRowKeys(
rows: Array<Record<string, unknown>>,
identifierKey?: string,
): string[] {
const canonicalRows = rows.map((row) => stableStringify(row));
const baseKeys = rows.map((row, index) => {
const identifier = getRowIdentifier(
row as Record<
string,
string | number | boolean | null | (string | number | boolean | null)[]
>,
identifierKey,
);
if (identifier) {
return `id:${identifier}`;
}
return `row:${hashString(canonicalRows[index])}`;
});
const baseCounts = new Map<string, number>();
baseKeys.forEach((key) => {
baseCounts.set(key, (baseCounts.get(key) ?? 0) + 1);
});
const usedKeys = new Map<string, number>();
return rows.map((row, index) => {
const baseKey = baseKeys[index];
if ((baseCounts.get(baseKey) ?? 0) === 1) {
return baseKey;
}
const rowFingerprint = hashString(canonicalRows[index]);
let disambiguatedKey = `${baseKey}::${rowFingerprint}`;
const seenCount = usedKeys.get(disambiguatedKey) ?? 0;
usedKeys.set(disambiguatedKey, seenCount + 1);
if (seenCount > 0) {
disambiguatedKey = `${disambiguatedKey}::d${seenCount + 1}`;
}
return disambiguatedKey;
});
}
function sanitizeDomIdToken(value: string): string {
return encodeURIComponent(value).replace(/%/g, "_");
}
export function getDataTableMobileDescriptionId(surfaceId: string): string {
return `${sanitizeDomIdToken(surfaceId)}-mobile-table-description`;
}
/**
* Parse a string that represents a numeric value, handling various formats:
* - Currency symbols: $, €, £, ¥, etc.
* - Percent symbols: %
* - Accounting negatives: (1234) → -1234
* - Thousands/decimal separators: 1,234.56 or 1.234,56
* - Compact notation: 2.8T (trillion), 1.5M (million), 500K (thousand)
* - Byte suffixes: 768B (bytes), 1.5KB, 2GB, 1TB
*
* Note: Single "B" is disambiguated - integers < 1024 are bytes, otherwise billions.
*
* @param input - String to parse
* @returns Parsed number or null if unparseable
*
* @example
* parseNumericLike("$1,234.56") // 1234.56
* parseNumericLike("2.8T") // 2800000000000
* parseNumericLike("768B") // 768
* parseNumericLike("50%") // 50
* parseNumericLike("(1234)") // -1234
*/
function parseNumericLike(input: string): number | null {
// Normalize whitespace (spaces, NBSPs, thin spaces)
let s = input.replace(/[\u00A0\u202F\s]/g, "").trim();
if (!s) return null;
// Accounting negatives: (1234) -> -1234
s = s.replace(/^\((.*)\)$/g, "-$1");
// Strip common currency and percent symbols
s = s.replace(/[%$€£¥₩₹₽₺₪₫฿₦₴₡₲₵₸]/g, "");
function hasGroupedThousands(value: string, sep: "," | "."): boolean {
const unsigned = value.replace(/^[+-]/, "");
const parts = unsigned.split(sep);
if (parts.length < 2) return false;
if (parts.some((part) => part.length === 0)) return false;
if (!/^\d{1,3}$/.test(parts[0])) return false;
if (parts[0] === "0") return false;
return parts.slice(1).every((part) => /^\d{3}$/.test(part));
}
const lastComma = s.lastIndexOf(",");
const lastDot = s.lastIndexOf(".");
if (lastComma !== -1 && lastDot !== -1) {
// Decide decimal by whichever occurs last
const decimalSep = lastComma > lastDot ? "," : ".";
const thousandSep = decimalSep === "," ? "." : ",";
s = s.split(thousandSep).join("");
s = s.replace(decimalSep, ".");
} else if (lastComma !== -1) {
// Only comma present
if (hasGroupedThousands(s, ",")) {
s = s.replace(/,/g, "");
} else {
const frac = s.length - lastComma - 1;
if (frac >= 1 && frac <= 3) s = s.replace(/,/g, ".");
else s = s.replace(/,/g, "");
}
} else if (lastDot !== -1) {
// Only dot present; normalize grouped thousands separators.
if (hasGroupedThousands(s, ".")) {
s = s.replace(/\./g, "");
} else if ((s.match(/\./g) || []).length > 1) {
s = s.replace(/\./g, "");
}
}
// Handle compact notation (K, M, B, T, P, G) and byte suffixes (KB, MB, GB, TB, PB)
const compactMatch = s.match(/^([+-]?\d+\.?\d*|\d*\.\d+)([KMBTPG]B?|B)$/i);
if (compactMatch) {
const baseNum = Number(compactMatch[1]);
if (Number.isNaN(baseNum)) return null;
const suffix = compactMatch[2].toUpperCase();
// Disambiguate single "B" (bytes vs billions)
// If whole number < 1024, treat as bytes. Otherwise, billions.
if (suffix === "B") {
const isLikelyBytes = Number.isInteger(baseNum) && baseNum < 1024;
return isLikelyBytes ? baseNum : baseNum * 1e9;
}
const multipliers: Record<string, number> = {
K: 1e3,
KB: 1024, // Kilo: metric vs binary
M: 1e6,
MB: 1024 ** 2, // Mega
G: 1e9,
GB: 1024 ** 3, // Giga
T: 1e12,
TB: 1024 ** 4, // Tera
P: 1e15,
PB: 1024 ** 5, // Peta
};
return baseNum * (multipliers[suffix] ?? 1);
}
if (/^[+-]?(?:\d+\.?\d*|\d*\.\d+)$/.test(s)) {
const n = Number(s);
return Number.isNaN(n) ? null : n;
}
return null;
}
@@ -1,19 +0,0 @@
# Message Draft
Implementation for the "message-draft" Tool UI surface.
## Files
- public exports: components/tool-ui/message-draft/index.tsx
- serializable schema + parse helpers: components/tool-ui/message-draft/schema.ts
## Companion assets
- Docs page: app/docs/message-draft/content.mdx
- Preset payload: lib/presets/message-draft.ts
## Quick check
Run this after edits:
pnpm test
@@ -1,2 +0,0 @@
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
@@ -1,10 +0,0 @@
export { MessageDraft } from "./message-draft";
export {
type SerializableMessageDraft,
type SerializableEmailDraft,
type SerializableSlackDraft,
type MessageDraftChannel,
type MessageDraftOutcome,
type SlackTarget,
type MessageDraftProps,
} from "./schema";
@@ -1,511 +0,0 @@
"use client";
import * as React from "react";
import { cn, Button } from "./_adapter";
import type {
MessageDraftProps,
SerializableEmailDraft,
SerializableSlackDraft,
} from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import type { Action } from "../shared/schema";
import { Check, ChevronDown } from "lucide-react";
type DraftState = "review" | "sending" | "sent" | "cancelled";
type DraftOutcome = MessageDraftProps["outcome"];
const DEFAULT_GRACE_PERIOD = 5000;
const COLLAPSED_BODY_HEIGHT = 280;
interface RecipientRowProps {
label: string;
recipients: string[];
maxVisible?: number;
muted?: boolean;
}
function RecipientRow({
label,
recipients,
maxVisible = 3,
muted = false,
}: RecipientRowProps) {
const visibleRecipients = recipients.slice(0, maxVisible);
const overflowCount = recipients.length - maxVisible;
return (
<tr className="text-sm">
<td className="text-muted-foreground w-0 pr-4 pb-1 text-right align-top font-medium whitespace-nowrap">
{label}
</td>
<td className={cn("pb-1 align-top", muted && "text-muted-foreground")}>
{visibleRecipients.join(", ")}
{overflowCount > 0 && (
<span className="text-muted-foreground"> +{overflowCount} more</span>
)}
</td>
</tr>
);
}
interface SingleFieldRowProps {
label: string;
value: string;
}
function SingleFieldRow({ label, value }: SingleFieldRowProps) {
return (
<tr className="text-sm">
<td className="text-muted-foreground w-0 pr-4 pb-1 text-right align-top font-medium whitespace-nowrap">
{label}
</td>
<td className="pb-1 align-top">{value}</td>
</tr>
);
}
interface ExpandableBodyProps {
body: string;
isExpanded: boolean;
onNeedsExpansionChange?: (needsExpansion: boolean) => void;
}
function ExpandableBody({
body,
isExpanded,
onNeedsExpansionChange,
}: ExpandableBodyProps) {
const [needsExpansion, setNeedsExpansion] = React.useState<boolean | null>(
null,
);
const contentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
if (contentRef.current) {
const needs = contentRef.current.scrollHeight > COLLAPSED_BODY_HEIGHT;
setNeedsExpansion(needs);
onNeedsExpansionChange?.(needs);
}
}, [body, onNeedsExpansionChange]);
return (
<div className="relative">
<div
ref={contentRef}
className={cn(
"overflow-hidden text-sm leading-relaxed",
needsExpansion !== null &&
"transition-[max-height] duration-300 ease-in-out",
)}
style={{
maxHeight:
needsExpansion === null
? `${COLLAPSED_BODY_HEIGHT}px`
: isExpanded || !needsExpansion
? `${contentRef.current?.scrollHeight ?? 1000}px`
: `${COLLAPSED_BODY_HEIGHT}px`,
}}
>
<p className="pt-1 whitespace-pre-wrap">{body}</p>
</div>
{needsExpansion && (
<div
className={cn(
"from-card pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t to-transparent transition-[height] duration-300 ease-in-out",
isExpanded ? "h-0" : "h-12",
)}
/>
)}
</div>
);
}
interface EmailDraftContentProps {
draft: SerializableEmailDraft;
titleId: string;
isExpanded: boolean;
onNeedsExpansionChange?: (needsExpansion: boolean) => void;
}
function EmailDraftContent({
draft,
titleId,
isExpanded,
onNeedsExpansionChange,
}: EmailDraftContentProps) {
return (
<>
<h2 id={titleId} className="pt-2 text-base leading-tight font-semibold">
{draft.subject}
</h2>
<table className="w-full">
<tbody>
{draft.from && <SingleFieldRow label="From" value={draft.from} />}
<RecipientRow label="To" recipients={draft.to} />
{draft.cc && draft.cc.length > 0 && (
<RecipientRow label="Cc" recipients={draft.cc} />
)}
{draft.bcc && draft.bcc.length > 0 && (
<RecipientRow label="Bcc" recipients={draft.bcc} muted />
)}
</tbody>
</table>
<div className="bg-border -mx-5 h-px" role="separator" />
<ExpandableBody
body={draft.body}
isExpanded={isExpanded}
onNeedsExpansionChange={onNeedsExpansionChange}
/>
</>
);
}
interface SlackDraftContentProps {
draft: SerializableSlackDraft;
titleId: string;
isExpanded: boolean;
onNeedsExpansionChange?: (needsExpansion: boolean) => void;
}
function SlackLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#E01E5A"
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"
/>
<path
fill="#36C5F0"
d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z"
/>
<path
fill="#2EB67D"
d="M18.958 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z"
/>
<path
fill="#ECB22E"
d="M15.165 18.958a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.52v-2.522h2.521zm0-1.271a2.527 2.527 0 0 1-2.521-2.521 2.526 2.526 0 0 1 2.521-2.521h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.313z"
/>
</svg>
);
}
function SlackDraftContent({
draft,
titleId,
isExpanded,
onNeedsExpansionChange,
}: SlackDraftContentProps) {
const { target } = draft;
const isChannel = target.type === "channel";
const targetDisplay = isChannel
? `#${target.name}`
: `Message to @${target.name}`;
const memberCount = isChannel ? target.memberCount : undefined;
return (
<>
<div
id={titleId}
className="flex items-center gap-1.5 text-sm font-medium"
>
<SlackLogo className="size-4" />
<span>{targetDisplay}</span>
{memberCount !== undefined && (
<span className="text-muted-foreground ml-auto text-sm font-normal">
{memberCount.toLocaleString()} members
</span>
)}
</div>
<div className="bg-border -mx-5 h-px" role="separator" />
<ExpandableBody
body={draft.body}
isExpanded={isExpanded}
onNeedsExpansionChange={onNeedsExpansionChange}
/>
</>
);
}
function formatSentTime(date: Date): string {
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
export function resolveStateFromOutcome(outcome: DraftOutcome): DraftState {
if (outcome === "sent") return "sent";
if (outcome === "cancelled") return "cancelled";
return "review";
}
export function resolveOutcomeTransition(
previousOutcome: DraftOutcome,
nextOutcome: DraftOutcome,
): DraftState | null {
if (previousOutcome === nextOutcome) {
return null;
}
return resolveStateFromOutcome(nextOutcome);
}
interface SentConfirmationProps {
sentAt: Date;
}
function SentConfirmation({ sentAt }: SentConfirmationProps) {
return (
<div
className="flex items-center justify-end gap-2 text-sm"
role="status"
aria-label="Message sent"
>
<span className="text-muted-foreground">
Sent at {formatSentTime(sentAt)}
</span>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full">
<Check className="size-3.5" />
</span>
</div>
);
}
export function MessageDraft(props: MessageDraftProps) {
const {
id,
className,
outcome,
undoGracePeriod = DEFAULT_GRACE_PERIOD,
onSend,
onUndo,
onCancel,
} = props;
const [state, setState] = React.useState<DraftState>(() =>
resolveStateFromOutcome(outcome),
);
const [countdown, setCountdown] = React.useState(
Math.ceil(undoGracePeriod / 1000),
);
const [sentAt, setSentAt] = React.useState<Date | null>(() =>
outcome === "sent" ? new Date() : null,
);
const [isExpanded, setIsExpanded] = React.useState(false);
const [needsExpansion, setNeedsExpansion] = React.useState(false);
const undoButtonRef = React.useRef<HTMLButtonElement>(null);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownRef = React.useRef<ReturnType<typeof setInterval> | null>(
null,
);
const previousOutcomeRef = React.useRef<DraftOutcome>(outcome);
const clearTimers = React.useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
React.useEffect(() => {
return clearTimers;
}, [clearTimers]);
React.useEffect(() => {
const nextState = resolveOutcomeTransition(
previousOutcomeRef.current,
outcome,
);
previousOutcomeRef.current = outcome;
if (nextState === null) {
return;
}
clearTimers();
setState(nextState);
setCountdown(Math.ceil(undoGracePeriod / 1000));
setSentAt(nextState === "sent" ? new Date() : null);
}, [outcome, undoGracePeriod, clearTimers]);
React.useEffect(() => {
if (state === "sending") {
undoButtonRef.current?.focus();
setCountdown(Math.ceil(undoGracePeriod / 1000));
countdownRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
return 0;
}
return prev - 1;
});
}, 1000);
timerRef.current = setTimeout(async () => {
clearTimers();
await onSend?.();
setSentAt(new Date());
setState("sent");
}, undoGracePeriod);
}
}, [state, undoGracePeriod, onSend, clearTimers]);
const handleSend = React.useCallback(() => {
setState("sending");
}, []);
const handleUndo = React.useCallback(() => {
clearTimers();
setState("review");
onUndo?.();
}, [clearTimers, onUndo]);
const handleCancel = React.useCallback(() => {
clearTimers();
setState("cancelled");
onCancel?.();
}, [clearTimers, onCancel]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape" && state === "review") {
event.preventDefault();
handleCancel();
}
},
[state, handleCancel],
);
const handleNeedsExpansionChange = React.useCallback((needs: boolean) => {
setNeedsExpansion(needs);
}, []);
const handleToggleExpand = React.useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const handleAction = React.useCallback(
async (actionId: string) => {
if (actionId === "send") {
handleSend();
} else if (actionId === "cancel") {
handleCancel();
}
},
[handleSend, handleCancel],
);
const actions: Action[] = [
{
id: "cancel",
label: "Cancel",
variant: "ghost",
},
{
id: "send",
label: "Send",
variant: "default",
},
];
const expandButton = needsExpansion ? (
<Button
variant="ghost"
size="sm"
onClick={handleToggleExpand}
className="h-7 gap-1 px-2 text-sm"
>
{isExpanded ? "Show less" : "Read more"}
<ChevronDown className={cn("size-3", isExpanded && "rotate-180")} />
</Button>
) : null;
const renderActions = () => {
switch (state) {
case "sending":
return (
<div
className="flex items-center justify-end gap-3"
aria-live="polite"
>
<span className="text-muted-foreground text-sm">
Sending in {countdown}s
</span>
<Button
ref={undoButtonRef}
variant="outline"
size="sm"
onClick={handleUndo}
className="rounded-full"
>
Undo
</Button>
</div>
);
case "sent":
return <SentConfirmation sentAt={sentAt ?? new Date()} />;
case "cancelled":
return null;
default:
return <ActionButtons actions={actions} onAction={handleAction} />;
}
};
if (state === "cancelled") {
return null;
}
return (
<article
className={cn(
"flex w-full max-w-lg min-w-64 flex-col gap-3",
"text-foreground",
className,
)}
data-slot="message-draft"
data-tool-ui-id={id}
data-state={state}
aria-labelledby={`${id}-title`}
onKeyDown={handleKeyDown}
>
<div className="bg-card flex w-full flex-col gap-3 rounded-2xl border px-5 pt-3 pb-5 shadow-xs transition-none">
{props.channel === "email" ? (
<EmailDraftContent
draft={props}
titleId={`${id}-title`}
isExpanded={isExpanded}
onNeedsExpansionChange={handleNeedsExpansionChange}
/>
) : (
<SlackDraftContent
draft={props}
titleId={`${id}-title`}
isExpanded={isExpanded}
onNeedsExpansionChange={handleNeedsExpansionChange}
/>
)}
{expandButton}
</div>
<div className="@container/actions">{renderActions()}</div>
</article>
);
}
@@ -1,83 +0,0 @@
import { z } from "zod";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const MessageDraftChannelSchema = z.enum(["email", "slack"]);
export type MessageDraftChannel = z.infer<typeof MessageDraftChannelSchema>;
export const MessageDraftOutcomeSchema = z.enum(["sent", "cancelled"]);
export type MessageDraftOutcome = z.infer<typeof MessageDraftOutcomeSchema>;
const SlackTargetSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("channel"),
name: z.string().min(1),
memberCount: z.number().optional(),
}),
z.object({ type: z.literal("dm"), name: z.string().min(1) }),
]);
export type SlackTarget = z.infer<typeof SlackTargetSchema>;
export const SerializableEmailDraftSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
body: z.string().min(1),
outcome: MessageDraftOutcomeSchema.optional(),
channel: z.literal("email"),
subject: z.string().min(1),
from: z.string().optional(),
to: z.array(z.string()).min(1),
cc: z.array(z.string()).optional(),
bcc: z.array(z.string()).optional(),
});
export const SerializableSlackDraftSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
body: z.string().min(1),
outcome: MessageDraftOutcomeSchema.optional(),
channel: z.literal("slack"),
target: SlackTargetSchema,
});
export const SerializableMessageDraftSchema = z.discriminatedUnion("channel", [
SerializableEmailDraftSchema,
SerializableSlackDraftSchema,
]);
export type SerializableMessageDraft = z.infer<
typeof SerializableMessageDraftSchema
>;
export type SerializableEmailDraft = z.infer<
typeof SerializableEmailDraftSchema
>;
export type SerializableSlackDraft = z.infer<
typeof SerializableSlackDraftSchema
>;
const SerializableMessageDraftSchemaContract = defineToolUiContract(
"MessageDraft",
SerializableMessageDraftSchema,
);
export const parseSerializableMessageDraft: (
input: unknown,
) => SerializableMessageDraft = SerializableMessageDraftSchemaContract.parse;
export const safeParseSerializableMessageDraft: (
input: unknown,
) => SerializableMessageDraft | null =
SerializableMessageDraftSchemaContract.safeParse;
export type MessageDraftProps = SerializableMessageDraft & {
className?: string;
undoGracePeriod?: number;
onSend?: () => void | Promise<void>;
onUndo?: () => void;
onCancel?: () => void;
};
@@ -1,7 +0,0 @@
export { OptionList } from "./option-list";
export type {
OptionListProps,
OptionListOption,
OptionListSelection,
SerializableOptionList,
} from "./schema";
@@ -4,13 +4,11 @@ import type { ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import {
ActionSchema,
SerializableActionSchema,
SerializableActionsConfigSchema,
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
export const OptionListOptionSchema = z.object({
id: z.string().min(1),
@@ -173,38 +171,4 @@ export type OptionListProps = Omit<
onAction?: EmbeddedActionsProps<OptionListSelection>["onAction"];
onBeforeAction?: EmbeddedActionsProps<OptionListSelection>["onBeforeAction"];
className?: string;
};
export const SerializableOptionListSchema = OptionListPropsSchemaBase.omit({
// Exclude controlled selection from tool/LLM payloads.
value: true,
})
.extend({
options: z.array(OptionListOptionSchema.omit({ icon: true })),
actions: z
.union([
z.array(SerializableActionSchema),
SerializableActionsConfigSchema,
])
.optional(),
})
.strict()
.superRefine(validateOptionListInvariants);
export type SerializableOptionList = z.infer<
typeof SerializableOptionListSchema
>;
const SerializableOptionListSchemaContract = defineToolUiContract(
"OptionList",
SerializableOptionListSchema,
);
export const parseSerializableOptionList: (
input: unknown,
) => SerializableOptionList = SerializableOptionListSchemaContract.parse;
export const safeParseSerializableOptionList: (
input: unknown,
) => SerializableOptionList | null =
SerializableOptionListSchemaContract.safeParse;
};
@@ -1,19 +0,0 @@
# Progress Tracker
Implementation for the "progress-tracker" Tool UI surface.
## Files
- public exports: components/tool-ui/progress-tracker/index.tsx
- serializable schema + parse helpers: components/tool-ui/progress-tracker/schema.ts
## Companion assets
- Docs page: app/docs/progress-tracker/content.mdx
- Preset payload: lib/presets/progress-tracker.ts
## Quick check
Run this after edits:
pnpm test
@@ -1 +0,0 @@
export { cn } from "@/lib/utils";
@@ -1,7 +0,0 @@
export { ProgressTracker } from "./progress-tracker";
export {
type SerializableProgressTracker,
type ProgressTrackerProps,
type ProgressTrackerChoice,
type ProgressStep,
} from "./schema";
@@ -1,381 +0,0 @@
import { cn } from "./_adapter";
import type {
ProgressStep,
ProgressTrackerChoice,
ProgressTrackerProps,
} from "./schema";
import { Check, X, Loader2, Timer, AlertCircle } from "lucide-react";
import type { LucideIcon } from "lucide-react";
function formatElapsedTime(milliseconds: number): string {
const roundedSeconds = Math.round(Math.max(0, milliseconds) / 100) / 10;
if (roundedSeconds < 60) {
return `${roundedSeconds.toFixed(1)}s`;
}
const wholeSeconds = Math.floor(roundedSeconds);
const minutes = Math.floor(wholeSeconds / 60);
const remainingSeconds = wholeSeconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
function formatElapsedTimeDateTime(milliseconds: number): string {
const roundedSeconds = Math.round(Math.max(0, milliseconds) / 100) / 10;
if (roundedSeconds < 60) {
return `PT${Number(roundedSeconds.toFixed(1))}S`;
}
const wholeSeconds = Math.floor(roundedSeconds);
const hours = Math.floor(wholeSeconds / 3600);
const minutes = Math.floor((wholeSeconds % 3600) / 60);
const seconds = wholeSeconds % 60;
const hourPart = hours > 0 ? `${hours}H` : "";
const minutePart = minutes > 0 ? `${minutes}M` : "";
const secondPart = seconds > 0 ? `${seconds}S` : "";
if (!hourPart && !minutePart && !secondPart) {
return "PT0S";
}
return `PT${hourPart}${minutePart}${secondPart}`;
}
function getCurrentStepId(steps: ProgressStep[]): string | null {
const inProgressStep = steps.find((s) => s.status === "in-progress");
if (inProgressStep) return inProgressStep.id;
const failedStep = steps.find((s) => s.status === "failed");
if (failedStep) return failedStep.id;
const firstPendingStep = steps.find((s) => s.status === "pending");
if (firstPendingStep) return firstPendingStep.id;
return null;
}
function getReceiptState(outcome: ProgressTrackerChoice["outcome"]): {
toneClassName: string;
icon: LucideIcon;
} {
switch (outcome) {
case "success":
return {
toneClassName: "text-emerald-600 dark:text-emerald-500",
icon: Check,
};
case "partial":
return {
toneClassName: "text-amber-600 dark:text-amber-500",
icon: AlertCircle,
};
case "failed":
return {
toneClassName: "text-destructive",
icon: AlertCircle,
};
case "cancelled":
return {
toneClassName: "text-muted-foreground",
icon: X,
};
}
}
interface StepIndicatorProps {
status: "pending" | "in-progress" | "completed" | "failed";
}
function StepIndicator({ status }: StepIndicatorProps) {
if (status === "pending") {
return (
<span
className="bg-card border-border flex size-6 shrink-0 items-center justify-center rounded-full border motion-safe:transition-all motion-safe:duration-200"
aria-hidden="true"
/>
);
}
if (status === "in-progress") {
return (
<span
className="bg-card border-border flex size-6 shrink-0 items-center justify-center rounded-full border shadow-[0_0_0_4px_hsl(var(--primary)/0.1)] motion-safe:transition-all motion-safe:duration-300"
aria-hidden="true"
>
<Loader2 className="text-primary size-5 motion-safe:animate-spin" />
</span>
);
}
if (status === "completed") {
return (
<span
className="bg-primary text-primary-foreground border-primary flex size-6 shrink-0 items-center justify-center rounded-full border shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out"
aria-hidden="true"
>
<Check
className="size-4 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:delay-75 motion-safe:duration-200 motion-safe:fill-mode-both"
strokeWidth={3}
/>
</span>
);
}
if (status === "failed") {
return (
<span
className="bg-destructive border-destructive flex size-6 shrink-0 items-center justify-center rounded-full border text-white shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:duration-300 motion-safe:ease-out dark:border-red-600 dark:bg-red-600"
aria-hidden="true"
>
<X
className="size-4 motion-safe:animate-in motion-safe:fade-in motion-safe:zoom-in-75 motion-safe:delay-75 motion-safe:duration-200 motion-safe:fill-mode-both"
strokeWidth={3}
/>
</span>
);
}
return null;
}
function ElapsedTimeBadge({ elapsedTime }: { elapsedTime?: number }) {
if (elapsedTime === undefined || elapsedTime <= 0) {
return null;
}
return (
<div className="text-muted-foreground flex items-center gap-1.5 font-mono text-xs">
<Timer className="-mt-px size-3.5" />
<time dateTime={formatElapsedTimeDateTime(elapsedTime)}>
{formatElapsedTime(elapsedTime)}
</time>
</div>
);
}
interface ProgressTrackerBaseProps {
id: ProgressTrackerProps["id"];
steps: ProgressTrackerProps["steps"];
elapsedTime?: ProgressTrackerProps["elapsedTime"];
className?: ProgressTrackerProps["className"];
}
function ProgressTrackerReceipt({
id,
steps,
elapsedTime,
className,
choice,
}: ProgressTrackerBaseProps & { choice: ProgressTrackerChoice }) {
const receiptState = getReceiptState(choice.outcome);
const ReceiptIcon = receiptState.icon;
return (
<div
className={cn(
"isolate flex w-full max-w-md min-w-80 flex-col",
"text-foreground select-none",
"motion-safe:animate-in motion-safe:fade-in motion-safe:blur-in-sm motion-safe:zoom-in-95 motion-safe:duration-300 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:fill-mode-both",
className,
)}
data-slot="progress-tracker"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label={choice.summary}
>
<div className="bg-card/60 flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs">
<div className="flex items-center justify-between">
<ElapsedTimeBadge elapsedTime={elapsedTime} />
<span
className={cn(
"flex items-center gap-1.5 text-xs font-medium",
receiptState.toneClassName,
)}
>
<ReceiptIcon className="size-3.5" />
{choice.summary}
</span>
</div>
<ol className="m-0 flex list-none flex-col gap-2 p-0">
{steps.map((step, index) => (
<li
key={step.id}
className="relative -mx-2 flex items-start gap-3 rounded-lg px-2 py-1.5"
>
{index < steps.length - 1 && (
<div
className="bg-border absolute top-8 left-5 w-px"
style={{
height: "calc(100% + 0.5rem)",
}}
aria-hidden="true"
/>
)}
<div className="relative z-10">
<StepIndicator status={step.status} />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-sm leading-6 font-medium">
{step.label}
</span>
{step.description && (
<span className="text-muted-foreground text-sm">
{step.description}
</span>
)}
</div>
</li>
))}
</ol>
</div>
</div>
);
}
function ProgressTrackerLive({
id,
steps,
elapsedTime,
className,
}: ProgressTrackerBaseProps) {
const hasInProgress = steps.some((step) => step.status === "in-progress");
const currentStepId = getCurrentStepId(steps);
return (
<article
className={cn(
"isolate flex w-full max-w-md min-w-80 flex-col gap-3",
"text-foreground select-none",
className,
)}
data-slot="progress-tracker"
data-tool-ui-id={id}
role="status"
aria-live="polite"
aria-busy={hasInProgress}
>
<div className="bg-card flex w-full flex-col gap-4 rounded-2xl border p-5 shadow-xs">
<ElapsedTimeBadge elapsedTime={elapsedTime} />
<ol className="m-0 flex list-none flex-col gap-3 p-0">
{steps.map((step, index) => {
const isCurrent = step.id === currentStepId;
const isActive = step.status === "in-progress";
const isFailed = step.status === "failed";
const hasDescription = !!step.description;
const shouldShowDescription = isActive || isFailed;
return (
<li
key={step.id}
className="relative -mx-2"
aria-current={isCurrent ? "step" : undefined}
>
{index < steps.length - 1 && (
<div
className={cn(
"bg-border absolute top-6 left-5 w-px",
"motion-safe:transition-all motion-safe:duration-300",
)}
style={{
height: "calc(100% + 0.25rem)",
}}
aria-hidden="true"
/>
)}
<div
className={cn(
"relative z-10 flex items-start gap-3 rounded-lg px-2 py-1.5",
"motion-safe:transition-all motion-safe:duration-300",
isCurrent && "bg-primary/5",
)}
style={{
backdropFilter: isCurrent ? "blur(2px)" : undefined,
}}
>
<div className="relative z-10">
<StepIndicator status={step.status} />
</div>
<div className="flex flex-1 flex-col">
<span
className={cn(
"text-sm leading-6 font-medium",
step.status === "pending" && "text-muted-foreground",
step.status === "in-progress" &&
"motion-safe:shimmer shimmer-invert text-foreground",
)}
>
{step.label}
</span>
{hasDescription && (
<div
className={cn(
"grid motion-safe:transition-[grid-template-rows,opacity] motion-safe:duration-300 motion-safe:ease-out",
shouldShowDescription
? "grid-rows-[1fr] opacity-100"
: "grid-rows-[0fr] opacity-0",
)}
aria-hidden={!shouldShowDescription}
>
<div className="overflow-hidden">
<span className="text-muted-foreground block pt-0.5 text-sm">
{step.description}
</span>
</div>
</div>
)}
</div>
</div>
</li>
);
})}
</ol>
</div>
</article>
);
}
function ProgressTrackerRoot({
id,
steps,
elapsedTime,
className,
choice,
}: ProgressTrackerProps) {
const viewKey = choice ? `receipt-${choice.outcome}` : "interactive";
return (
<div key={viewKey} className="contents">
{choice ? (
<ProgressTrackerReceipt
id={id}
steps={steps}
elapsedTime={elapsedTime}
className={className}
choice={choice}
/>
) : (
<ProgressTrackerLive
id={id}
steps={steps}
elapsedTime={elapsedTime}
className={className}
/>
)}
</div>
);
}
type ProgressTrackerComponent = typeof ProgressTrackerRoot & {
Live: typeof ProgressTrackerLive;
Receipt: typeof ProgressTrackerReceipt;
};
export const ProgressTracker = Object.assign(ProgressTrackerRoot, {
Live: ProgressTrackerLive,
Receipt: ProgressTrackerReceipt,
}) as ProgressTrackerComponent;
@@ -1,76 +0,0 @@
import { z } from "zod";
import {
ToolUISurfaceSchema,
ToolUIReceiptSchema,
type ToolUIReceipt,
} from "../shared/schema";
import { defineToolUiContract } from "../shared/contract";
/**
* Receipt state for ProgressTracker showing the outcome of a workflow.
*/
export type ProgressTrackerChoice = ToolUIReceipt;
export const ProgressStepSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
status: z.enum(["pending", "in-progress", "completed", "failed"]),
});
export type ProgressStep = z.infer<typeof ProgressStepSchema>;
const ProgressStepsSchema = z
.array(ProgressStepSchema)
.min(1)
.superRefine((steps, ctx) => {
const seenIds = new Set<string>();
for (const [index, step] of steps.entries()) {
if (seenIds.has(step.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate step id: "${step.id}"`,
path: [index, "id"],
});
}
seenIds.add(step.id);
}
});
export const SerializableProgressTrackerSchema = ToolUISurfaceSchema.omit({
receipt: true,
})
.extend({
steps: ProgressStepsSchema,
elapsedTime: z.number().finite().nonnegative().optional(),
/**
* When set, renders the component in receipt state showing the workflow outcome.
*/
choice: ToolUIReceiptSchema.optional(),
})
.strict();
export type SerializableProgressTracker = z.infer<
typeof SerializableProgressTrackerSchema
>;
const SerializableProgressTrackerSchemaContract = defineToolUiContract(
"ProgressTracker",
SerializableProgressTrackerSchema,
);
export const parseSerializableProgressTracker: (
input: unknown,
) => SerializableProgressTracker =
SerializableProgressTrackerSchemaContract.parse;
export const safeParseSerializableProgressTracker: (
input: unknown,
) => SerializableProgressTracker | null =
SerializableProgressTrackerSchemaContract.safeParse;
export interface ProgressTrackerProps extends SerializableProgressTracker {
className?: string;
}
@@ -1,15 +0,0 @@
export { QuestionFlow } from "./question-flow";
export {
type SerializableQuestionFlow,
type SerializableProgressiveMode,
type SerializableUpfrontMode,
type SerializableReceiptMode,
type QuestionFlowProps,
type QuestionFlowProgressiveProps,
type QuestionFlowUpfrontProps,
type QuestionFlowReceiptProps,
type QuestionFlowOption,
type QuestionFlowStepDefinition,
type QuestionFlowChoice,
type QuestionFlowSummaryItem,
} from "./schema";
@@ -225,7 +225,7 @@ interface StepBodyData {
selectedIds: Set<string>;
}
export function getQuestionFlowStepIds(id: string, stepKey: string) {
function getQuestionFlowStepIds(id: string, stepKey: string) {
const safeId = encodeURIComponent(id).replace(/%/g, "_");
const safeStepKey = encodeURIComponent(stepKey).replace(/%/g, "_");
return {
@@ -1,6 +1,5 @@
import { z } from "zod";
import type { ReactNode } from "react";
import { defineToolUiContract } from "../shared/contract";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";
export const QuestionFlowOptionSchema = z.object({
@@ -13,7 +12,7 @@ export const QuestionFlowOptionSchema = z.object({
export type QuestionFlowOption = z.infer<typeof QuestionFlowOptionSchema>;
export const QuestionFlowStepDefinitionSchema = z.object({
const QuestionFlowStepDefinitionSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
description: z.string().optional(),
@@ -21,32 +20,22 @@ export const QuestionFlowStepDefinitionSchema = z.object({
selectionMode: z.enum(["single", "multi"]).optional(),
});
export type QuestionFlowStepDefinition = z.infer<
typeof QuestionFlowStepDefinitionSchema
>;
export const QuestionFlowSummaryItemSchema = z.object({
const QuestionFlowSummaryItemSchema = z.object({
label: z.string().min(1),
value: z.string().min(1),
});
export type QuestionFlowSummaryItem = z.infer<
typeof QuestionFlowSummaryItemSchema
>;
export const QuestionFlowChoiceSchema = z.object({
const QuestionFlowChoiceSchema = z.object({
title: z.string().min(1),
summary: z.array(QuestionFlowSummaryItemSchema).min(1),
});
export type QuestionFlowChoice = z.infer<typeof QuestionFlowChoiceSchema>;
const BaseSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
});
export const SerializableProgressiveModeSchema = BaseSchema.extend({
const SerializableProgressiveModeSchema = BaseSchema.extend({
step: z.number().min(1),
title: z.string().min(1),
description: z.string().optional(),
@@ -54,7 +43,7 @@ export const SerializableProgressiveModeSchema = BaseSchema.extend({
selectionMode: z.enum(["single", "multi"]).optional(),
});
export type SerializableProgressiveMode = z.infer<
type SerializableProgressiveMode = z.infer<
typeof SerializableProgressiveModeSchema
>;
@@ -74,29 +63,6 @@ export type SerializableReceiptMode = z.infer<
typeof SerializableReceiptModeSchema
>;
export const SerializableQuestionFlowSchema = z.union([
SerializableProgressiveModeSchema,
SerializableUpfrontModeSchema,
SerializableReceiptModeSchema,
]);
export type SerializableQuestionFlow = z.infer<
typeof SerializableQuestionFlowSchema
>;
const SerializableQuestionFlowSchemaContract = defineToolUiContract(
"QuestionFlow",
SerializableQuestionFlowSchema,
);
export const parseSerializableQuestionFlow: (
input: unknown,
) => SerializableQuestionFlow = SerializableQuestionFlowSchemaContract.parse;
export const safeParseSerializableQuestionFlow: (
input: unknown,
) => SerializableQuestionFlow | null =
SerializableQuestionFlowSchemaContract.safeParse;
interface BaseRuntimeProps {
className?: string;
}
@@ -4,7 +4,7 @@ import type { Action } from "./schema";
import { cn, Button } from "./_adapter";
import { useActionButtons } from "./use-action-buttons";
export interface ActionButtonsProps {
interface ActionButtonsProps {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
@@ -1,19 +0,0 @@
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";
export interface ToolUiContract<T> {
schema: z.ZodType<T>;
parse: (input: unknown) => T;
safeParse: (input: unknown) => T | null;
}
export function defineToolUiContract<T>(
componentName: string,
schema: z.ZodType<T>,
): ToolUiContract<T> {
return {
schema,
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
};
}
@@ -1,51 +0,0 @@
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}
/**
* Parse unknown input, returning `null` instead of throwing on failure.
*
* Use this in assistant-ui `render` functions where `args` stream in
* incrementally and may be incomplete until the tool call finishes.
*/
export function safeParseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
): T | null {
const res = schema.safeParse(input);
return res.success ? res.data : null;
}
@@ -24,8 +24,6 @@ import type { ReactNode } from "react";
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
@@ -37,17 +35,13 @@ export const ToolUIRoleSchema = z.enum([
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
@@ -58,19 +52,6 @@ export const ToolUIReceiptSchema = z.object({
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
@@ -91,54 +72,8 @@ export const ActionSchema = z.object({
});
export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;
export const DecisionResultSchema = z.object({
kind: z.literal("decision"),
version: z.literal(1),
decisionId: z.string().min(1),
actionId: z.string().min(1),
actionLabel: z.string().min(1),
at: z.string().datetime(),
payload: z.record(z.string(), z.unknown()).optional(),
});
export type DecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
payload?: TPayload;
};
export function createDecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
decisionId: string;
action: { id: string; label: string };
payload?: TPayload;
}): DecisionResult<TPayload> {
return {
kind: "decision",
version: 1,
decisionId: args.decisionId,
actionId: args.action.id,
actionLabel: args.action.label,
at: new Date().toISOString(),
payload: args.payload,
};
}
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
const SerializableActionSchema = ActionSchema.omit({ icon: true });
export interface ActionsConfig {
items: Action[];
@@ -150,10 +85,4 @@ export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
});
@@ -3,14 +3,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Action } from "./schema";
export type UseActionButtonsOptions = {
type UseActionButtonsOptions = {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
};
export type UseActionButtonsResult = {
type UseActionButtonsResult = {
actions: Array<
Action & {
currentLabel: string;
@@ -30,7 +30,7 @@ type ActionExecutionLock = {
release: () => void;
};
export function createActionExecutionLock(): ActionExecutionLock {
function createActionExecutionLock(): ActionExecutionLock {
let locked = false;
return {
@@ -1,2 +0,0 @@
export { Terminal } from "./terminal";
export type { TerminalProps, SerializableTerminal } from "./schema";
@@ -1,5 +1,4 @@
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
@@ -21,23 +20,4 @@ export const TerminalPropsSchema = z.object({
className: z.string().optional(),
});
export type TerminalProps = z.infer<typeof TerminalPropsSchema>;
export const SerializableTerminalSchema = TerminalPropsSchema.omit({
className: true,
});
export type SerializableTerminal = z.infer<typeof SerializableTerminalSchema>;
const SerializableTerminalSchemaContract = defineToolUiContract(
"Terminal",
SerializableTerminalSchema,
);
export const parseSerializableTerminal: (
input: unknown,
) => SerializableTerminal = SerializableTerminalSchemaContract.parse;
export const safeParseSerializableTerminal: (
input: unknown,
) => SerializableTerminal | null = SerializableTerminalSchemaContract.safeParse;
export type TerminalProps = z.infer<typeof TerminalPropsSchema>;
-64
View File
@@ -1,64 +0,0 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
-48
View File
@@ -52,56 +52,8 @@ function AvatarFallback({
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
-48
View File
@@ -1,48 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+1 -1
View File
@@ -60,4 +60,4 @@ const Button = React.forwardRef<
})
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button }
@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
-114
View File
@@ -1,114 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+1 -17
View File
@@ -43,14 +43,6 @@ export function getWebview(browserId: string, tabId?: string): BrowserWebview |
return registry.get(makeKey(browserId, resolvedTabId));
}
export function getActiveTabId(browserId: string): string | undefined {
return activeTabMap.get(browserId);
}
export function getAllWebviews(): Map<string, BrowserWebview> {
return new Map(registry);
}
export function findBrowserByWebContentsId(wcId: number): string | undefined {
for (const [key, wv] of registry.entries()) {
if ((wv as any).getWebContentsId?.() === wcId) {
@@ -58,12 +50,4 @@ export function findBrowserByWebContentsId(wcId: number): string | undefined {
}
}
return undefined;
}
export function unregisterAllForBrowser(browserId: string): void {
const prefix = `${browserId}:`;
for (const key of registry.keys()) {
if (key.startsWith(prefix)) registry.delete(key);
}
activeTabMap.delete(browserId);
}
}
+1 -2
View File
@@ -2,5 +2,4 @@ const port = (window as any).__OPENSWARM_PORT__ || 8325;
const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai';
export const WS_BASE = `ws://${host}:${port}`;
-48
View File
@@ -1,48 +0,0 @@
const BLOCKED_TAGS = new Set([
'script', 'style', 'iframe', 'object', 'embed', 'foreignobject',
'use', 'image', 'animate', 'set', 'animatetransform', 'animatemotion',
]);
const EVENT_ATTR = /^on/i;
const DANGEROUS_ATTR = new Set(['href', 'xlink:href']);
export function sanitizeSvgString(raw: string): string {
if (!raw || typeof raw !== 'string') return '';
try {
const parser = new DOMParser();
const doc = parser.parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg">${raw}</svg>`,
'image/svg+xml',
);
const errors = doc.querySelector('parsererror');
if (errors) return '';
const walk = (node: Element) => {
const children = Array.from(node.children);
for (const child of children) {
if (BLOCKED_TAGS.has(child.tagName.toLowerCase())) {
child.remove();
continue;
}
for (const attr of Array.from(child.attributes)) {
if (EVENT_ATTR.test(attr.name)) {
child.removeAttribute(attr.name);
}
if (DANGEROUS_ATTR.has(attr.name.toLowerCase()) && attr.value.trim().toLowerCase().startsWith('javascript')) {
child.removeAttribute(attr.name);
}
}
walk(child);
}
};
const svg = doc.documentElement;
walk(svg);
return svg.innerHTML;
} catch {
return '';
}
}
-4
View File
@@ -16,11 +16,9 @@ export const {
toggleExpandSession,
expandSession,
collapseSession,
collapseAllSessions,
setExpandedSessionIds,
updateSessionName,
updateGroupMeta,
setDraftSystemPrompt,
updateSession,
updateSessionStatus,
addMessage,
@@ -28,11 +26,9 @@ export const {
streamDelta,
streamEnd,
addApprovalRequest,
removeApprovalRequest,
updateSessionCost,
addBranch,
setActiveBranch,
updateSessionProvider,
updateSessionModel,
updateSessionMode,
closeSessionFromWs,
@@ -53,15 +53,15 @@ const dashboardLayoutSlice = createSlice({
export const {
setCardPosition, placeCard, setCardSize, removeCard, bringToFront,
reconcileSessions, replaceDraftId, tidyLayout,
reconcileSessions, tidyLayout,
addViewCard, setViewCardPosition, setViewCardSize, removeViewCard,
addBrowserCard, addBrowserCardFromBackend, setBrowserCardPosition,
setBrowserCardSize, removeBrowserCard, pasteBrowserCard,
updateBrowserCardUrl, addBrowserTab, removeBrowserTab,
addBrowserTab, removeBrowserTab,
setActiveBrowserTab, updateBrowserTabUrl, updateBrowserTabTitle,
updateBrowserTabFavicon, reorderBrowserTab, moveCards,
setGlowingBrowserCards, fadeGlowingBrowserCards,
clearGlowingBrowserCards, clearAllGlowingBrowserCards,
clearGlowingBrowserCards,
setGlowingAgentCard, fadeGlowingAgentCard, clearGlowingAgentCard,
resetLayout,
} = dashboardLayoutSlice.actions;
@@ -17,7 +17,7 @@ export interface McpServer {
source: string;
}
export interface McpServerDetail extends McpServer {
interface McpServerDetail extends McpServer {
environmentVariables: { name: string; description: string; default?: string; format?: string }[];
keywords: string[];
license: string;
+1 -1
View File
@@ -3,7 +3,7 @@ import { API_BASE } from '@/shared/config';
const AGENTS_API = `${API_BASE}/agents`;
export interface ModelOption {
interface ModelOption {
value: string;
label: string;
version?: string;
+2 -2
View File
@@ -121,7 +121,7 @@ export const executeOutput = createAsyncThunk(
}
);
export interface AutoRunResult {
interface AutoRunResult {
input_data: Record<string, any> | null;
backend_result: Record<string, any> | null;
stdout: string | null;
@@ -141,7 +141,7 @@ export const autoRunOutput = createAsyncThunk(
}
);
export interface AutoRunAgentResult {
interface AutoRunAgentResult {
session_id: string;
}
@@ -111,15 +111,6 @@ export const resetSystemPrompt = createAsyncThunk(
}
);
export const browseDirectories = createAsyncThunk(
'settings/browseDirectories',
async (path: string) => {
const res = await fetch(`${SETTINGS_API}/browse-directories?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error((await res.json()).detail);
return (await res.json()) as BrowseResult;
}
);
const settingsSlice = createSlice({
name: 'settings',
initialState,
@@ -37,7 +37,7 @@ const initialState: SkillRegistryState = {
detailLoading: false,
};
export const searchSkillRegistry = createAsyncThunk(
const searchSkillRegistry = createAsyncThunk(
'skillRegistry/search',
async ({ q, limit = 20, offset = 0, sort = 'name', category = '' }: { q: string; limit?: number; offset?: number; sort?: string; category?: string }) => {
const params = new URLSearchParams({ q, limit: String(limit), offset: String(offset), sort, category });
@@ -123,5 +123,4 @@ const skillRegistrySlice = createSlice({
},
});
export const { clearSkillDetail } = skillRegistrySlice.actions;
export default skillRegistrySlice.reducer;
+1 -1
View File
@@ -102,7 +102,7 @@ export const startOAuth = createAsyncThunk(
}
);
export const disconnectOAuth = createAsyncThunk(
const disconnectOAuth = createAsyncThunk(
'tools/disconnectOAuth',
async (toolId: string) => {
const res = await fetch(`${TOOLS_API}/${toolId}/oauth/disconnect`, { method: 'POST' });
+1 -1
View File
@@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export type UpdateStatus =
type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
@@ -112,6 +112,3 @@ export const darkTokens: ClaudeTokens = {
},
transition: 'all 300ms cubic-bezier(0.165, 0.85, 0.45, 1)',
};
/** @deprecated Use useClaudeTokens() hook instead for dark mode support */
export const claude = lightTokens;
+1 -1
View File
@@ -6,7 +6,7 @@ import {
type BrowserAction,
} from './browserCommandHandler';
export interface BrowserActivityState {
interface BrowserActivityState {
active: boolean;
action: BrowserAction | null;
detail: string | null;
+1 -3
View File
@@ -157,6 +157,4 @@ export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { ski
export function createSessionWs(sessionId: string): WebSocketManager {
return new WebSocketManager(`${WS_BASE}/ws/agents/${sessionId}`);
}
export default WebSocketManager;
}
+1 -1
View File
@@ -22,7 +22,7 @@ export type WSEvent = {
data: Record<string, any>;
};
export interface WsDeltaCallbacks {
interface WsDeltaCallbacks {
bufferDelta: (sessionId: string, messageId: string, delta: string) => void;
flushDeltas: () => void;
hasPendingDeltas: () => boolean;