[eric] split: extract invoke/create/compact/default tool bubbles + shell dispatch

This commit is contained in:
ciregenz
2026-05-23 05:20:58 -07:00
parent 7802c0abf8
commit 374df87ee1
6 changed files with 1000 additions and 1994 deletions
@@ -0,0 +1,71 @@
import React from 'react';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const AgentResponseBody: React.FC<{ open: boolean; markdown: string }> = ({ open, markdown }) => {
const c = useClaudeTokens();
return (
<Collapse in={open}>
<Box
sx={{
borderTop: `1px solid ${c.border.subtle}`,
px: 1.5,
py: 1.25,
maxHeight: 400,
overflowY: 'auto',
overflowX: 'hidden',
color: c.text.secondary,
fontFamily: c.font.sans,
fontSize: '0.78rem',
lineHeight: 1.65,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
'& p': { m: 0, mb: 0.75, '&:last-child': { mb: 0 } },
'& h1, & h2, & h3, & h4': {
color: c.text.primary, fontFamily: c.font.sans,
mt: 1, mb: 0.5, '&:first-of-type': { mt: 0 },
},
'& h1': { fontSize: '0.88rem' }, '& h2': { fontSize: '0.84rem' },
'& h3': { fontSize: '0.8rem' }, '& h4': { fontSize: '0.78rem' },
'& strong': { color: c.text.primary, fontWeight: 600 },
'& a': { color: c.accent.primary, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } },
'& ul, & ol': { pl: 2, mb: 0.75, mt: 0 },
'& li': { mb: 0.2 },
'& blockquote': {
m: 0, mb: 0.75, pl: 1, ml: 0,
borderLeft: `2px solid ${c.border.subtle}`,
color: c.text.tertiary, fontStyle: 'italic',
},
'& code': {
bgcolor: c.bg.secondary, px: 0.4, py: 0.15,
borderRadius: 0.5, fontSize: '0.72rem', fontFamily: c.font.mono,
},
'& pre': {
bgcolor: c.bg.secondary, borderRadius: 1, p: 1,
overflow: 'auto', fontSize: '0.72rem', fontFamily: c.font.mono,
m: 0, mb: 0.75,
},
'& pre code': { bgcolor: 'transparent', p: 0 },
'& hr': { border: 'none', borderTop: `1px solid ${c.border.subtle}`, my: 0.75 },
'&::-webkit-scrollbar': { width: 5 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: c.border.medium, borderRadius: 3 },
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, ...props }) => (
<a {...props}>{children}</a>
),
}}
>
{markdown}
</ReactMarkdown>
</Box>
</Collapse>
);
};
@@ -0,0 +1,168 @@
import React 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 ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import BlockIcon from '@mui/icons-material/Block';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { getToolLabel } from './toolLabels';
import BrowserAgentInlineFeed from './BrowserAgentInlineFeed';
import { GoogleServiceIcon } from './GoogleServiceIcon';
import { ElapsedTimer, formatElapsed } from './toolBubbleChrome';
import { useTermColors } from './toolColorize';
import { ParsedResult } from './toolResultParsing';
import { McpToolInfo, getMcpShortAction } from './mcpToolName';
import { McpResultCard } from './McpResultCard';
interface CompactMcpBubbleProps {
call: AgentMessage;
input: any;
sessionId?: string;
isPending: boolean;
isStreaming: boolean;
isDenied: boolean;
isError: boolean;
result: AgentMessage | null;
mcpInfo: McpToolInfo;
toolName: string;
resultSummary: string | null;
resultElapsedMs: number | null;
showTimer: boolean;
showBody: boolean;
toggle: () => void;
parsedResult: ParsedResult | null;
isBrowserAgent: boolean;
selectAttrs: Record<string, string>;
}
export const CompactMcpBubble: React.FC<CompactMcpBubbleProps> = ({
call, input, sessionId, isPending, isStreaming, isDenied, isError, result,
mcpInfo, toolName, resultSummary, resultElapsedMs, showTimer, showBody, toggle, parsedResult,
isBrowserAgent, selectAttrs,
}) => {
const c = useClaudeTokens();
const tc = useTermColors();
const shortAction = mcpInfo.isMcp ? getMcpShortAction(mcpInfo) : toolName;
const mcpVerbLabel = (() => {
const lbl = getToolLabel(toolName, call.id);
return result && !isDenied ? lbl.past : lbl.present;
})();
const serviceLabel = mcpInfo.isMcp ? mcpVerbLabel : shortAction;
const ServiceIcon = mcpInfo.isMcp && mcpInfo.service
? <GoogleServiceIcon service={mcpInfo.service} size={14} />
: null;
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 }} />
)}
{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 && (
<BrowserAgentInlineFeed
parentSessionId={sessionId}
browserId={input?.browser_id}
/>
)}
{parsedResult && parsedResult.type === 'mcp' ? (
<McpResultCard parsed={parsedResult} compact />
) : 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>
);
};
@@ -0,0 +1,176 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import BlockIcon from '@mui/icons-material/Block';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ElapsedTimer, formatElapsed } from './toolBubbleChrome';
import { AgentResponseBody } from './AgentResponseBody';
interface CreateAgentBubbleProps {
call: AgentMessage;
input: any;
isPending: boolean;
isDenied: boolean;
isError: boolean;
resultElapsedMs: number | null;
expanded: boolean;
showTimer: boolean;
toggle: () => void;
accentRgb: string;
createAgentResponse: string;
createAgentSessionId: string | null;
handleRevealAgent: (e: React.MouseEvent) => void;
bubbleRef: React.RefObject<HTMLDivElement>;
selectAttrs: Record<string, string>;
}
export const CreateAgentBubble: React.FC<CreateAgentBubbleProps> = ({
call, input, isPending, isDenied, isError, resultElapsedMs, expanded, showTimer,
toggle, accentRgb, createAgentResponse, createAgentSessionId, handleRevealAgent, bubbleRef, selectAttrs,
}) => {
const c = useClaudeTokens();
const taskPrompt = input?.prompt || input?.task || input?.message || '';
const taskLabel = taskPrompt
? taskPrompt.length > 40 ? taskPrompt.slice(0, 40) + '…' : taskPrompt
: 'Sub-agent';
const hasResponse = !!createAgentResponse;
return (
<Box ref={bubbleRef} {...selectAttrs} sx={{ maxWidth: '85%', my: 0.5 }}>
<Box
sx={{
'--glow-rgb': accentRgb,
bgcolor: c.bg.elevated,
border: `1px solid ${
isPending ? c.accent.primary : isDenied ? c.status.error + '60' : c.border.subtle
}`,
borderRadius: 2,
overflow: 'hidden',
animation: isPending ? '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: 0.75,
cursor: hasResponse ? 'pointer' : 'default',
'&:hover': hasResponse ? { bgcolor: 'rgba(0,0,0,0.02)' } : {},
}}
>
<CallSplitIcon sx={{ fontSize: 15, color: c.accent.primary, flexShrink: 0 }} />
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.8rem',
fontWeight: 600,
flexShrink: 0,
}}
>
CreateAgent
</Typography>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
bgcolor: `${c.accent.primary}14`,
borderRadius: 1,
px: 0.75,
py: 0.15,
maxWidth: 180,
overflow: 'hidden',
}}
>
<Typography
noWrap
sx={{
fontSize: '0.72rem',
fontWeight: 500,
color: c.text.secondary,
fontFamily: c.font.sans,
}}
>
{taskLabel}
</Typography>
</Box>
{!hasResponse && !showTimer && <Box sx={{ flex: 1 }} />}
{hasResponse && createAgentResponse && !expanded && (
<Typography
noWrap
sx={{
flex: 1,
minWidth: 0,
fontSize: '0.73rem',
color: c.text.tertiary,
fontFamily: c.font.sans,
}}
>
{createAgentResponse.slice(0, 100)}{createAgentResponse.length > 100 ? '…' : ''}
</Typography>
)}
{expanded && <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>
)}
{hasResponse && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError && (
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
)}
{resultElapsedMs != null && (
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
{formatElapsed(resultElapsedMs)}
</Typography>
)}
</Box>
)}
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
{createAgentSessionId && (
<Tooltip title="Reveal on dashboard" arrow>
<IconButton
size="small"
onClick={handleRevealAgent}
sx={{
color: c.accent.primary,
p: 0.25,
flexShrink: 0,
'&:hover': { bgcolor: `${c.accent.primary}18` },
}}
>
<CallSplitIcon sx={{ fontSize: 15, transform: 'rotate(180deg)' }} />
</IconButton>
</Tooltip>
)}
{hasResponse && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 18 }} /> : <ExpandMoreIcon sx={{ fontSize: 18 }} />}
</IconButton>
)}
</Box>
<AgentResponseBody open={expanded && hasResponse} markdown={createAgentResponse} />
</Box>
</Box>
);
};
@@ -0,0 +1,299 @@
import React 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 ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import BlockIcon from '@mui/icons-material/Block';
import SearchIcon from '@mui/icons-material/Search';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { getToolLabelWithInput } from './toolLabels';
import BrowserAgentInlineFeed from './BrowserAgentInlineFeed';
import { GoogleServiceIcon } from './GoogleServiceIcon';
import { ElapsedTimer, formatElapsed } from './toolBubbleChrome';
import { useTermColors, colorizeInput, colorizeOutput } from './toolColorize';
import { ParsedResult } from './toolResultParsing';
import { McpToolInfo } from './mcpToolName';
import { McpResultCard } from './McpResultCard';
interface DefaultToolBubbleProps {
call: AgentMessage;
input: any;
sessionId?: string;
mcpCompact: boolean;
isPending: boolean;
isStreaming: boolean;
isDenied: boolean;
isError: boolean;
result: AgentMessage | null;
mcpInfo: McpToolInfo;
toolName: string;
inputSummary: string;
formattedInput: string;
promptPrefix: string;
resultSummary: string | null;
resultElapsedMs: number | null;
showTimer: boolean;
showBody: boolean;
toggle: () => void;
parsedResult: ParsedResult | null;
isBrowserAgent: boolean;
accentRgb: string;
selectAttrs: Record<string, string>;
}
export const DefaultToolBubble: React.FC<DefaultToolBubbleProps> = ({
call, input, sessionId, mcpCompact, isPending, isStreaming, isDenied, isError, result,
mcpInfo, toolName, inputSummary, formattedInput, promptPrefix, resultSummary, resultElapsedMs,
showTimer, showBody, toggle, parsedResult, isBrowserAgent, accentRgb, selectAttrs,
}) => {
const c = useClaudeTokens();
const tc = useTermColors();
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,
}}
>
{(() => {
const { present, past } = getToolLabelWithInput(toolName, input, call.id);
return result && !isDenied ? past : present;
})()}
</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 }} />
{resultSummary && (
<Typography sx={{ color: c.status.error, 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 && (
<BrowserAgentInlineFeed
parentSessionId={sessionId}
browserId={input?.browser_id}
/>
)}
{parsedResult && parsedResult.type === 'mcp' ? (
<McpResultCard parsed={parsedResult} />
) : 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>
);
};
@@ -0,0 +1,181 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import BlockIcon from '@mui/icons-material/Block';
import CallSplitIcon from '@mui/icons-material/CallSplit';
import { AgentMessage } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ElapsedTimer, formatElapsed } from './toolBubbleChrome';
import { AgentResponseBody } from './AgentResponseBody';
import { InvokeAgentParsed } from './agentToolParsing';
interface InvokeAgentBubbleProps {
call: AgentMessage;
input: any;
isPending: boolean;
isDenied: boolean;
isError: boolean;
resultElapsedMs: number | null;
expanded: boolean;
showTimer: boolean;
toggle: () => void;
accentRgb: string;
invokeAgentParsed: InvokeAgentParsed | null;
invokedSessionId: string | null;
handleRevealAgent: (e: React.MouseEvent) => void;
bubbleRef: React.RefObject<HTMLDivElement>;
selectAttrs: Record<string, string>;
}
export const InvokeAgentBubble: React.FC<InvokeAgentBubbleProps> = ({
call, input, isPending, isDenied, isError, resultElapsedMs, expanded, showTimer,
toggle, accentRgb, invokeAgentParsed, invokedSessionId, handleRevealAgent, bubbleRef, selectAttrs,
}) => {
const c = useClaudeTokens();
const agentName = invokeAgentParsed?.agentName || input?.session_id || 'Agent';
const responsePreview = invokeAgentParsed?.response || '';
const costLabel = invokeAgentParsed?.cost ? `$${invokeAgentParsed.cost}` : null;
const hasResponse = !!invokeAgentParsed;
return (
<Box ref={bubbleRef} {...selectAttrs} sx={{ maxWidth: '85%', my: 0.5 }}>
<Box
sx={{
'--glow-rgb': accentRgb,
bgcolor: c.bg.elevated,
border: `1px solid ${
isPending ? c.accent.primary : isDenied ? c.status.error + '60' : c.border.subtle
}`,
borderRadius: 2,
overflow: 'hidden',
animation: isPending ? '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: 0.75,
cursor: hasResponse ? 'pointer' : 'default',
'&:hover': hasResponse ? { bgcolor: 'rgba(0,0,0,0.02)' } : {},
}}
>
<CallSplitIcon sx={{ fontSize: 15, color: c.accent.primary, flexShrink: 0 }} />
<Typography
sx={{
color: c.accent.primary,
fontSize: '0.8rem',
fontWeight: 600,
flexShrink: 0,
}}
>
InvokeAgent
</Typography>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
bgcolor: `${c.accent.primary}14`,
borderRadius: 1,
px: 0.75,
py: 0.15,
maxWidth: 180,
overflow: 'hidden',
}}
>
<Typography
noWrap
sx={{
fontSize: '0.72rem',
fontWeight: 500,
color: c.text.secondary,
fontFamily: c.font.sans,
}}
>
{agentName}
</Typography>
</Box>
{!hasResponse && !showTimer && <Box sx={{ flex: 1 }} />}
{hasResponse && responsePreview && !expanded && (
<Typography
noWrap
sx={{
flex: 1,
minWidth: 0,
fontSize: '0.73rem',
color: c.text.tertiary,
fontFamily: c.font.sans,
}}
>
{responsePreview.slice(0, 100)}{responsePreview.length > 100 ? '…' : ''}
</Typography>
)}
{expanded && <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>
)}
{hasResponse && !isDenied && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{isError && (
<ErrorOutlineIcon sx={{ fontSize: 13, color: c.status.error }} />
)}
{resultElapsedMs != null && (
<Typography sx={{ fontSize: '0.65rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
{formatElapsed(resultElapsedMs)}
</Typography>
)}
{costLabel && (
<Typography sx={{ fontSize: '0.63rem', fontFamily: c.font.mono, color: c.text.tertiary }}>
{costLabel}
</Typography>
)}
</Box>
)}
{showTimer && <ElapsedTimer startTime={call.timestamp} />}
{invokedSessionId && (
<Tooltip title="Reveal on dashboard" arrow>
<IconButton
size="small"
onClick={handleRevealAgent}
sx={{
color: c.accent.primary,
p: 0.25,
flexShrink: 0,
'&:hover': { bgcolor: `${c.accent.primary}18` },
}}
>
<CallSplitIcon sx={{ fontSize: 15, transform: 'rotate(180deg)' }} />
</IconButton>
</Tooltip>
)}
{hasResponse && (
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.25, flexShrink: 0 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 18 }} /> : <ExpandMoreIcon sx={{ fontSize: 18 }} />}
</IconButton>
)}
</Box>
<AgentResponseBody open={expanded && hasResponse} markdown={responsePreview} />
</Box>
</Box>
);
};
File diff suppressed because it is too large Load Diff