[Haik]: Agentic Refactor 6. MCP Cards & Browser Feed

This commit is contained in:
haikdc
2026-03-30 17:58:53 -07:00
parent 8ccc3b4c5b
commit 4675697bfa
12 changed files with 588 additions and 769 deletions
+51 -29
View File
@@ -18,6 +18,7 @@ Register Tool UI components for MCP service results (`MessageDraft` for Gmail, `
## Key Files to Read First
Understand what you're replacing:
- `frontend/src/app/pages/AgentChat/McpServiceCards.tsx` — dispatches to Gmail/Calendar/Drive/Generic cards (158 lines)
- `frontend/src/app/pages/AgentChat/GmailCard.tsx` — email list/detail rendering (136 lines)
- `frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx` — browser automation feed (164 lines)
@@ -26,19 +27,22 @@ Understand what you're replacing:
- `frontend/src/app/pages/AgentChat/DiffViewer.tsx` — git diff panel (140 lines)
Understand what you're keeping and wiring:
- `frontend/src/app/pages/AgentChat/AgentToolBubble.tsx` — InvokeAgent/CreateAgent (193 lines) — KEEP
- `frontend/src/app/pages/AgentChat/ViewBubble.tsx` — iframe app preview (194 lines) — KEEP
- `frontend/src/app/pages/AgentChat/ViewBubbleParts.tsx` — iframe parts (136 lines) — KEEP
Also read the installed Tool UI schemas:
- `src/components/tool-ui/message-draft/schema.ts`
- `src/components/tool-ui/data-table/schema.ts`
- `src/components/tool-ui/data-table/schema.tsdi`
- `src/components/tool-ui/progress-tracker/schema.ts`
- `src/components/tool-ui/item-carousel/schema.ts` (if installed)
## Background: How MCP Tool Results Work
MCP tools have names like `mcp__google-gmail__search`, `mcp__google-calendar__listEvents`, `mcp__google-drive__listFiles`. The existing `parseMcpToolName()` function (in `toolCallUtils.ts`) parses these into:
```typescript
{
isMcp: true,
@@ -50,6 +54,7 @@ MCP tools have names like `mcp__google-gmail__search`, `mcp__google-calendar__li
```
The `McpResultCard` component dispatches based on `service`:
- `gmail``GmailCard`
- `calendar``CalendarCard`
- `drive` / `sheets``DriveCard`
@@ -60,6 +65,7 @@ The `McpResultCard` component dispatches based on `service`:
### 1. Read Tool UI component schemas
Read the installed schemas:
- `src/components/tool-ui/message-draft/schema.ts` — email/message rendering
- `src/components/tool-ui/data-table/schema.ts` — table rendering
- `src/components/tool-ui/progress-tracker/schema.ts` — step-by-step progress
@@ -67,10 +73,12 @@ Read the installed schemas:
### 2. Implement mcp-tools.tsx — Gmail → MessageDraft
The `GmailCard` renders:
- **Email list**: Multiple email cards with subject, from, date, snippet
- **Single email**: Subject header, from/to/date fields, labels, body (markdown), attachments
Map to Tool UI's `MessageDraft`:
```tsx
import { MessageDraft } from '@/components/tool-ui/message-draft';
@@ -91,6 +99,7 @@ function renderGmailResult(data: any, action: string) {
```
For **email list results** (search/list), use `DataTable`:
```tsx
import { DataTable } from '@/components/tool-ui/data-table';
@@ -116,10 +125,12 @@ function renderGmailList(messages: any[]) {
### 3. Implement mcp-tools.tsx — Calendar → DataTable
The `CalendarCard` renders:
- **Event list**: Cards with summary + date
- **Single event**: Summary, start, end, location, description
Map event lists to `DataTable`:
```tsx
function renderCalendarList(items: any[]) {
return (
@@ -146,6 +157,7 @@ function renderCalendarList(items: any[]) {
### 4. Implement mcp-tools.tsx — Drive → DataTable
The `DriveCard` renders file lists with name and mimeType. Map to `DataTable`:
```tsx
function renderDriveFiles(files: any[]) {
return (
@@ -176,6 +188,7 @@ The challenge: MCP tool names are dynamic (`mcp__<server>__<action>`). You can't
**Solution**: Use a catch-all pattern. Check if assistant-ui supports a `ToolFallback` component or a wildcard toolkit entry. Look up `ui/tool-fallback` in the docs.
If the toolkit supports a fallback/default renderer:
```tsx
export const mcpToolkit = {
// Specific MCP tools can be registered by name if desired
@@ -204,6 +217,7 @@ If assistant-ui doesn't support a fallback, register a `ToolFallback` component
The `BrowserAgentInlineFeed` renders a compact activity log of browser automation steps (navigate, click, type, screenshot) with status indicators.
Map to `ProgressTracker`:
```tsx
import { ProgressTracker } from '@/components/tool-ui/progress-tracker';
@@ -240,6 +254,7 @@ import { CodeDiff } from '@/components/tool-ui/code-diff';
```
**Note**: DiffViewer is rendered in `ChatHeader`, not as a tool call. It may not fit the toolkit pattern. Two options:
- Replace the rendering logic inside DiffViewer to use `CodeDiff` component but keep the wrapper
- Or just replace the internals
@@ -298,21 +313,25 @@ These utility functions are needed by the MCP toolkit. Port them into `mcp-tools
## Files Created / Modified
| File | Action | Description |
|------|--------|-------------|
| `toolkit/mcp-tools.tsx` | **Fill in** (was stub) | Gmail, Calendar, Drive, Generic MCP, Browser feed, DiffViewer renderers |
| `toolkit/custom-tools.tsx` | **Fill in** (was stub) | AgentToolBubble, ViewBubble wrappers |
| File | Action | Description |
| -------------------------- | ---------------------- | ----------------------------------------------------------------------- |
| `toolkit/mcp-tools.tsx` | **Fill in** (was stub) | Gmail, Calendar, Drive, Generic MCP, Browser feed, DiffViewer renderers |
| `toolkit/custom-tools.tsx` | **Fill in** (was stub) | AgentToolBubble, ViewBubble wrappers |
## Files Deleted (by this agent)
| File | Lines | Replaced By |
|------|-------|------------|
| `GmailCard.tsx` | 136 | MessageDraft + DataTable in `mcp-tools.tsx` |
| `McpServiceCards.tsx` | 158 | Routing logic in `mcp-tools.tsx` |
| `BrowserAgentInlineFeed.tsx` | 164 | ProgressTracker in `mcp-tools.tsx` |
| `BrowserFeedEntryRow.tsx` | 120 | ProgressTracker step rendering |
| `browserFeedUtils.ts` | 129 | Simplified in `mcp-tools.tsx` |
| `DiffViewer.tsx` | 140 | CodeDiff (keep thin wrapper if needed for API fetch) |
| File | Lines | Replaced By |
| ---------------------------- | ----- | ---------------------------------------------------- |
| `GmailCard.tsx` | 136 | MessageDraft + DataTable in `mcp-tools.tsx` |
| `McpServiceCards.tsx` | 158 | Routing logic in `mcp-tools.tsx` |
| `BrowserAgentInlineFeed.tsx` | 164 | ProgressTracker in `mcp-tools.tsx` |
| `BrowserFeedEntryRow.tsx` | 120 | ProgressTracker step rendering |
| `browserFeedUtils.ts` | 129 | Simplified in `mcp-tools.tsx` |
| `DiffViewer.tsx` | 140 | CodeDiff (keep thin wrapper if needed for API fetch) |
**Important**: `DiffViewer` is rendered in `ChatHeader.tsx`, not as a tool call. Before deleting, check how it's used. If it's a side panel that fetches from an API, you may want to keep a thin wrapper that uses `CodeDiff` internally rather than fully deleting it.
@@ -320,11 +339,13 @@ These utility functions are needed by the MCP toolkit. Port them into `mcp-tools
## Files Kept (wired as custom toolkit entries)
| File | Lines | Action |
|------|-------|--------|
| `AgentToolBubble.tsx` | 193 | Kept, registered in `custom-tools.tsx` |
| `ViewBubble.tsx` | 194 | Kept, registered in `custom-tools.tsx` |
| `ViewBubbleParts.tsx` | 136 | Kept (dependency of ViewBubble) |
| File | Lines | Action |
| --------------------- | ----- | -------------------------------------- |
| `AgentToolBubble.tsx` | 193 | Kept, registered in `custom-tools.tsx` |
| `ViewBubble.tsx` | 194 | Kept, registered in `custom-tools.tsx` |
| `ViewBubbleParts.tsx` | 136 | Kept (dependency of ViewBubble) |
## Files NOT Modified
@@ -334,14 +355,15 @@ These utility functions are needed by the MCP toolkit. Port them into `mcp-tools
## Verification Checklist
- [ ] `toolkit/mcp-tools.tsx` exports `mcpToolkit` with MCP tool renderers
- [ ] Gmail email results render as `MessageDraft` (single) or `DataTable` (list)
- [ ] Calendar events render as `DataTable`
- [ ] Drive files render as `DataTable`
- [ ] Unknown MCP tools render a generic key-value fallback
- [ ] Browser agent activity renders as `ProgressTracker` steps
- [ ] `toolkit/custom-tools.tsx` exports `customToolkit` with `InvokeAgent`, `CreateAgent`, `RenderOutput`
- [ ] `AgentToolBubble` and `ViewBubble` render correctly through the toolkit
- [ ] DiffViewer rendering uses `CodeDiff` internally
- [ ] No TypeScript errors
- [ ] Deleted files don't break other imports
- `toolkit/mcp-tools.tsx` exports `mcpToolkit` with MCP tool renderers
- Gmail email results render as `MessageDraft` (single) or `DataTable` (list)
- Calendar events render as `DataTable`
- Drive files render as `DataTable`
- Unknown MCP tools render a generic key-value fallback
- Browser agent activity renders as `ProgressTracker` steps
- `toolkit/custom-tools.tsx` exports `customToolkit` with `InvokeAgent`, `CreateAgent`, `RenderOutput`
- `AgentToolBubble` and `ViewBubble` render correctly through the toolkit
- DiffViewer rendering uses `CodeDiff` internally
- No TypeScript errors
- Deleted files don't break other imports
@@ -1,164 +0,0 @@
import React, { useEffect, useRef, useMemo } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import LanguageIcon from '@mui/icons-material/Language';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector, useAppDispatch } from '@/shared/hooks';
import { AgentSession, fetchBrowserAgentChildren } from '@/shared/state/agentsSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import type { RootState } from '@/shared/state/store';
import { formatMessage, darkFeedColors, lightFeedColors } from './browserFeedUtils';
import type { FeedEntry } from './browserFeedUtils';
import { EntryRow, SessionStatusChip } from './BrowserFeedEntryRow';
interface Props {
parentSessionId: string;
browserId?: string;
}
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),
),
);
const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const { mode } = useThemeMode();
const fc = mode === 'dark' ? darkFeedColors : lightFeedColors;
const scrollRef = useRef<HTMLDivElement>(null);
const fetchedForSession = useRef<string | null>(null);
const browserSessions = useAppSelector((state) =>
selectBrowserSessions(state, parentSessionId, browserId),
);
useEffect(() => {
if (browserSessions.length === 0 && fetchedForSession.current !== parentSessionId) {
fetchedForSession.current = parentSessionId;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedForSession.current = null; });
}
}, [browserSessions.length, parentSessionId, dispatch]);
const sessionsWithEntries = useMemo(() => {
return browserSessions.map((session) => {
const entries: FeedEntry[] = [];
for (const msg of session.messages) {
const entry = formatMessage(msg);
if (entry) entries.push(entry);
}
if (session.streamingMessage?.role === 'assistant' && session.streamingMessage.content) {
entries.push({ type: 'thought', text: session.streamingMessage.content });
}
return { session, entries };
});
}, [browserSessions]);
const totalMessages = browserSessions.reduce(
(n, s) => n + s.messages.length + (s.streamingMessage ? 1 : 0),
0,
);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [totalMessages]);
if (browserSessions.length === 0) return null;
const showLabels = sessionsWithEntries.length > 1;
const accentColor = c.accent.primary;
return (
<Box
ref={scrollRef}
sx={{
maxHeight: 300,
overflowY: 'auto',
px: 1.5,
py: 1,
display: 'flex',
flexDirection: 'column',
gap: 0.25,
scrollbarWidth: 'thin',
scrollbarColor: `${fc.scrollThumb} transparent`,
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': {
background: fc.scrollThumb,
borderRadius: 2,
},
}}
>
{sessionsWithEntries.map(({ session, entries }, si) => (
<Box key={session.id} sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{showLabels && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: si > 0 ? 1 : 0, mb: 0.25 }}>
<LanguageIcon sx={{ fontSize: 12, color: accentColor, opacity: 0.7 }} />
<Typography
sx={{
fontSize: '0.65rem',
fontWeight: 600,
color: accentColor,
opacity: 0.8,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{session.browser_id || `Browser ${si + 1}`}
</Typography>
<SessionStatusChip status={session.status} />
</Box>
)}
{!showLabels && entries.length === 0 && session.status === 'running' && (
<Typography
sx={{
fontSize: '0.7rem',
color: c.text.tertiary,
fontStyle: 'italic',
fontFamily: c.font.mono,
}}
>
Starting browser agent...
</Typography>
)}
{entries.map((entry, i) => (
<EntryRow key={i} entry={entry} accentColor={accentColor} fc={fc} />
))}
{!showLabels && session.status === 'running' && entries.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: accentColor,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
</Box>
)}
</Box>
))}
</Box>
);
};
export default React.memo(BrowserAgentInlineFeed);
@@ -1,120 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import type { FeedEntry, FeedColors } from './browserFeedUtils';
import { getActionIcon } from './browserFeedUtils';
export const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => {
const c = useClaudeTokens();
if (entry.type === 'thought') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<SmartToyOutlinedIcon
sx={{ fontSize: 10, color: fc.thoughtIcon, mt: '3px', flexShrink: 0 }}
/>
<Typography
sx={{
fontSize: '0.7rem',
color: fc.thought,
lineHeight: 1.45,
wordBreak: 'break-word',
fontFamily: c.font.mono,
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'action') {
const ActionIcon = getActionIcon(entry.actionTool);
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0 }}>
<ActionIcon sx={{ fontSize: 11, color: accentColor, mt: '2px', flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.7rem',
fontFamily: c.font.mono,
color: accentColor,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'result') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', minWidth: 0, pl: 1.25 }}>
<Typography
sx={{
fontSize: '0.65rem',
fontFamily: c.font.mono,
color: fc.result,
lineHeight: 1.45,
wordBreak: 'break-word',
}}
>
{entry.text}
</Typography>
</Box>
);
}
if (entry.type === 'system') {
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center', minWidth: 0 }}>
<ErrorOutlineIcon sx={{ fontSize: 10, color: fc.errorIcon, flexShrink: 0 }} />
<Typography
sx={{
fontSize: '0.68rem',
fontFamily: c.font.mono,
color: fc.error,
lineHeight: 1.45,
}}
>
{entry.text}
</Typography>
</Box>
);
}
return null;
};
export const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
const c = useClaudeTokens();
if (status === 'running') {
return (
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.status.success,
animation: 'ba-feed-pulse 1.4s ease-in-out infinite',
'@keyframes ba-feed-pulse': {
'0%, 100%': { opacity: 0.3, transform: 'scale(0.8)' },
'50%': { opacity: 1, transform: 'scale(1.2)' },
},
}}
/>
);
}
if (status === 'completed') {
return <CheckCircleOutlineIcon sx={{ fontSize: 10, color: c.status.success }} />;
}
if (status === 'error') {
return <ErrorOutlineIcon sx={{ fontSize: 10, color: c.status.error }} />;
}
return null;
};
@@ -5,6 +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 { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
@@ -102,31 +103,13 @@ const DiffViewer: React.FC<Props> = ({ sessionId }) => {
{loading ? (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>Loading...</Typography>
) : diff ? (
<pre
style={{
margin: 0,
fontSize: '0.72rem',
fontFamily: c.font.mono,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{diff.split('\n').map((line, i) => {
let color = c.text.muted;
if (line.startsWith('+') && !line.startsWith('+++')) color = c.status.success;
else if (line.startsWith('-') && !line.startsWith('---')) color = c.status.error;
else if (line.startsWith('@@')) color = c.accent.primary;
else if (line.startsWith('diff ') || line.startsWith('index ')) color = c.text.tertiary;
return (
<span key={i} style={{ color }}>
{line}
{'\n'}
</span>
);
})}
</pre>
<CodeDiff
id={`diff-${sessionId}`}
patch={diff}
language="diff"
diffStyle="unified"
lineNumbers="visible"
/>
) : (
<Typography sx={{ color: c.text.ghost, fontSize: '0.8rem' }}>
No changes detected in the worktree.
@@ -1,136 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import EmailIcon from '@mui/icons-material/Email';
import SendIcon from '@mui/icons-material/Send';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCardColors } from './toolCallColors';
import { getGmailHeader, formatTimestamp, stripHtml } from './toolCallUtils';
export { getGmailHeader } from './toolCallUtils';
export 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 };
}
export const GmailCard: React.FC<{ data: Record<string, any>; action: string; hideSubjectHeader?: boolean }> = ({ data, action, hideSubjectHeader }) => {
const c = useClaudeTokens();
const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_MUTED, TC_DIM, TC_ACCENT, TC_SUCCESS, TC_WARNING } = useCardColors();
const email = extractEmailFields(data);
const labels = data.labelIds || data.labels || [];
const attachments = data.attachments || [];
const isSend = action.includes('send');
const isSearch = action.includes('search') || action.includes('list');
const messages: any[] = data.messages || (isSearch && data.results ? data.results : []);
if (messages.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, p: 1.5, pt: 1 }}>
{messages.slice(0, 5).map((msg: any, i: number) => {
const m = extractEmailFields(msg);
return (
<Box key={i} sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, px: 1.25, py: 1, display: 'flex', flexDirection: 'column', gap: 0.4, transition: 'background-color 0.15s', '&:hover': { bgcolor: TC_HOVER } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 1 }}>
<span style={{ color: TC_HEADING, fontSize: '0.74rem', fontWeight: 600, fontFamily: c.font.sans }}>{m.subject}</span>
{m.date && <span style={{ color: TC_DIM, fontSize: '0.6rem', flexShrink: 0, fontFamily: c.font.mono }}>{m.date}</span>}
</Box>
{m.from && <span style={{ color: TC_MUTED, fontSize: '0.68rem', fontFamily: c.font.sans }}>{m.from}</span>}
{(m.snippet || m.bodyPreview) && (
<span style={{ color: TC_BODY, fontSize: '0.68rem', lineHeight: 1.45, fontFamily: c.font.sans }}>
{(m.snippet || m.bodyPreview).slice(0, 120)}{(m.snippet || m.bodyPreview).length > 120 ? '…' : ''}
</span>
)}
</Box>
);
})}
{messages.length > 5 && <span style={{ color: TC_DIM, fontSize: '0.66rem', fontStyle: 'italic', textAlign: 'center', display: 'block' }}>+{messages.length - 5} more</span>}
</Box>
);
}
return (
<Box sx={{ ...(hideSubjectHeader ? { overflow: 'hidden' } : { bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, overflow: 'hidden' }) }}>
{!hideSubjectHeader && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.85, borderBottom: `1px solid ${TC_BORDER}` }}>
{isSend ? <SendIcon sx={{ fontSize: 14, color: TC_SUCCESS, opacity: 0.8 }} /> : <EmailIcon sx={{ fontSize: 14, color: TC_ACCENT, opacity: 0.8 }} />}
<span style={{ color: TC_HEADING, fontSize: '0.78rem', fontWeight: 600, flex: 1, fontFamily: c.font.sans }}>{email.subject}</span>
</Box>
)}
<Box sx={{ px: 1.5, py: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{(email.from || email.to || email.date) && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.3 }}>
{email.from && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>From</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.from}</span>
</Box>
)}
{email.to && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>To</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.to}</span>
</Box>
)}
{email.date && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 32, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Date</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{email.date}</span>
</Box>
)}
</Box>
)}
{labels.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.4, flexWrap: 'wrap', mt: 0.15 }}>
{labels.map((l: string, i: number) => (
<Box key={i} sx={{ display: 'inline-flex', alignItems: 'center', bgcolor: `${TC_ACCENT}18`, borderRadius: 0.75, px: 0.6, py: 0.1 }}>
<span style={{ fontSize: '0.56rem', color: TC_ACCENT, fontFamily: c.font.mono, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{l}</span>
</Box>
))}
</Box>
)}
{(email.snippet || email.bodyPreview) && (
<Box sx={{
mt: 0.25, pt: 0.5, borderTop: `1px solid ${TC_BORDER}`, color: TC_BODY, fontFamily: c.font.sans, fontSize: '0.7rem', lineHeight: 1.6, overflowWrap: 'anywhere', wordBreak: 'break-word',
'& p': { m: 0, mb: 0.75, '&:last-child': { mb: 0 } },
'& h1, & h2, & h3, & h4, & h5, & h6': { color: TC_HEADING, fontFamily: c.font.sans, mt: 1, mb: 0.5, '&:first-of-type': { mt: 0 } },
'& h1': { fontSize: '0.82rem' }, '& h2': { fontSize: '0.78rem' }, '& h3': { fontSize: '0.74rem' }, '& h4, & h5, & h6': { fontSize: '0.7rem' },
'& strong': { color: TC_HEADING, fontWeight: 600 }, '& em': { fontStyle: 'italic' },
'& a': { color: TC_ACCENT, 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 ${TC_BORDER}`, color: TC_MUTED, fontStyle: 'italic' },
'& code': { bgcolor: `${TC_BORDER}`, px: 0.4, py: 0.15, borderRadius: 0.5, fontSize: '0.65rem', fontFamily: c.font.mono },
'& pre': { bgcolor: `${TC_BORDER}`, borderRadius: 1, p: 1, overflow: 'auto', fontSize: '0.65rem', fontFamily: c.font.mono, m: 0, mb: 0.75 },
'& pre code': { bgcolor: 'transparent', p: 0 },
'& hr': { border: 'none', borderTop: `1px solid ${TC_BORDER}`, my: 0.75 },
'& img': { maxWidth: '100%', borderRadius: 1 },
}}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ children, ...props }) => <a {...props}>{children}</a> }}>
{email.bodyPreview || email.snippet}
</ReactMarkdown>
</Box>
)}
{attachments.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.4, flexWrap: 'wrap', mt: 0.2 }}>
{attachments.map((a: any, i: number) => (
<Box key={i} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.3, bgcolor: `${TC_WARNING}15`, borderRadius: 0.75, px: 0.6, py: 0.1 }}>
<AttachFileIcon sx={{ fontSize: 9, color: TC_WARNING, opacity: 0.7 }} />
<span style={{ fontSize: '0.58rem', color: TC_WARNING, fontFamily: c.font.mono }}>{a.filename || a.name || 'attachment'}</span>
</Box>
))}
</Box>
)}
</Box>
</Box>
);
};
@@ -1,158 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import EventIcon from '@mui/icons-material/Event';
import FolderIcon from '@mui/icons-material/Folder';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useCardColors, useTermColors } from './toolCallColors';
import { formatTimestamp, ParsedMcpResult } from './toolCallUtils';
import { GmailCard } from './GmailCard';
const CalendarCard: React.FC<{ data: Record<string, any>; hideHeader?: boolean }> = ({ data, hideHeader }) => {
const c = useClaudeTokens();
const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_BODY, TC_DIM, TC_SUCCESS } = useCardColors();
const items: any[] = data.items || (Array.isArray(data) ? data : []);
const single = !items.length ? data : null;
if (single && (single.summary || single.start)) {
const start = single.start?.dateTime || single.start?.date || single.start || '';
const end = single.end?.dateTime || single.end?.date || single.end || '';
return (
<Box sx={{ ...(hideHeader ? { overflow: 'hidden' } : { bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, overflow: 'hidden' }) }}>
{!hideHeader && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, px: 1.5, py: 0.85, borderBottom: `1px solid ${TC_BORDER}` }}>
<EventIcon sx={{ fontSize: 14, color: TC_SUCCESS, opacity: 0.8 }} />
<span style={{ color: TC_HEADING, fontSize: '0.78rem', fontWeight: 600, fontFamily: c.font.sans }}>{single.summary || '(no title)'}</span>
</Box>
)}
<Box sx={{ px: 1.5, py: 1, display: 'flex', flexDirection: 'column', gap: 0.3 }}>
{start && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Start</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{formatTimestamp(start)}</span>
</Box>
)}
{end && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>End</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{formatTimestamp(end)}</span>
</Box>
)}
{single.location && (
<Box sx={{ display: 'flex', gap: 0.75, fontSize: '0.7rem', alignItems: 'baseline' }}>
<span style={{ color: TC_DIM, minWidth: 48, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Where</span>
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{single.location}</span>
</Box>
)}
{single.description && (
<Box sx={{ mt: 0.3, pt: 0.5, borderTop: `1px solid ${TC_BORDER}` }}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: c.font.sans, fontSize: '0.68rem', lineHeight: 1.5, color: TC_BODY }}>
{single.description.slice(0, 300)}{single.description.length > 300 ? '…' : ''}
</pre>
</Box>
)}
</Box>
</Box>
);
}
if (items.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, p: 1.5, pt: 1 }}>
{items.slice(0, 6).map((item: any, i: number) => (
<Box key={i} sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, px: 1.25, py: 0.75, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 1, transition: 'background-color 0.15s', '&:hover': { bgcolor: TC_HOVER } }}>
<span style={{ color: TC_HEADING, fontSize: '0.72rem', fontWeight: 500, fontFamily: c.font.sans }}>{item.summary || '(no title)'}</span>
<span style={{ color: TC_DIM, fontSize: '0.6rem', flexShrink: 0, fontFamily: c.font.mono }}>{formatTimestamp(item.start?.dateTime || item.start?.date || item.start)}</span>
</Box>
))}
{items.length > 6 && <span style={{ color: TC_DIM, fontSize: '0.64rem', fontStyle: 'italic', textAlign: 'center', display: 'block' }}>+{items.length - 6} more</span>}
</Box>
);
}
return null;
};
const DriveCard: React.FC<{ data: Record<string, any> }> = ({ data }) => {
const c = useClaudeTokens();
const { TC_BG, TC_BORDER, TC_HOVER, TC_HEADING, TC_DIM, TC_WARNING } = useCardColors();
const files: any[] = data.files || (Array.isArray(data) ? data : []);
const single = !files.length && data.name ? data : null;
if (single) {
return (
<Box sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, mx: 1.5, my: 1, px: 1.25, py: 0.85, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<FolderIcon sx={{ fontSize: 16, color: TC_WARNING, opacity: 0.7 }} />
<Box>
<span style={{ color: TC_HEADING, fontSize: '0.73rem', fontWeight: 500, display: 'block', fontFamily: c.font.sans }}>{single.name}</span>
{single.mimeType && <span style={{ color: TC_DIM, fontSize: '0.6rem', fontFamily: c.font.mono }}>{single.mimeType}</span>}
</Box>
</Box>
);
}
if (files.length > 0) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1.5, pt: 1 }}>
{files.slice(0, 8).map((f: any, i: number) => (
<Box key={i} sx={{ bgcolor: TC_BG, border: `1px solid ${TC_BORDER}`, borderRadius: 1.5, px: 1.25, py: 0.6, display: 'flex', alignItems: 'center', gap: 0.75, transition: 'background-color 0.15s', '&:hover': { bgcolor: TC_HOVER } }}>
<FolderIcon sx={{ fontSize: 13, color: TC_WARNING, opacity: 0.5 }} />
<span style={{ color: TC_HEADING, fontSize: '0.7rem', fontFamily: c.font.sans }}>{f.name || f.id}</span>
{f.mimeType && <span style={{ color: TC_DIM, fontSize: '0.58rem', flexShrink: 0, fontFamily: c.font.mono }}>{f.mimeType.split('/').pop()}</span>}
</Box>
))}
</Box>
);
}
return null;
};
const GenericMcpCard: React.FC<{ data: Record<string, any> }> = ({ data }) => {
const c = useClaudeTokens();
const { TC_DIM, TC_BODY } = useCardColors();
const entries = Object.entries(data).filter(([, v]) => v != null);
if (entries.length === 0)
return <span style={{ color: TC_DIM, fontStyle: 'italic', fontSize: '0.7rem', padding: '8px 12px', display: 'block' }}>(empty response)</span>;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.3, px: 1.5, py: 1 }}>
{entries.slice(0, 20).map(([key, val], i) => {
const isLong = typeof val === 'string' && val.length > 100;
const isObj = typeof val === 'object';
return (
<Box key={i} sx={{ fontSize: '0.7rem', display: 'flex', gap: 0.75, lineHeight: 1.5 }}>
<span style={{ color: TC_DIM, minWidth: 72, flexShrink: 0, fontWeight: 500, fontFamily: c.font.mono, fontSize: '0.62rem', textTransform: 'uppercase', letterSpacing: '0.03em', paddingTop: 1 }}>{key}</span>
{isObj ? (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: TC_BODY, fontFamily: c.font.mono, fontSize: '0.68rem' }}>{JSON.stringify(val, null, 2).slice(0, 500)}</pre>
) : isLong ? (
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: TC_BODY, fontFamily: c.font.sans, fontSize: '0.68rem' }}>{String(val).slice(0, 500)}{String(val).length > 500 ? '…' : ''}</pre>
) : (
<span style={{ color: TC_BODY, fontFamily: c.font.sans }}>{String(val)}</span>
)}
</Box>
);
})}
{entries.length > 20 && <span style={{ color: TC_DIM, fontSize: '0.62rem', fontStyle: 'italic' }}>+{entries.length - 20} more fields</span>}
</Box>
);
};
export const McpResultCard: React.FC<{ parsed: ParsedMcpResult; compact?: boolean }> = ({ parsed, compact }) => {
const tc = useTermColors();
const { service, action, data } = parsed;
if (data.error || data.is_error) {
return (
<Box sx={{ p: 1 }}>
<span style={{ color: tc.STDERR_COLOR, fontSize: '0.73rem' }}>{data.error || data.message || JSON.stringify(data, null, 2)}</span>
</Box>
);
}
if (service === 'gmail') return <GmailCard data={data} action={action} hideSubjectHeader={compact} />;
if (service === 'calendar') return <CalendarCard data={data} hideHeader={compact} />;
if (service === 'drive' || service === 'sheets') return <DriveCard data={data} />;
return <GenericMcpCard data={data} />;
};
@@ -12,10 +12,9 @@ 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 BrowserAgentInlineFeed from './BrowserAgentInlineFeed';
import { useTermColors, colorizeInput, colorizeOutput } from './toolCallColors';
import { ElapsedTimer } from './ElapsedTimer';
import { McpResultCard } from './McpServiceCards';
import { BrowserFeedTracker, renderParsedMcpData } from './toolkit/mcp-tools';
import { InvokeAgentBubble, CreateAgentBubble } from './AgentToolBubble';
import {
ToolCallBubbleProps, ensureToolCallKeyframes, getToolData, parseMcpToolName,
@@ -88,9 +87,9 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
</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} />}
{isBrowserAgent && sessionId && <BrowserFeedTracker parentSessionId={sessionId} browserId={input?.browser_id} />}
{parsedResult && parsedResult.type === 'mcp' ? (
<McpResultCard parsed={parsedResult} compact />
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}
@@ -142,9 +141,9 @@ const ToolCallBubble: React.FC<ToolCallBubbleProps> = React.memo(
{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} />}
{isBrowserAgent && sessionId && <BrowserFeedTracker parentSessionId={sessionId} browserId={input?.browser_id} />}
{parsedResult && parsedResult.type === 'mcp' ? (
<McpResultCard parsed={parsedResult} />
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' ? (
@@ -1,129 +0,0 @@
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined';
import KeyboardOutlinedIcon from '@mui/icons-material/KeyboardOutlined';
import CameraAltOutlinedIcon from '@mui/icons-material/CameraAltOutlined';
import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined';
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined';
import BuildOutlinedIcon from '@mui/icons-material/BuildOutlined';
import type { AgentMessage } from '@/shared/state/agentsSlice';
export interface FeedEntry {
type: 'thought' | 'action' | 'result' | 'system';
text: string;
actionTool?: string;
sessionLabel?: string;
}
export interface FeedColors {
thought: string;
thoughtIcon: string;
result: string;
error: string;
errorIcon: string;
scrollThumb: string;
}
export const darkFeedColors: FeedColors = {
thought: '#a0aab8',
thoughtIcon: '#555b6e',
result: '#555b6e',
error: '#ff8787',
errorIcon: '#ff8787',
scrollThumb: '#2a2d3e',
};
export const lightFeedColors: FeedColors = {
thought: '#555550',
thoughtIcon: '#9e9c95',
result: '#9e9c95',
error: '#c03030',
errorIcon: '#c03030',
scrollThumb: '#ccc9c0',
};
export function formatMessage(msg: AgentMessage): FeedEntry | null {
if (msg.role === 'user') return null;
if (msg.role === 'assistant' && typeof msg.content === 'string') {
const trimmed = msg.content.trim();
if (!trimmed) return null;
return { type: 'thought', text: trimmed };
}
if (msg.role === 'tool_call') {
const content =
typeof msg.content === 'string'
? (() => { try { return JSON.parse(msg.content); } catch { return {}; } })()
: msg.content;
const tool = content?.tool || content?.name || '?';
const input = content?.input || {};
let brief = '';
switch (tool) {
case 'BrowserNavigate':
brief = `Navigate → ${input.url || '...'}`;
break;
case 'BrowserClick':
brief = `Click ${input.selector || '...'}`;
break;
case 'BrowserType': {
const txt = (input.text || '').slice(0, 40);
const ellipsis = (input.text || '').length > 40 ? '…' : '';
brief = `Type "${txt}${ellipsis}" into ${input.selector || '...'}`;
break;
}
case 'BrowserScreenshot':
brief = 'Screenshot';
break;
case 'BrowserGetText':
brief = 'Read page text';
break;
case 'BrowserGetElements':
brief = `Inspect elements${input.selector ? ` (${input.selector})` : ''}`;
break;
case 'BrowserEvaluate':
brief = `Evaluate JS`;
break;
default:
brief = `${tool}(${JSON.stringify(input).slice(0, 60)})`;
}
return { type: 'action', text: brief, actionTool: tool };
}
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 toolName = content?.tool_name || '';
const elapsed = content?.elapsed_ms;
const text = content?.text || '';
if (toolName === 'BrowserScreenshot') {
return { type: 'result', text: `Screenshot captured${elapsed ? ` (${elapsed}ms)` : ''}` };
}
const preview = text.length > 120 ? text.slice(0, 120) + '…' : text;
return { type: 'result', text: `${preview}${elapsed ? ` (${elapsed}ms)` : ''}` };
}
if (msg.role === 'system') {
return { type: 'system', text: typeof msg.content === 'string' ? msg.content : '' };
}
return null;
}
export type SvgIconComponent = typeof OpenInNewIcon;
export function getActionIcon(tool?: string): SvgIconComponent {
switch (tool) {
case 'BrowserNavigate': return OpenInNewIcon;
case 'BrowserClick': return TouchAppOutlinedIcon;
case 'BrowserType': return KeyboardOutlinedIcon;
case 'BrowserScreenshot': return CameraAltOutlinedIcon;
case 'BrowserGetText': return ArticleOutlinedIcon;
case 'BrowserGetElements': return AccountTreeOutlinedIcon;
case 'BrowserEvaluate': return CodeOutlinedIcon;
default: return BuildOutlinedIcon;
}
}
@@ -1,3 +1,131 @@
import React from 'react';
import type { ReactNode } from 'react';
import type { Toolkit } from '@assistant-ui/react';
import { InvokeAgentBubble, CreateAgentBubble } from '../AgentToolBubble';
import ViewBubble from '../ViewBubble';
export const customToolkit: Partial<Toolkit> = {};
// ---------------------------------------------------------------------------
// Render-props contract (mirrors native-tools.tsx pattern)
// ---------------------------------------------------------------------------
interface RP {
args: unknown;
result: unknown;
status: { type: string };
toolCallId: string;
}
type BE = { type: 'backend'; render: (p: RP) => ReactNode };
const be = (render: (p: RP) => ReactNode): BE => ({ type: 'backend', render });
// ---------------------------------------------------------------------------
// Prop bridges — ToolCallMessagePartProps → legacy component interfaces
// ---------------------------------------------------------------------------
function bridgeToCallMessage(
toolCallId: string,
toolName: string,
args: unknown,
) {
return {
id: toolCallId,
role: 'tool_call' as const,
content: { tool: toolName, input: args || {}, id: toolCallId },
timestamp: Date.now(),
};
}
function bridgeToResultMessage(toolCallId: string, result: unknown) {
if (result == null) return null;
let text: string;
if (typeof result === 'string') {
text = result;
} else if (typeof result === 'object') {
const r = result as Record<string, unknown>;
text =
typeof r.text === 'string'
? r.text
: typeof r.content === 'string'
? r.content
: JSON.stringify(result);
} else {
text = String(result);
}
return {
id: `${toolCallId}-result`,
role: 'tool_result' as const,
content: { text },
};
}
// ---------------------------------------------------------------------------
// InvokeAgent
// ---------------------------------------------------------------------------
const invokeAgentRenderer = be(({ args, result, status, toolCallId }) => {
const call = bridgeToCallMessage(toolCallId, 'InvokeAgent', args);
const resultMsg =
status.type !== 'running' && result != null
? bridgeToResultMessage(toolCallId, result)
: null;
return (
<InvokeAgentBubble
call={call as any}
result={resultMsg as any}
isPending={status.type === 'running'}
isStreaming={false}
/>
);
});
// ---------------------------------------------------------------------------
// CreateAgent (tool name "Agent")
// ---------------------------------------------------------------------------
const createAgentRenderer = be(({ args, result, status, toolCallId }) => {
const call = bridgeToCallMessage(toolCallId, 'Agent', args);
const resultMsg =
status.type !== 'running' && result != null
? bridgeToResultMessage(toolCallId, result)
: null;
return (
<CreateAgentBubble
call={call as any}
result={resultMsg as any}
isPending={status.type === 'running'}
isStreaming={false}
/>
);
});
// ---------------------------------------------------------------------------
// RenderOutput → ViewBubble
// ---------------------------------------------------------------------------
const renderOutputRenderer = be(({ args, result, status }) => {
const toolInput =
typeof args === 'object' && args !== null
? (args as Record<string, any>)
: {};
const toolResult = result as string | Record<string, any> | undefined;
return (
<ViewBubble
toolInput={toolInput}
toolResult={toolResult}
isStreaming={status.type === 'running'}
/>
);
});
// ---------------------------------------------------------------------------
// Exported toolkit
// ---------------------------------------------------------------------------
export const customToolkit: Partial<Toolkit> = {
InvokeAgent: invokeAgentRenderer,
Agent: createAgentRenderer,
RenderOutput: renderOutputRenderer,
} as Partial<Toolkit>;
@@ -0,0 +1,173 @@
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,3 +1,224 @@
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';
import {
parseMcpToolName, getGmailHeader, formatTimestamp, stripHtml,
} from '../toolCallUtils';
export { BrowserFeedTracker } from './mcp-browser-feed';
// -- Helpers ----------------------------------------------------------------
/** Unwrap MCP result (string / content-block array / object) into data. */
export function extractMcpData(result: unknown): Record<string, any> {
if (result == null) return {};
let text: string | undefined;
if (typeof result === 'string') {
text = result;
} else if (typeof result === 'object') {
const r = result as Record<string, unknown>;
for (const k of ['text', 'output', 'content', 'result']) {
if (typeof r[k] === 'string') { text = r[k] as string; break; }
}
if (text === undefined) return result as Record<string, any>;
} else {
return {};
}
if (!text) return {};
try {
let parsed = JSON.parse(text);
if (
Array.isArray(parsed) &&
parsed.some((b: any) => b?.type === 'text' && typeof b?.text === 'string')
) {
const joined = parsed
.filter((b: any) => b?.type === 'text')
.map((b: any) => b.text)
.join('\n');
try { parsed = JSON.parse(joined); } catch { return {}; }
}
if (typeof parsed === 'object' && parsed !== null) return parsed;
} catch { /* not JSON */ }
return {};
}
export 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;
}
}
/** Full MCP dispatch — parses raw tool name + result, routes to renderer. */
export function renderMcpResult(
toolName: string, result: unknown, toolCallId: string,
): ReactNode | null {
const mcpInfo = parseMcpToolName(toolName);
const data = extractMcpData(result);
return renderParsedMcpData(mcpInfo.service, mcpInfo.action, data, toolCallId);
}
// -- Exported toolkit (empty — MCP tool names are dynamic; Agent 7 wires) ---
export const mcpToolkit: Partial<Toolkit> = {};
File diff suppressed because one or more lines are too long