mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: manually removed hella shit that was just not being used at all (the knip linter is clutch)
This commit is contained in:
@@ -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>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type BrowserAction,
|
||||
} from './browserCommandHandler';
|
||||
|
||||
export interface BrowserActivityState {
|
||||
interface BrowserActivityState {
|
||||
active: boolean;
|
||||
action: BrowserAction | null;
|
||||
detail: string | null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user