mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[Haik]: Agentic Refactor 6. MCP Cards & Browser Feed
This commit is contained in:
@@ -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
Reference in New Issue
Block a user