[Haik]: ckpt, (refactored remaining >250 line files to be less then 250) (made some fixes to the way 9router runs locally, but theres more issues stemming from the fact that local and prod backends run on the same port, fixing this rn)

This commit is contained in:
haikdc
2026-03-30 07:36:51 -07:00
parent 3b86a79366
commit 389679660b
25 changed files with 2997 additions and 989 deletions
+165
View File
@@ -0,0 +1,165 @@
# assistant-ui + Tool UI Migration Plan
## Goal
Replace the custom chat UI in `frontend/src/app/pages/AgentChat/` with [assistant-ui](https://www.assistant-ui.com/) (React chat primitives) and [Tool UI](https://www.tool-ui.com/) (tool call rendering components). This replaces ~5,800 lines of custom chat code with maintained, accessible, schema-driven components.
## Decisions
- **Tailwind + MUI coexist**: Tailwind is added for assistant-ui/Tool UI; MUI stays for non-chat pages (Dashboard, Settings, etc.)
- **Clean swap**: No feature flag — old code is replaced directly
- **No custom styling**: Use default assistant-ui/Tool UI appearance as-is (no theming to match MUI)
- **SkillBuilderChat**: Included in migration
- **Sub-agent rendering**: Kept as-is, wired into toolkit as custom tool UIs
## Architecture: Before → After
### Before
```
AgentChat.tsx
├── useAgentChat.ts (WS lifecycle, session state)
├── useMessageRendering.ts (branch resolution, render items)
├── ChatInput.tsx + CommandPicker (custom contentEditable + @/slash picker)
├── MessageBubble → UserBubbleContent / AssistantBubbleContent (custom markdown)
├── MessageActionBar (copy/edit/regen/branch)
├── BranchNavigator (custom branch picker)
├── ToolCallBubble + toolCallColors + toolCallUtils (custom terminal rendering)
├── ToolGroupBubble (custom accordion)
├── ApprovalBar / BatchApprovalBar / QuestionForm (custom HITL)
├── GmailCard / McpServiceCards (custom MCP rendering)
├── BrowserAgentInlineFeed (custom progress feed)
├── DiffViewer (custom diff rendering)
└── ThinkingBubble (custom reasoning display)
```
### After
```
AgentChat.tsx
├── AssistantRuntimeProvider + ExternalStoreRuntime (bridges Redux + WS)
├── OpenSwarmThread (assistant-ui Thread + Message + ActionBar + BranchPicker)
├── OpenSwarmComposer (assistant-ui Composer + ComposerMentionPopover)
├── Toolkit registry
│ ├── native-tools.tsx → Tool UI Terminal, CodeBlock, CodeDiff
│ ├── approval-tools.tsx → Tool UI ApprovalCard, QuestionFlow, OptionList
│ ├── mcp-tools.tsx → Tool UI MessageDraft, DataTable, ProgressTracker
│ └── custom-tools.tsx → AgentToolBubble, ViewBubble (kept as-is)
├── ModelModeSelector (kept, MUI)
├── MessageQueue (kept, MUI)
└── ChatHeader (kept, MUI)
```
## Phases & Dependency Graph
```
┌─────────────────────────────────────┐
│ PHASE 1 — Sequential (blocking) │
│ Agent 1: Foundation & Packages │
└──────────────┬──────────────────────┘
┌─────────┼──────────┬──────────────┬──────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────┐┌─────────┐┌──────────┐┌──────────┐┌──────────────┐
│ Agent 2 ││ Agent 3 ││ Agent 4 ││ Agent 5 ││ Agent 6 │
│ Thread ││Composer ││ Tool UI: ││ Tool UI: ││ Tool UI: │
│ & ││ & ││ Native ││Approvals ││ MCP Cards │
│Messages ││Mentions ││ Tools ││ & ││ & Browser │
│ ││ ││ ││Questions ││ Feed │
└────┬────┘└────┬────┘└────┬─────┘└────┬─────┘└──────┬───────┘
│ │ │ │ │
│ PHASE 2 — All 5 agents run in parallel │
│ │ │ │ │
└─────────┴──────────┴────────────┴─────────────┘
┌───────────▼───────────────┐
│ PHASE 3 — Sequential │
│ Agent 7: Integration, │
│ SkillBuilderChat & │
│ Cleanup │
└───────────────────────────┘
```
## Agent Summary
| Agent | Plan File | Phase | What It Does |
|-------|-----------|-------|-------------|
| 1 | `MIGRATION_AGENT_1.md` | 1 (sequential) | Install Tailwind, shadcn, assistant-ui, Tool UI packages. Create ExternalStoreRuntime adapter, toolkit skeleton, new directory structure. Scaffold `AgentChat.tsx` with provider wrapper. |
| 2 | `MIGRATION_AGENT_2.md` | 2 (parallel) | Replace message list with `Thread`. Replace bubbles with `Message` primitives. Replace `MessageActionBar``ActionBar`, `BranchNavigator``BranchPicker`, `ThinkingBubble``Reasoning`. |
| 3 | `MIGRATION_AGENT_3.md` | 2 (parallel) | Replace `ChatInput` + `CommandPicker` with `Composer` + `ComposerMentionPopover`. Create `MentionAdapter` for templates/skills/modes/tools/files. |
| 4 | `MIGRATION_AGENT_4.md` | 2 (parallel) | Register Tool UI components for native tools: `Terminal` (bash), `CodeBlock` (file read), `CodeDiff` (edit/diff). Replace `ToolCallBubble`, `toolCallColors`, `toolCallUtils`. |
| 5 | `MIGRATION_AGENT_5.md` | 2 (parallel) | Register Tool UI components for approvals: `ApprovalCard`, `QuestionFlow`, `OptionList`. Replace `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`. |
| 6 | `MIGRATION_AGENT_6.md` | 2 (parallel) | Register Tool UI for MCP services: `MessageDraft` (Gmail), `DataTable` (Calendar/Drive), `ProgressTracker` (browser feed). Wire `AgentToolBubble` and `ViewBubble` as custom toolkit entries. |
| 7 | `MIGRATION_AGENT_7.md` | 3 (sequential) | Wire all pieces in `AgentChat.tsx`. Migrate `SkillBuilderChat`. Delete all dead files. Verify build. |
## File Ownership (Parallel Safety)
During Phase 2, each agent only touches files it owns. No conflicts.
| Agent | Creates | Modifies | Deletes |
|-------|---------|----------|---------|
| 2 | `thread/` directory | Nothing shared | `MessageBubble`, `UserBubbleContent`, `AssistantBubbleContent`, `MessageActionBar`, `BranchNavigator`, `ThinkingBubble`, `MessageImageThumbnails`, `messageBubbleUtils`, `useMessageRendering` |
| 3 | `composer/` directory | Nothing shared | `ChatInput`, `useChatSubmit`, `CommandPicker`, `commandPickerTypes`, `useCommandPickerItems`, `CommandPickerIcons`, `SlashCommandPicker`, `AttachmentChips`, `ImageAttachments`, `richEditorUtils`, `RichPromptEditor` |
| 4 | `toolkit/native-tools.tsx` | Nothing shared | `ToolCallBubble`, `toolCallColors`, `toolCallUtils`, `ElapsedTimer`, `ToolGroupBubble` |
| 5 | `toolkit/approval-tools.tsx` | Nothing shared | `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`, `approvalUtils` |
| 6 | `toolkit/mcp-tools.tsx`, `toolkit/custom-tools.tsx` | Nothing shared | `GmailCard`, `McpServiceCards`, `BrowserAgentInlineFeed`, `BrowserFeedEntryRow`, `browserFeedUtils`, `DiffViewer` |
| 7 | (integration) | `AgentChat.tsx`, `SkillBuilderChat.tsx` | Remaining dead imports/files |
## Key Technical Notes
### ExternalStoreRuntime Adapter
The runtime adapter (`runtime/useOpenSwarmRuntime.ts`) bridges Redux ↔ assistant-ui:
- **Messages**: Read from `session.messages` + `session.streamingMessage` in Redux
- **isRunning**: Derived from `session.status === 'running'`
- **onNew**: Dispatches `sendMessage` thunk → WebSocket
- **onEdit**: Dispatches `editMessage` thunk → REST API
- **onCancel**: Dispatches `stopAgent` thunk → REST API
- **Branches**: Mapped from `session.branches` + `session.active_branch_id`
### Message Format Conversion
Redux `AgentMessage` → assistant-ui format:
- `role: 'user'``{ role: 'user', content: [{ type: 'text', text }] }`
- `role: 'assistant'``{ role: 'assistant', content: [{ type: 'text', text }] }`
- `role: 'tool_call'``{ role: 'assistant', content: [{ type: 'tool-call', toolCallId, toolName, args }] }`
- `role: 'tool_result'``{ role: 'tool', content: [{ type: 'tool-result', toolCallId, result }] }`
### Tailwind + MUI Coexistence
Tailwind is configured with a prefix or scoped to assistant-ui components to avoid conflicts with MUI's global styles. The `important` selector strategy or Tailwind's `prefix` option may be needed.
### Tool UI Registration Pattern
All Tool UI components are registered in toolkit files. Each toolkit file exports a partial `Toolkit` object. The `toolkit/index.ts` merges them:
```typescript
import { nativeToolkit } from './native-tools';
import { approvalToolkit } from './approval-tools';
import { mcpToolkit } from './mcp-tools';
import { customToolkit } from './custom-tools';
export const toolkit: Toolkit = {
...nativeToolkit,
...approvalToolkit,
...mcpToolkit,
...customToolkit,
};
```
## Lines of Code Impact (Estimated)
| Category | Lines |
|----------|-------|
| Deleted (old components) | ~5,800 |
| New bridge/adapter code | ~600800 |
| Tool UI components (installed, not authored) | ~2,000 (maintained externally) |
| **Net reduction in authored code** | **~5,000** |
## Estimated Effort
| Phase | Agents | Estimated Time |
|-------|--------|---------------|
| Phase 1 | Agent 1 | 12 hours |
| Phase 2 | Agents 26 (parallel) | 24 hours each, ~4 hours wall clock |
| Phase 3 | Agent 7 | 12 hours |
| **Total wall clock** | | **~68 hours** |
+268
View File
@@ -0,0 +1,268 @@
# Migration Agent 1: Foundation & Packages
## Objective
Install all required packages, configure Tailwind CSS alongside MUI, set up shadcn/ui, create the ExternalStoreRuntime adapter, create the toolkit skeleton, and scaffold the new directory structure. This agent's output is the foundation that all Phase 2 agents build on.
**This agent must complete before any other agent starts.**
## Constraints
- **No custom styling**: Do not theme assistant-ui or Tool UI components to match MUI. Use their default appearance.
- **Tailwind + MUI coexist**: Tailwind should not break existing MUI styles. Use Tailwind's `prefix` option (e.g., `tw-`) or scope it carefully.
- **Webpack 5**: This project uses Webpack 5 (not Next.js). Tailwind must be configured for Webpack with PostCSS.
- **React 18**: The project uses React 18.2. assistant-ui supports React 18 — check their compatibility docs at `/docs/react-compatibility`.
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use `assistantUIDocs` and `assistantUIExamples` tools to look up current API docs.
## Step-by-Step
### 1. Install Tailwind CSS for Webpack 5
```bash
cd frontend
npm install -D tailwindcss @tailwindcss/postcss postcss postcss-loader
```
Create `frontend/postcss.config.js`:
```js
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};
```
Create `frontend/src/styles/tailwind.css`:
```css
@import "tailwindcss";
```
Update `webpack.config.js` to add a CSS rule with PostCSS for `.css` files (not `.module.scss` — those stay as-is):
```js
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader']
}
```
Import the tailwind CSS in `frontend/src/index.tsx`:
```typescript
import './styles/tailwind.css';
```
Verify: `npm run dev` should start without errors, existing MUI pages should look unchanged.
### 2. Install shadcn/ui
shadcn/ui typically assumes Next.js, but works with Webpack. Run:
```bash
npx shadcn@latest init
```
When prompted:
- Style: Default
- Base color: Neutral (or Slate — doesn't matter since we're not custom styling)
- CSS variables: Yes
- Path alias: `@/` (matches existing tsconfig paths)
- Components directory: `src/components/ui`
This creates `components.json` and a `lib/utils.ts` file. Ensure `cn()` utility works.
### 3. Install assistant-ui
```bash
npm install @assistant-ui/react @assistant-ui/react-markdown
```
Optionally, for the Lexical rich editor (used by Composer Mention):
```bash
npm install @assistant-ui/react-lexical lexical @lexical/react
```
Use `assistantUIDocs` MCP tool to check the installation page (`/docs/installation`) for the latest install instructions and any peer dependencies.
Add assistant-ui's default styles. Check their docs for the exact import — it may be:
```typescript
import "@assistant-ui/react/styles/index.css";
```
Or they may use Tailwind-based styling via shadcn components. Follow whatever the current docs say.
### 4. Install ALL Tool UI components upfront
Install every Tool UI component the migration needs. This prevents parallel agents from running conflicting `npx shadcn` installs.
```bash
npx shadcn@latest add @tool-ui/terminal
npx shadcn@latest add @tool-ui/code-block
npx shadcn@latest add @tool-ui/code-diff
npx shadcn@latest add @tool-ui/approval-card
npx shadcn@latest add @tool-ui/question-flow
npx shadcn@latest add @tool-ui/option-list
npx shadcn@latest add @tool-ui/message-draft
npx shadcn@latest add @tool-ui/data-table
npx shadcn@latest add @tool-ui/item-carousel
npx shadcn@latest add @tool-ui/progress-tracker
```
Each installs to `src/components/tool-ui/<component>/` with a component file, schema, and types.
### 5. Create the ExternalStoreRuntime adapter
Create `frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts`.
This hook bridges the Redux store + WebSocket to assistant-ui's runtime. Use `assistantUIDocs` to look up the ExternalStoreRuntime API at path `runtimes/custom/external-store`.
Key responsibilities:
- Read `session.messages` from Redux and convert to assistant-ui's message format
- Read `session.streamingMessage` and surface it as the in-progress message
- Map `session.status` to `isRunning`
- `onNew` → dispatch `sendMessage` thunk (existing in `agentsThunks.ts`)
- `onEdit` → dispatch `editMessage` thunk
- `onCancel` → dispatch `stopAgent` thunk
- Handle `session.branches` for branching support
**Message format conversion** — the critical mapping:
Redux `AgentMessage` types:
```typescript
interface AgentMessage {
id: string;
role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system';
content: any;
timestamp: string;
branch_id: string;
parent_id: string | null;
// ... other fields
}
```
assistant-ui expects messages like:
```typescript
// User message
{ role: 'user', content: [{ type: 'text', text: '...' }] }
// Assistant message
{ role: 'assistant', content: [{ type: 'text', text: '...' }] }
// Tool call (part of assistant message)
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: '...', toolName: '...', args: {} }] }
// Tool result
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: '...', result: {} }] }
```
Write a `convertMessages(messages: AgentMessage[]): ThreadMessage[]` function that handles this. Note that consecutive tool_call + tool_result messages may need to be merged or paired.
Look up the ExternalStoreRuntime docs carefully — the exact shape of `ThreadMessage` and how streaming is handled (via `status` field on messages) matters.
### 6. Create the toolkit skeleton
Create the directory `frontend/src/app/pages/AgentChat/toolkit/`.
Create `frontend/src/app/pages/AgentChat/toolkit/index.ts`:
```typescript
import { type Toolkit } from '@assistant-ui/react';
import { nativeToolkit } from './native-tools';
import { approvalToolkit } from './approval-tools';
import { mcpToolkit } from './mcp-tools';
import { customToolkit } from './custom-tools';
export const toolkit: Toolkit = {
...nativeToolkit,
...approvalToolkit,
...mcpToolkit,
...customToolkit,
};
```
Create placeholder files for each toolkit module:
- `toolkit/native-tools.tsx` — exports `nativeToolkit: Partial<Toolkit> = {}`
- `toolkit/approval-tools.tsx` — exports `approvalToolkit: Partial<Toolkit> = {}`
- `toolkit/mcp-tools.tsx` — exports `mcpToolkit: Partial<Toolkit> = {}`
- `toolkit/custom-tools.tsx` — exports `customToolkit: Partial<Toolkit> = {}`
These are empty stubs. Phase 2 agents fill them in.
### 7. Create Thread and Composer placeholder directories
Create:
- `frontend/src/app/pages/AgentChat/thread/` — Agent 2 fills this
- `frontend/src/app/pages/AgentChat/composer/` — Agent 3 fills this
Create placeholder files:
- `thread/OpenSwarmThread.tsx` — exports a simple `<div>Thread placeholder</div>`
- `composer/OpenSwarmComposer.tsx` — exports a simple `<div>Composer placeholder</div>`
### 8. Scaffold AgentChat.tsx with provider wrapper
Modify `AgentChat.tsx` to wrap the chat area with `AssistantRuntimeProvider`. Keep the existing structure but prepare for Phase 2 agents to swap in their components.
The modified AgentChat should:
1. Import and use `useOpenSwarmRuntime(sessionId)` to get the runtime
2. Wrap the chat area with `<AssistantRuntimeProvider runtime={runtime}>`
3. Register the toolkit via `useAui` or `Tools` (check assistant-ui docs for the current API)
4. Keep existing imports for `ChatHeader`, `MessageQueue`, `ModelModeSelector` (these stay)
5. Keep the `useAgentChat` hook (it manages WS lifecycle, send handlers, etc.)
6. For now, keep the existing message rendering loop AND the placeholders — the app should still work with the old rendering. Agent 7 does the final swap.
**Important**: The app must still work after this agent completes. Do not remove any existing components yet — just add the runtime wrapper and placeholders alongside them.
### 9. Verify everything works
- `npm run dev` starts without errors
- The existing chat page renders and works as before
- Tailwind utility classes work (test by adding a `tw-text-red-500` class somewhere temporarily)
- Tool UI component files exist in `src/components/tool-ui/`
- No TypeScript errors from the new imports
## Files Created
| File | Description |
|------|-------------|
| `frontend/postcss.config.js` | PostCSS config for Tailwind |
| `frontend/src/styles/tailwind.css` | Tailwind entry CSS |
| `frontend/components.json` | shadcn/ui config |
| `frontend/src/lib/utils.ts` | shadcn `cn()` utility |
| `frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts` | ExternalStoreRuntime adapter |
| `frontend/src/app/pages/AgentChat/toolkit/index.ts` | Merged toolkit registry |
| `frontend/src/app/pages/AgentChat/toolkit/native-tools.tsx` | Stub for Agent 4 |
| `frontend/src/app/pages/AgentChat/toolkit/approval-tools.tsx` | Stub for Agent 5 |
| `frontend/src/app/pages/AgentChat/toolkit/mcp-tools.tsx` | Stub for Agent 6 |
| `frontend/src/app/pages/AgentChat/toolkit/custom-tools.tsx` | Stub for Agent 6 |
| `frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx` | Placeholder for Agent 2 |
| `frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx` | Placeholder for Agent 3 |
| `frontend/src/components/tool-ui/*` | All Tool UI components (installed via shadcn) |
## Files Modified
| File | Change |
|------|--------|
| `frontend/package.json` | New dependencies added |
| `frontend/webpack.config.js` | CSS rule with postcss-loader added |
| `frontend/tsconfig.json` | May need path adjustments for shadcn |
| `frontend/src/index.tsx` | Import tailwind CSS |
| `frontend/src/app/pages/AgentChat/AgentChat.tsx` | Wrapped with `AssistantRuntimeProvider` |
## Files Deleted
None. This agent only adds — it never removes existing functionality.
## Verification Checklist
- [ ] `npm run dev` starts without errors
- [ ] Existing chat page works exactly as before
- [ ] Tailwind classes render correctly
- [ ] `src/components/tool-ui/terminal/` exists with component + schema
- [ ] `src/components/tool-ui/code-block/` exists with component + schema
- [ ] `src/components/tool-ui/code-diff/` exists with component + schema
- [ ] `src/components/tool-ui/approval-card/` exists with component + schema
- [ ] `src/components/tool-ui/question-flow/` exists with component + schema
- [ ] `src/components/tool-ui/option-list/` exists with component + schema
- [ ] `src/components/tool-ui/message-draft/` exists with component + schema
- [ ] `src/components/tool-ui/data-table/` exists with component + schema
- [ ] `src/components/tool-ui/progress-tracker/` exists with component + schema
- [ ] `useOpenSwarmRuntime` hook compiles without errors
- [ ] Toolkit index imports all stubs without errors
- [ ] `AgentChat.tsx` has `AssistantRuntimeProvider` wrapper
+184
View File
@@ -0,0 +1,184 @@
# Migration Agent 2: Thread & Message Rendering
## Objective
Replace the custom message list, scroll viewport, message bubbles, action bar, branch picker, thinking indicator, and image rendering with assistant-ui's `Thread`, `Message`, `ActionBar`, `BranchPicker`, and `Reasoning` primitives.
## Prerequisites
- **Agent 1 must be complete.** The ExternalStoreRuntime adapter, toolkit skeleton, and `AssistantRuntimeProvider` wrapper must exist.
## Constraints
- **No custom styling**: Use default assistant-ui appearance. Do not add CSS to match MUI theme.
- **Only modify files in the `thread/` directory** and the files listed under "Files Deleted." Do NOT modify `AgentChat.tsx`, `toolkit/`, `composer/`, or any file owned by another agent.
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use `assistantUIDocs` and `assistantUIExamples` tools to look up current API docs.
## Key Files to Read First
Understand what you're replacing:
- `frontend/src/app/pages/AgentChat/useMessageRendering.ts` — branch resolution, render items
- `frontend/src/app/pages/AgentChat/MessageBubble.tsx` — routes to User/Assistant bubbles
- `frontend/src/app/pages/AgentChat/UserBubbleContent.tsx` — user message with context pills, images, editing
- `frontend/src/app/pages/AgentChat/AssistantBubbleContent.tsx` — markdown rendering
- `frontend/src/app/pages/AgentChat/MessageActionBar.tsx` — copy/edit/regenerate/branch actions
- `frontend/src/app/pages/AgentChat/BranchNavigator.tsx` — branch picker arrows
- `frontend/src/app/pages/AgentChat/ThinkingBubble.tsx` — thinking/reasoning display
- `frontend/src/app/pages/AgentChat/MessageImageThumbnails.tsx` — image display in messages
- `frontend/src/app/pages/AgentChat/AttachedContextSection.tsx` — context pills in user messages
## Step-by-Step
### 1. Look up assistant-ui docs
Use the `assistantUIDocs` MCP tool to read these pages:
- `primitives/thread` — Thread primitives (viewport, messages, scroll)
- `primitives/message` — Message primitives (root, content, parts)
- `primitives/action-bar` — ActionBar primitives (copy, edit, reload, speak)
- `primitives/branch-picker` — BranchPicker primitives (prev/next/count)
- `ui/thread` — Pre-built Thread component
- `ui/markdown` — Markdown component
- `ui/reasoning` — Reasoning/thinking UI
- `guides/branching` — How branching works
### 2. Build OpenSwarmThread
Create `frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx`.
This is the main thread component that replaces the scroll container + message render loop in `AgentChat.tsx`. It should:
1. Use `ThreadPrimitive.Root` and `ThreadPrimitive.Viewport` for the scrollable container with auto-scroll
2. Use `ThreadPrimitive.Messages` to render the message list
3. Provide custom `UserMessage` and `AssistantMessage` components
```tsx
import { ThreadPrimitive } from '@assistant-ui/react';
import { UserMessage } from './UserMessage';
import { AssistantMessage } from './AssistantMessage';
export const OpenSwarmThread = () => {
return (
<ThreadPrimitive.Root>
<ThreadPrimitive.Viewport>
<ThreadPrimitive.Messages
components={{ UserMessage, AssistantMessage }}
/>
</ThreadPrimitive.Viewport>
<ThreadPrimitive.ScrollToBottom />
</ThreadPrimitive.Root>
);
};
```
### 3. Build UserMessage component
Create `thread/UserMessage.tsx`.
This replaces `UserBubbleContent.tsx`. It should:
- Use `MessagePrimitive.Root` and `MessagePrimitive.Content`
- Render user text content
- Render attached context paths (files, directories) as chips/badges
- Render attached images using `MessagePrimitive.Attachments` or custom rendering
- Support inline editing via `MessagePrimitive.EditComposer` (or however assistant-ui handles edit)
- Show the `ActionBar` (copy, edit) on hover
For context pills and attached skills, read the existing `AttachedContextSection.tsx` and `UserBubbleContent.tsx` to understand the data shape. These are in `message.context_paths` and `message.attached_skills`.
Note: The ExternalStoreRuntime adapter (from Agent 1) should include these in the converted message format. If they're not accessible via assistant-ui's message API, render them by reading from Redux directly using the message ID.
### 4. Build AssistantMessage component
Create `thread/AssistantMessage.tsx`.
This replaces `AssistantBubbleContent.tsx`. It should:
- Use `MessagePrimitive.Root` and `MessagePrimitive.Content`
- Render markdown content using assistant-ui's `MarkdownText` or `@assistant-ui/react-markdown`
- Render tool calls inline (assistant-ui handles this automatically when tools are registered in the toolkit)
- Show the `ActionBar` (copy, regenerate, branch) on hover
- Show the `BranchPicker` when the message has sibling branches
Use assistant-ui's `makeMarkdownText` or the `Markdown` component from `@assistant-ui/react-markdown`. Look up the current API in the docs.
### 5. Build ActionBar
Create `thread/MessageActions.tsx` (or inline in UserMessage/AssistantMessage).
This replaces `MessageActionBar.tsx`. Use assistant-ui's `ActionBarPrimitive`:
- `ActionBarPrimitive.Copy` — copy message text
- `ActionBarPrimitive.Edit` — edit user message
- `ActionBarPrimitive.Reload` — regenerate assistant message (maps to your "Regenerate" button)
For the "Branch chat" action, this is custom. Use a custom button inside the ActionBar that calls the existing `duplicateSession` thunk. You can use `useMessage` hook to get the current message context.
### 6. Build BranchPicker
Create `thread/BranchPicker.tsx` (or inline in AssistantMessage).
This replaces `BranchNavigator.tsx`. Use assistant-ui's `BranchPickerPrimitive`:
- `BranchPickerPrimitive.Previous`
- `BranchPickerPrimitive.Number` / `BranchPickerPrimitive.Count`
- `BranchPickerPrimitive.Next`
The ExternalStoreRuntime adapter (Agent 1) should expose branch data. If not, this may need coordination with Agent 1's runtime adapter to ensure branches are surfaced correctly.
### 7. Handle thinking/reasoning display
Create `thread/ThinkingIndicator.tsx` (or use assistant-ui's built-in).
This replaces `ThinkingBubble.tsx`. Check if assistant-ui's `Thread` automatically shows a loading indicator when `isRunning` is true. If so, the default behavior may be sufficient. If a custom thinking bubble is needed, use assistant-ui's reasoning or chain-of-thought UI.
### 8. Handle streaming messages
The ExternalStoreRuntime adapter should handle streaming state. When `session.streamingMessage` exists in Redux, the runtime should surface it as a message with `status: 'in_progress'`. assistant-ui will automatically animate streaming text.
Verify that the streaming cursor / token animation works correctly with the runtime adapter from Agent 1.
## Files Created
| File | Description |
|------|-------------|
| `thread/OpenSwarmThread.tsx` | Main thread component (replaces scroll container + message loop) |
| `thread/UserMessage.tsx` | User message rendering |
| `thread/AssistantMessage.tsx` | Assistant message with markdown, tool calls |
| `thread/MessageActions.tsx` | Action bar (copy, edit, regenerate, branch) |
| `thread/BranchPicker.tsx` | Branch navigation |
| `thread/ThinkingIndicator.tsx` | Thinking/loading state (if needed beyond defaults) |
All files go in `frontend/src/app/pages/AgentChat/thread/`.
## Files Deleted (by this agent)
These files are replaced by the new thread components. Delete them:
| File | Lines | Replaced By |
|------|-------|------------|
| `MessageBubble.tsx` | 99 | `UserMessage.tsx` + `AssistantMessage.tsx` |
| `UserBubbleContent.tsx` | 152 | `UserMessage.tsx` |
| `AssistantBubbleContent.tsx` | 128 | `AssistantMessage.tsx` |
| `MessageActionBar.tsx` | 153 | `MessageActions.tsx` |
| `BranchNavigator.tsx` | 60 | `BranchPicker.tsx` |
| `ThinkingBubble.tsx` | 55 | `ThinkingIndicator.tsx` or built-in |
| `MessageImageThumbnails.tsx` | 108 | Handled in `UserMessage.tsx` via Attachment primitives |
| `messageBubbleUtils.ts` | 54 | No longer needed |
| `useMessageRendering.ts` | 196 | Thread handles message list rendering |
**Total deleted: ~1,005 lines**
## Files NOT Modified
- `AgentChat.tsx` — Agent 7 wires in `OpenSwarmThread`
- Anything in `composer/`, `toolkit/`, or shared state
## Verification Checklist
- [ ] `OpenSwarmThread` renders a scrollable message list
- [ ] User messages display text content
- [ ] Assistant messages render markdown correctly
- [ ] Tool call messages are rendered (even if just as fallback text — toolkit fills in later)
- [ ] ActionBar shows copy/edit/regenerate on hover
- [ ] BranchPicker shows navigation when branches exist
- [ ] Streaming messages animate token-by-token
- [ ] Auto-scroll to bottom works on new messages
- [ ] Scroll-to-bottom button appears when scrolled up
- [ ] No TypeScript errors
- [ ] All deleted files are removed
+206
View File
@@ -0,0 +1,206 @@
# Migration Agent 3: Composer & Mention System
## Objective
Replace the custom `ChatInput` (contentEditable + CommandPicker) with assistant-ui's `Composer` primitives and `ComposerMentionPopover` for `@`/`/` trigger-based command picking.
## Prerequisites
- **Agent 1 must be complete.** The `AssistantRuntimeProvider`, runtime adapter, and assistant-ui packages must be installed.
## Constraints
- **No custom styling**: Use default assistant-ui Composer and Mention appearance.
- **Only modify files in the `composer/` directory** and the files listed under "Files Deleted." Do NOT modify `AgentChat.tsx`, `toolkit/`, `thread/`, or any file owned by another agent.
- **Keep `ModelModeSelector.tsx`** as-is — it's MUI and stays. The new Composer should render it in the footer area alongside the send button, similar to how it's currently positioned.
- **Keep `MessageQueue.tsx`** as-is — it wraps the composer.
- **Keep `TemplateInvokeModal.tsx`** as-is — it's triggered when a template is selected.
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use `assistantUIDocs` and `assistantUIExamples` tools to look up current API docs.
## Key Files to Read First
Understand what you're replacing:
- `frontend/src/app/pages/AgentChat/ChatInput.tsx` — main composer component
- `frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts` — send/paste/drop/pick logic
- `frontend/src/app/pages/AgentChat/AttachmentChips.tsx` — context path + tool chips
- `frontend/src/app/pages/AgentChat/ImageAttachments.tsx` — image thumbnails + lightbox
- `frontend/src/app/components/CommandPicker.tsx` — unified `@`/`/` command picker
- `frontend/src/app/components/commandPickerTypes.tsx` — picker types
- `frontend/src/app/components/useCommandPickerItems.tsx` — items for the picker (templates, skills, modes, tools, files)
- `frontend/src/app/components/CommandPickerIcons.tsx` — icons for picker categories
- `frontend/src/app/components/SlashCommandPicker.tsx` — legacy picker
- `frontend/src/app/components/richEditorUtils.ts` — skill pill serialize/deserialize
- `frontend/src/app/components/RichPromptEditor.tsx` — shared rich editor used in mode/template editors
Also read the assistant-ui docs:
- `ui/mention` — ComposerMentionPopover, MentionAdapter, DirectiveText
- `primitives/composer` — Composer primitives (Root, Input, Send, Attachments)
- `primitives/attachment` — Attachment primitives
- `guides/attachments` — Attachment handling
## Step-by-Step
### 1. Look up assistant-ui Mention docs
Use `assistantUIDocs` MCP tool to read:
- `ui/mention` — full Mention API
- `primitives/composer` — Composer primitives
The Mention system provides:
- `ComposerMentionPopover.Root` — wraps the composer, detects `@` trigger
- `ComposerMentionPopover` — the popover UI with categories and keyboard nav
- `LexicalComposerInput` — rich editor with inline mention chips
- Custom `MentionAdapter` — you supply the list of mentionable items
### 2. Create the MentionAdapter
Create `frontend/src/app/pages/AgentChat/composer/OpenSwarmMentionAdapter.ts`.
This replaces `useCommandPickerItems.tsx`. The adapter provides the list of mentionable items for both `@` and `/` triggers.
Read the existing `useCommandPickerItems.tsx` to understand what categories exist:
- **Templates** (triggered by `/`) — from `state.templates.items`
- **Skills** (triggered by `/`) — from `state.skills.items`
- **Modes** (triggered by `/`) — from `state.modes.items`
- **File attach** (triggered by `@`) — opens file browser
- **Web search** (triggered by `@`) — web search context
- **MCP tool groups** (triggered by `@`) — from `state.mcpRegistry`
- **View/app outputs** (triggered by `@`) — from dashboard outputs
The Mention adapter should return these as categories with items. The adapter's `search` function filters items based on the typed query.
Note: The default trigger for assistant-ui Mention is `@`. To support both `@` and `/`, you may need two `ComposerMentionPopover.Root` instances with different `trigger` props, or a single adapter that handles both (check docs for multi-trigger support).
### 3. Build OpenSwarmComposer
Create `frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx`.
This replaces `ChatInput.tsx`. Structure:
```tsx
import { ComposerPrimitive } from '@assistant-ui/react';
import { ComposerMentionPopover } from '@/components/assistant-ui/composer-mention';
import { LexicalComposerInput } from '@assistant-ui/react-lexical';
import ModelModeSelector from '../ModelModeSelector';
export const OpenSwarmComposer = ({ mode, onModeChange, model, onModelChange, ...props }) => {
return (
<ComposerMentionPopover.Root adapter={openSwarmMentionAdapter}>
<ComposerPrimitive.Root>
{/* Attachment display area (images, context paths) */}
<ComposerPrimitive.Attachments />
{/* Rich text input with inline mention chips */}
<LexicalComposerInput placeholder="Agent, @ for context, / for commands" />
{/* Mention popover (appears on @ or / trigger) */}
<ComposerMentionPopover />
{/* Footer: mode/model selector + send button */}
<ModelModeSelector ... />
<ComposerPrimitive.Send />
<ComposerPrimitive.Cancel /> {/* Stop button when running */}
</ComposerPrimitive.Root>
</ComposerMentionPopover.Root>
);
};
```
Key behaviors to implement:
- **Send**: `ComposerPrimitive.Send` calls the runtime's `onNew` which dispatches `sendMessage`
- **Stop**: `ComposerPrimitive.Cancel` calls the runtime's `onCancel` which dispatches `stopAgent`
- **Image paste/drop**: Use `ComposerPrimitive.Attachments` for drag-drop and paste handling. Check assistant-ui's attachment guide.
- **File upload**: When `@file` is selected from the mention popover, trigger the existing file upload flow (POST to `/api/settings/upload-files`)
- **Template selection**: When a template with variables is selected from `/`, open `TemplateInvokeModal`. This requires a callback on mention selection.
### 4. Handle the ChatInputHandle API
The existing `ChatInput` exposes a `ChatInputHandle` ref with `getConfig()` and `setContent()` methods. These are used by:
- `SkillBuilderChat.tsx` — to programmatically set content
- `useAgentChat.ts` — to get config before sending
With assistant-ui, programmatic control goes through the `ComposerRuntime`. Check docs at `api-reference/runtimes/composer-runtime` for `setText()`, `send()`, etc.
Create a wrapper or hook that provides equivalent functionality:
- `getConfig()` → read from composer runtime state
- `setContent()` → use `composerRuntime.setText()`
### 5. Handle attachment rendering in the composer
The current `ChatInput` renders:
- **Image thumbnails** with lightbox and remove buttons (`ImageAttachments.tsx`)
- **Context path chips** with copy-to-clipboard and remove (`AttachmentChips.tsx`)
- **Forced tool chips** (`AttachmentChips.tsx`)
- **UI element selection chips** (`AttachmentChips.tsx`)
With assistant-ui, use `ComposerPrimitive.Attachments` to render attachments. For custom chip types (context paths, forced tools, UI elements), you may need custom rendering inside the Composer area.
### 6. Handle the RichPromptEditor
`RichPromptEditor.tsx` is a shared component used outside the chat (in mode editors, template editors). It has similar `@`/`/` trigger detection and skill pill insertion.
Two options:
- **Option A**: Keep `RichPromptEditor.tsx` as-is for now (it's not in the critical chat path)
- **Option B**: Refactor it to also use `ComposerMentionPopover`
Go with **Option A** — keep it as-is. It's used in Settings/Modes pages, which are out of scope. Just make sure deleting `richEditorUtils.ts` doesn't break it. If it imports from there, extract the needed utilities.
**Important**: Before deleting `richEditorUtils.ts`, check if `RichPromptEditor.tsx` imports from it. If so, either:
- Move the needed functions into `RichPromptEditor.tsx` itself
- Keep `richEditorUtils.ts` but only with the functions `RichPromptEditor` needs
## Files Created
| File | Description |
|------|-------------|
| `composer/OpenSwarmComposer.tsx` | Main composer component |
| `composer/OpenSwarmMentionAdapter.ts` | Mention adapter for templates/skills/modes/tools/files |
| `composer/useComposerHandle.ts` | Hook providing `getConfig`/`setContent` via ComposerRuntime |
All files go in `frontend/src/app/pages/AgentChat/composer/`.
Also install the composer-mention shadcn component if not already present:
```bash
npx shadcn@latest add composer-mention
```
## Files Deleted (by this agent)
| File | Lines | Replaced By |
|------|-------|------------|
| `ChatInput.tsx` | 209 | `OpenSwarmComposer.tsx` |
| `hooks/useChatSubmit.ts` | 248 | Runtime `onNew`/`onEdit`/`onCancel` |
| `AttachmentChips.tsx` | 118 | Mention chips + Composer Attachments |
| `ImageAttachments.tsx` | 85 | Composer Attachment primitives |
| `CommandPicker.tsx` | 230 | `ComposerMentionPopover` |
| `commandPickerTypes.tsx` | 47 | `OpenSwarmMentionAdapter.ts` types |
| `useCommandPickerItems.tsx` | 225 | `OpenSwarmMentionAdapter.ts` |
| `CommandPickerIcons.tsx` | 40 | Category icons in adapter |
| `SlashCommandPicker.tsx` | 165 | `ComposerMentionPopover` |
**About `richEditorUtils.ts` (196 lines)**: Check if `RichPromptEditor.tsx` imports from it. If yes, extract only the functions `RichPromptEditor` needs into that file, then delete `richEditorUtils.ts`. If no imports, delete it entirely.
**Total deleted: ~1,367+ lines**
## Files NOT Modified
- `AgentChat.tsx` — Agent 7 wires in `OpenSwarmComposer`
- `ModelModeSelector.tsx` — kept as-is, rendered inside the new Composer
- `MessageQueue.tsx` — kept as-is, wraps the new Composer
- `TemplateInvokeModal.tsx` — kept as-is, triggered by mention selection
- Anything in `thread/`, `toolkit/`, or shared state
## Verification Checklist
- [ ] `OpenSwarmComposer` renders a text input area
- [ ] Typing `@` opens the mention popover with context categories (files, tools, etc.)
- [ ] Typing `/` opens the mention popover with command categories (templates, skills, modes)
- [ ] Keyboard navigation works (arrows, Enter, Escape)
- [ ] Selected mentions appear as inline chips in the editor
- [ ] Images can be pasted or dragged into the composer
- [ ] Send button calls the runtime's `onNew`
- [ ] Stop button appears when the agent is running and calls `onCancel`
- [ ] `ModelModeSelector` renders in the composer footer
- [ ] No TypeScript errors
- [ ] All deleted files are removed
- [ ] `RichPromptEditor` still works in Modes/Templates pages (if it existed before)
+262
View File
@@ -0,0 +1,262 @@
# Migration Agent 4: Tool Toolkit — Terminal, Code, Diffs
## Objective
Register Tool UI components (`Terminal`, `CodeBlock`, `CodeDiff`) in the toolkit for rendering native tool calls (bash, file read/write, edit, grep, glob, search). Replace `ToolCallBubble`, `toolCallColors`, `toolCallUtils`, `ElapsedTimer`, and `ToolGroupBubble`.
## Prerequisites
- **Agent 1 must be complete.** Tool UI components must be installed in `src/components/tool-ui/`, and the toolkit skeleton must exist at `toolkit/native-tools.tsx`.
## Constraints
- **No custom styling**: Use default Tool UI Terminal/CodeBlock/CodeDiff appearance.
- **Only modify `toolkit/native-tools.tsx`**. Do NOT modify `AgentChat.tsx`, `thread/`, `composer/`, or other toolkit files.
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use it for assistant-ui API lookups. For Tool UI docs, refer to the website or read the installed component schemas.
## Key Files to Read First
Understand what you're replacing:
- `frontend/src/app/pages/AgentChat/ToolCallBubble.tsx` — main tool call rendering (173 lines)
- `frontend/src/app/pages/AgentChat/toolCallUtils.ts``parseMcpToolName`, `getToolData`, `parseToolResult`, `formatInputDisplay`, etc.
- `frontend/src/app/pages/AgentChat/toolCallColors.tsx``colorizeInput`, `colorizeOutput`, terminal color constants
- `frontend/src/app/pages/AgentChat/ElapsedTimer.tsx` — duration timer component
- `frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx` — grouped tool calls accordion
Also read the installed Tool UI component schemas:
- `src/components/tool-ui/terminal/schema.ts` — Terminal props schema
- `src/components/tool-ui/code-block/schema.ts` — CodeBlock props schema
- `src/components/tool-ui/code-diff/schema.ts` — CodeDiff props schema
## Background: How Tool Calls Work in This App
Messages with `role: 'tool_call'` have this content shape:
```typescript
{
tool: string; // tool name (e.g. "Bash", "Read", "Edit", "Grep", "mcp__google-gmail__search")
input: any; // tool input args (e.g. { command: "ls -la" })
}
```
Messages with `role: 'tool_result'` have:
```typescript
{
text: string; // raw result text
elapsed_ms?: number; // optional duration
}
// OR just a string
```
The `toolCallUtils.ts` file has `parseToolResult()` which parses the result into structured types:
- `{ type: 'bash', stdout, stderr, exitCode }` for Bash tool
- `{ type: 'text', content, isError }` for Read/Grep/etc.
- `{ type: 'mcp', service, action, data }` for MCP tools (handled by Agent 6)
## Step-by-Step
### 1. Read Tool UI component APIs
Read the schema files for each installed component:
- `src/components/tool-ui/terminal/schema.ts`
- `src/components/tool-ui/code-block/schema.ts`
- `src/components/tool-ui/code-diff/schema.ts`
Understand the props each component accepts.
### 2. Understand the toolkit registration pattern
In assistant-ui, tool calls are rendered by registering renderers in a `Toolkit` object. Each key is a tool name, and the value describes how to render it.
Use `assistantUIDocs` to look up:
- `guides/tool-ui` — Generative UI / tool rendering
- `copilots/make-assistant-tool-ui``makeAssistantToolUI` API
The toolkit pattern from Tool UI's quick-start:
```tsx
const toolkit: Toolkit = {
toolName: {
type: "backend",
render: ({ result, args }) => {
// Parse result, return Tool UI component
return <Terminal {...parsedProps} />;
},
},
};
```
### 3. Implement native-tools.tsx
Fill in `frontend/src/app/pages/AgentChat/toolkit/native-tools.tsx`.
Register renderers for each native tool type:
#### Bash → Terminal
```tsx
import { Terminal } from '@/components/tool-ui/terminal';
Bash: {
type: "backend",
render: ({ result, args }) => {
const command = args?.command || '';
// Parse result: { stdout, stderr, exitCode, elapsed_ms }
return (
<Terminal
id={`bash-${toolCallId}`}
command={command}
stdout={parsed.stdout}
stderr={parsed.stderr}
exitCode={parsed.exitCode}
durationMs={parsed.elapsed_ms}
cwd={args?.working_directory}
/>
);
},
}
```
#### Read → CodeBlock
```tsx
import { CodeBlock } from '@/components/tool-ui/code-block';
Read: {
type: "backend",
render: ({ result, args }) => {
const filePath = args?.file_path || args?.path || '';
const language = guessLanguage(filePath); // from extension
return (
<CodeBlock
id={`read-${toolCallId}`}
code={resultText}
language={language}
filename={filePath}
lineNumbers="visible"
/>
);
},
}
```
#### Edit / Write → CodeDiff
```tsx
import { CodeDiff } from '@/components/tool-ui/code-diff';
Edit: {
type: "backend",
render: ({ result, args }) => {
const filePath = args?.file_path || args?.path || '';
return (
<CodeDiff
id={`edit-${toolCallId}`}
oldCode={args?.old_string || ''}
newCode={args?.new_string || ''}
language={guessLanguage(filePath)}
filename={filePath}
/>
);
},
}
```
#### Grep / Glob / Search → Terminal
These are search commands; display results in Terminal:
```tsx
Grep: {
type: "backend",
render: ({ result, args }) => {
return (
<Terminal
id={`grep-${toolCallId}`}
command={`grep ${args?.pattern || ''}`}
stdout={resultText}
exitCode={0}
/>
);
},
}
```
### 4. Handle the "input" display
Currently `ToolCallBubble` shows the tool input (command, file path, etc.) in a terminal-style header. With Tool UI's `Terminal`, the `command` prop handles this. For `CodeBlock` and `CodeDiff`, the `filename` prop shows the context.
For tools that don't fit Terminal/CodeBlock/CodeDiff, create a simple fallback renderer that shows the tool name and JSON-formatted args.
### 5. Handle tool groups (ToolGroupBubble replacement)
`ToolGroupBubble` wraps multiple tool calls in a collapsible accordion with a generated SVG icon and completed/pending counters.
Check if assistant-ui has a `ChainOfThought` or `ToolGroup` primitive:
- Look up `primitives/chain-of-thought` in assistant-ui docs
- Look up `ui/reasoning` for grouping
If assistant-ui provides grouping, use it. If not, create a simple wrapper component that groups consecutive tool calls into a collapsible section. Put this in `toolkit/native-tools.tsx` as a helper.
### 6. Write a `guessLanguage` utility
Create a small utility function that maps file extensions to language names for CodeBlock:
```typescript
function guessLanguage(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase();
const map: Record<string, string> = {
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
py: 'python', rs: 'rust', go: 'go', rb: 'ruby', java: 'java',
json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
html: 'html', css: 'css', scss: 'scss', sh: 'bash', bash: 'bash',
sql: 'sql', xml: 'xml', toml: 'toml', // ... etc
};
return map[ext || ''] || 'text';
}
```
### 7. Handle pending/streaming states
Currently `ToolCallBubble` shows:
- A pulsing cursor when pending
- A blinking cursor when streaming
- An elapsed timer when pending
Tool UI's `Terminal` has built-in support for these states through its props. For pending state without result, you can render `Terminal` without stdout/stderr and it shows as in-progress. Check the exact behavior.
## Files Created / Modified
| File | Action | Description |
|------|--------|-------------|
| `toolkit/native-tools.tsx` | **Fill in** (was stub from Agent 1) | All native tool registrations |
## Files Deleted (by this agent)
| File | Lines | Replaced By |
|------|-------|------------|
| `ToolCallBubble.tsx` | 172 | Toolkit registrations in `native-tools.tsx` |
| `toolCallColors.tsx` | 211 | Tool UI Terminal handles ANSI colors natively |
| `toolCallUtils.ts` | 249 | Simplified parsing in toolkit renderers |
| `ElapsedTimer.tsx` | 41 | Terminal's `durationMs` prop |
| `ToolGroupBubble.tsx` | 200 | ChainOfThought or custom grouping |
**Important**: Before deleting `toolCallUtils.ts`, check if other files import from it:
- `McpServiceCards.tsx` imports `ParsedMcpResult`, `formatTimestamp` — these are owned by Agent 6
- `GmailCard.tsx` imports `getGmailHeader`, `formatTimestamp`, `stripHtml`
- `approvalUtils.tsx` imports `parseMcpToolName`
If other files import from `toolCallUtils.ts`, **do not delete it yet**. Instead, move the functions that only native-tools need into `native-tools.tsx`, and leave `toolCallUtils.ts` for other agents to consume. Agent 7 (Cleanup) will delete it once all consumers are gone.
Similarly for `ToolGroupBubble.tsx` — check if `AgentChat.tsx` imports `isToolGroup`/`isToolPair` from it. If so, coordinate with Agent 7.
**Total deleted: ~873 lines** (depending on shared imports)
## Files NOT Modified
- `AgentChat.tsx` — Agent 7 handles integration
- `thread/`, `composer/` — owned by Agents 2 and 3
- `toolkit/approval-tools.tsx`, `toolkit/mcp-tools.tsx` — owned by Agents 5 and 6
## Verification Checklist
- [ ] `toolkit/native-tools.tsx` exports a `nativeToolkit` object
- [ ] Bash tool calls render as `Terminal` with command, stdout, stderr, exitCode, duration
- [ ] Read tool calls render as `CodeBlock` with syntax highlighting and filename
- [ ] Edit tool calls render as `CodeDiff` with old/new code and filename
- [ ] Grep/Glob/Search tool calls render as `Terminal`
- [ ] Unknown native tools render a sensible fallback
- [ ] No TypeScript errors
- [ ] Deleted files don't break other imports (check before deleting)
+229
View File
@@ -0,0 +1,229 @@
# Migration Agent 5: Tool Toolkit — Approvals & Questions
## Objective
Register Tool UI components (`ApprovalCard`, `QuestionFlow`, `OptionList`) in the toolkit for rendering HITL approval requests and user questions. Replace `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`, and `approvalUtils`.
## Prerequisites
- **Agent 1 must be complete.** Tool UI components must be installed in `src/components/tool-ui/`, and the toolkit skeleton must exist at `toolkit/approval-tools.tsx`.
## Constraints
- **No custom styling**: Use default Tool UI ApprovalCard/QuestionFlow/OptionList appearance.
- **Only modify `toolkit/approval-tools.tsx`** and create any helpers needed within it. Do NOT modify `AgentChat.tsx`, `thread/`, `composer/`, or other toolkit files.
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use it for assistant-ui API lookups.
## Key Files to Read First
Understand what you're replacing:
- `frontend/src/app/pages/AgentChat/ApprovalBar.tsx` — single tool approval (195 lines)
- `frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx` — mass approve/deny (179 lines)
- `frontend/src/app/pages/AgentChat/QuestionForm.tsx` — AskUserQuestion with options/multi-select/free-text (238 lines)
- `frontend/src/app/pages/AgentChat/ToolPreview.tsx` — code preview of bash/read/write/edit/grep args (140 lines)
- `frontend/src/app/pages/AgentChat/approvalUtils.tsx` — MCP tool metadata, integration icons (139 lines)
Also read the installed Tool UI schemas:
- `src/components/tool-ui/approval-card/schema.ts`
- `src/components/tool-ui/question-flow/schema.ts`
- `src/components/tool-ui/option-list/schema.ts`
## Background: How Approvals Work in This App
The backend sends `approval_request` events over WebSocket. These are stored in `session.pending_approvals` in Redux:
```typescript
interface ApprovalRequest {
id: string;
session_id: string;
tool_name: string; // e.g. "Bash", "mcp__google-gmail__sendEmail"
tool_input: Record<string, any>; // the tool args needing approval
created_at: string;
}
```
The user approves or denies via `handleApprove(requestId, updatedInput?)` or `handleDeny(requestId, message?)` which dispatches to the backend.
Special case: `tool_name === 'AskUserQuestion'` renders a `QuestionForm` instead of an approval bar. The `tool_input` contains:
```typescript
{
question: string;
options?: Array<{ id, label, description? }>;
allow_multiple?: boolean;
allow_free_text?: boolean;
}
```
The user's response is sent back via `onApprove(requestId, { answer: selectedOptions })`.
## Step-by-Step
### 1. Read Tool UI component schemas
Read the installed schema files to understand each component's props:
- `src/components/tool-ui/approval-card/schema.ts``ApprovalCard` props
- `src/components/tool-ui/question-flow/schema.ts``QuestionFlow` props
- `src/components/tool-ui/option-list/schema.ts``OptionList` props
### 2. Understand the rendering context
Approvals are NOT standard tool call results — they're rendered in a separate area below the message list in `AgentChat.tsx` (lines 199-205). They come from `session.pending_approvals`, not from the message stream.
This means approvals might not fit neatly into the toolkit registration pattern (which is for rendering tool call results inside the message thread). Instead, they may need to be rendered as standalone components in the approval area.
**Two approaches**:
**Approach A — Keep approvals as standalone components**: Create wrapper components in `toolkit/approval-tools.tsx` that use Tool UI's `ApprovalCard`, `QuestionFlow`, and `OptionList` internally. These are rendered in the approval area of `AgentChat.tsx` (Agent 7 wires this in).
**Approach B — Render approvals as tool call parts in the thread**: Map `pending_approvals` to tool messages in the runtime adapter, so they appear inline in the message thread. The toolkit renderers handle them.
Go with **Approach A** — it's simpler and matches the existing UX where approvals appear as a bar between the messages and the composer. The components just change from custom MUI to Tool UI.
### 3. Create approval wrapper components
#### SingleApproval → ApprovalCard
Map from `ApprovalRequest` to `ApprovalCard` props:
```tsx
import { ApprovalCard } from '@/components/tool-ui/approval-card';
export const ToolApproval: React.FC<{
request: ApprovalRequest;
onApprove: (id: string) => void;
onDeny: (id: string, message?: string) => void;
}> = ({ request, onApprove, onDeny }) => {
const parsedTool = parseMcpToolName(request.tool_name);
return (
<ApprovalCard
id={request.id}
title={parsedTool.isMcp ? parsedTool.displayName : `Run ${request.tool_name}`}
description={getApprovalDescription(request)}
metadata={buildMetadata(request.tool_input)}
variant={isDangerous(request.tool_name) ? 'destructive' : 'default'}
confirmLabel="Approve"
cancelLabel="Deny"
onConfirm={() => onApprove(request.id)}
onCancel={() => onDeny(request.id)}
/>
);
};
```
The `metadata` prop accepts key-value pairs, which maps well to showing the tool input args:
```typescript
function buildMetadata(toolInput: Record<string, any>): Array<{ key: string; value: string }> {
return Object.entries(toolInput)
.filter(([, v]) => v != null)
.slice(0, 5)
.map(([key, value]) => ({
key,
value: typeof value === 'string' ? value.slice(0, 100) : JSON.stringify(value).slice(0, 100),
}));
}
```
#### QuestionForm → QuestionFlow + OptionList
When `tool_name === 'AskUserQuestion'`, render a `QuestionFlow` (if multi-step) or `OptionList` (if single question with options):
```tsx
import { OptionList } from '@/components/tool-ui/option-list';
import { QuestionFlow } from '@/components/tool-ui/question-flow';
export const ToolQuestion: React.FC<{
request: ApprovalRequest;
onApprove: (id: string, updatedInput?: Record<string, any>) => void;
onDeny: (id: string) => void;
}> = ({ request, onApprove, onDeny }) => {
const { question, options, allow_multiple, allow_free_text } = request.tool_input;
if (options?.length > 0) {
return (
<OptionList
id={request.id}
options={options.map(opt => ({
id: opt.id || opt.value || opt.label,
label: opt.label || opt.text,
description: opt.description,
}))}
selectionMode={allow_multiple ? 'multi' : 'single'}
actions={[
{ id: 'confirm', label: 'Submit' },
{ id: 'cancel', label: 'Skip', variant: 'secondary' },
]}
onAction={(actionId, selection) => {
if (actionId === 'confirm') {
onApprove(request.id, { answer: selection });
} else {
onDeny(request.id);
}
}}
/>
);
}
// Free text question without options — render a simple card
// (or use a text input approach)
};
```
#### BatchApprovalBar → composed ApprovalCards
When `session.pending_approvals.length > 1`, render a batch approval UI. Options:
- Render multiple `ApprovalCard` instances stacked
- Add a "Approve All" / "Deny All" header above them
### 4. Port MCP tool metadata
The existing `approvalUtils.tsx` has `parseMcpToolName()` and `useMcpToolMeta()` that look up MCP tool metadata (integration icons, descriptions) from the Redux store. Port these utility functions into the approval-tools file or a local helper.
**Important**: `parseMcpToolName` is also used by Agent 6 (for MCP service cards) and Agent 4 (in toolCallUtils). If you extract it, put it in a shared location that other agents can access, or duplicate the logic since it's small.
### 5. Handle ToolPreview replacement
`ToolPreview.tsx` renders a preview of tool arguments for native tools (showing the bash command, file path, edit diff, etc.). With Tool UI, this preview is naturally handled by the metadata display in `ApprovalCard`.
For more detailed previews (like showing the full bash command or code diff), you can nest a Tool UI `Terminal` or `CodeBlock` inside the approval area. But for now, the metadata key-value pairs should be sufficient.
## Files Created / Modified
| File | Action | Description |
|------|--------|-------------|
| `toolkit/approval-tools.tsx` | **Fill in** (was stub) | Approval renderers: `ToolApproval`, `ToolQuestion`, batch wrapper |
## Files Deleted (by this agent)
| File | Lines | Replaced By |
|------|-------|------------|
| `ApprovalBar.tsx` | 195 | `ToolApproval` using `ApprovalCard` |
| `BatchApprovalBar.tsx` | 179 | Batch wrapper using composed `ApprovalCard` |
| `QuestionForm.tsx` | 238 | `ToolQuestion` using `OptionList` / `QuestionFlow` |
| `ToolPreview.tsx` | 140 | `ApprovalCard` metadata + optional nested Terminal/CodeBlock |
| `approvalUtils.tsx` | 139 | Utility functions ported to `approval-tools.tsx` |
**Important**: Before deleting `approvalUtils.tsx`, check if other files import `parseMcpToolName` from it. If `ToolCallBubble.tsx` or `McpServiceCards.tsx` imports it, leave the file until Agent 7 cleanup, or extract the shared function.
**Total deleted: ~891 lines**
## Files NOT Modified
- `AgentChat.tsx` — Agent 7 wires the new approval components into the approval area
- `thread/`, `composer/` — owned by Agents 2 and 3
- Other toolkit files — owned by Agents 4 and 6
## Verification Checklist
- [ ] `toolkit/approval-tools.tsx` exports `approvalToolkit` and approval wrapper components
- [ ] `ToolApproval` renders an `ApprovalCard` with title, description, metadata
- [ ] Native tool approvals show tool name and key input args
- [ ] MCP tool approvals show the integration name and action
- [ ] Destructive variant is used for dangerous tools (e.g. Bash with `rm`, `delete`)
- [ ] `ToolQuestion` renders `OptionList` for questions with options
- [ ] Multi-select works for `allow_multiple: true` questions
- [ ] Free-text questions render an input field
- [ ] Approve/Deny callbacks work correctly
- [ ] Batch approval renders multiple cards or a batch wrapper
- [ ] No TypeScript errors
- [ ] Deleted files don't break other imports
+347
View File
@@ -0,0 +1,347 @@
# Migration Agent 6: Tool Toolkit — MCP Services, Browser Feed & Custom Tools
## Objective
Register Tool UI components for MCP service results (`MessageDraft` for Gmail, `DataTable`/`ItemCarousel` for Calendar/Drive, `ProgressTracker` for browser feed). Wire existing `AgentToolBubble` (sub-agent rendering) and `ViewBubble` (iframe previews) into the toolkit as custom entries. Replace `GmailCard`, `McpServiceCards`, `BrowserAgentInlineFeed`, `BrowserFeedEntryRow`, `browserFeedUtils`, and `DiffViewer`.
## Prerequisites
- **Agent 1 must be complete.** Tool UI components must be installed in `src/components/tool-ui/`, and the toolkit skeleton must exist at `toolkit/mcp-tools.tsx` and `toolkit/custom-tools.tsx`.
## Constraints
- **No custom styling**: Use default Tool UI appearance for MessageDraft, DataTable, ProgressTracker.
- **Only modify `toolkit/mcp-tools.tsx` and `toolkit/custom-tools.tsx`**. Do NOT modify `AgentChat.tsx`, `thread/`, `composer/`, or other toolkit files.
- **Keep `AgentToolBubble.tsx` and `ViewBubble.tsx` as-is** — just wire them into the custom toolkit as renderers. Do not rewrite them.
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`.
## 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)
- `frontend/src/app/pages/AgentChat/BrowserFeedEntryRow.tsx` — feed entry rendering (120 lines)
- `frontend/src/app/pages/AgentChat/browserFeedUtils.ts` — browser feed utilities (129 lines)
- `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/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,
serverSlug: 'google-gmail',
service: 'gmail',
actionName: 'search',
displayName: 'Search',
}
```
The `McpResultCard` component dispatches based on `service`:
- `gmail``GmailCard`
- `calendar``CalendarCard`
- `drive` / `sheets``DriveCard`
- anything else → `GenericMcpCard`
## Step-by-Step
### 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
### 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';
// For single email results
function renderGmailResult(data: any, action: string) {
const email = extractEmailFields(data);
return (
<MessageDraft
id={`gmail-${data.id || 'result'}`}
from={email.from}
to={email.to}
subject={email.subject}
body={email.bodyPreview || email.snippet}
// MessageDraft may have additional props for sent state, etc.
/>
);
}
```
For **email list results** (search/list), use `DataTable`:
```tsx
import { DataTable } from '@/components/tool-ui/data-table';
function renderGmailList(messages: any[]) {
return (
<DataTable
id="gmail-list"
columns={[
{ key: 'from', label: 'From', priority: 'primary' },
{ key: 'subject', label: 'Subject' },
{ key: 'date', label: 'Date', format: { kind: 'date', dateFormat: 'relative' } },
{ key: 'snippet', label: 'Preview', truncate: true },
]}
data={messages.map((msg, i) => ({
id: String(i),
...extractEmailFields(msg),
}))}
/>
);
}
```
### 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 (
<DataTable
id="calendar-list"
columns={[
{ key: 'summary', label: 'Event', priority: 'primary' },
{ key: 'start', label: 'Start', format: { kind: 'date', dateFormat: 'short' } },
{ key: 'end', label: 'End', format: { kind: 'date', dateFormat: 'short' } },
{ key: 'location', label: 'Location' },
]}
data={items.map((item, i) => ({
id: String(i),
summary: item.summary || '(no title)',
start: item.start?.dateTime || item.start?.date || '',
end: item.end?.dateTime || item.end?.date || '',
location: item.location || '',
}))}
/>
);
}
```
### 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 (
<DataTable
id="drive-files"
columns={[
{ key: 'name', label: 'File', priority: 'primary' },
{ key: 'mimeType', label: 'Type' },
]}
data={files.map((f, i) => ({
id: String(i),
name: f.name || f.id,
mimeType: f.mimeType?.split('/').pop() || '',
}))}
/>
);
}
```
### 5. Implement mcp-tools.tsx — Generic MCP fallback
For MCP tools without a specific handler, render a simple key-value display. Use `DataTable` with two columns (key, value) or create a minimal fallback component.
### 6. Register MCP tool renderers in the toolkit
The challenge: MCP tool names are dynamic (`mcp__<server>__<action>`). You can't register every possible tool name in the toolkit.
**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
// Generic fallback handles all MCP tools
__fallback__: {
type: "backend",
render: ({ result, args, toolName }) => {
const mcpInfo = parseMcpToolName(toolName);
if (!mcpInfo.isMcp) return null; // Let other handlers deal with it
switch (mcpInfo.service) {
case 'gmail': return renderGmailResult(result, mcpInfo.actionName);
case 'calendar': return renderCalendarResult(result, mcpInfo.actionName);
case 'drive': return renderDriveResult(result);
default: return renderGenericMcp(result);
}
},
},
};
```
If assistant-ui doesn't support a fallback, register a `ToolFallback` component that checks if the tool name starts with `mcp__` and routes accordingly.
### 7. Implement mcp-tools.tsx — Browser Feed → ProgressTracker
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';
function renderBrowserFeed(entries: BrowserFeedEntry[]) {
return (
<ProgressTracker
id="browser-feed"
steps={entries.map(entry => ({
id: entry.id,
label: entry.action, // "navigate", "click", "type"
description: entry.detail, // URL, selector, text
status: entry.status === 'done' ? 'completed'
: entry.status === 'error' ? 'failed'
: entry.status === 'running' ? 'in-progress'
: 'pending',
}))}
/>
);
}
```
The browser feed data comes from Redux (via `useBrowserActivity` or similar). The renderer needs to read this data. Since it's rendered inside a tool call bubble (for `BrowserAgent` tool), the data should be accessible from the tool result or via Redux selector.
### 8. Implement mcp-tools.tsx — DiffViewer → CodeDiff
The `DiffViewer.tsx` fetches a git worktree diff from the API and renders colorized diff output. Replace with Tool UI's `CodeDiff`:
```tsx
import { CodeDiff } from '@/components/tool-ui/code-diff';
// DiffViewer fetches diff text from /api/agents/sessions/{id}/worktree-diff
// This is rendered as a side panel, not a tool call result.
// It may need to stay as a standalone component, just using CodeDiff internally.
```
**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
Go with replacing the internals: keep a thin wrapper that fetches the diff and passes it to `CodeDiff`.
### 9. Implement custom-tools.tsx — Wire AgentToolBubble and ViewBubble
These are kept as-is but registered in the toolkit so assistant-ui knows how to render them:
```tsx
import { InvokeAgentBubble, CreateAgentBubble } from '../AgentToolBubble';
import ViewBubble from '../ViewBubble';
export const customToolkit = {
InvokeAgent: {
type: "backend",
render: ({ result, args, toolCallId }) => (
<InvokeAgentBubble
call={{ id: toolCallId, content: { tool: 'InvokeAgent', input: args }, ... }}
result={result ? { content: result } : null}
isPending={!result}
isStreaming={false}
/>
),
},
CreateAgent: {
type: "backend",
render: ({ result, args, toolCallId }) => (
<CreateAgentBubble
call={{ id: toolCallId, content: { tool: 'CreateAgent', input: args }, ... }}
result={result ? { content: result } : null}
isPending={!result}
isStreaming={false}
/>
),
},
RenderOutput: {
type: "backend",
render: ({ result, args, toolCallId }) => (
<ViewBubble
call={{ id: toolCallId, content: { tool: 'RenderOutput', input: args }, ... }}
result={result ? { content: result } : null}
isPending={!result}
isStreaming={false}
/>
),
},
};
```
Adapt the props to match what `AgentToolBubble` and `ViewBubble` expect. Read those files to understand their prop interfaces.
### 10. Port `parseMcpToolName` and `extractEmailFields`
These utility functions are needed by the MCP toolkit. Port them into `mcp-tools.tsx` or a local helper file. Keep them minimal — only port what you need.
## 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 |
## 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) |
**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.
**Total deleted: ~847 lines**
## 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) |
## Files NOT Modified
- `AgentChat.tsx` — Agent 7 handles integration
- `thread/`, `composer/` — owned by Agents 2 and 3
- Other toolkit files — owned by Agents 4 and 5
## 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
+257
View File
@@ -0,0 +1,257 @@
# Migration Agent 7: Integration, SkillBuilderChat & Cleanup
## Objective
Wire all Phase 2 outputs together in `AgentChat.tsx`. Migrate `SkillBuilderChat.tsx`. Delete all remaining dead code. Verify the build passes and the chat is functional end-to-end.
**This agent runs last, after ALL Phase 2 agents (26) are complete.**
## Prerequisites
- **Agents 16 must ALL be complete.**
- Agent 1: Runtime adapter, toolkit skeleton, providers exist
- Agent 2: `thread/OpenSwarmThread.tsx` exists and works
- Agent 3: `composer/OpenSwarmComposer.tsx` exists and works
- Agent 4: `toolkit/native-tools.tsx` is filled in
- Agent 5: `toolkit/approval-tools.tsx` is filled in
- Agent 6: `toolkit/mcp-tools.tsx` and `toolkit/custom-tools.tsx` are filled in
## Constraints
- **No custom styling**: Do not add CSS to make assistant-ui/Tool UI match MUI.
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`.
## Step-by-Step
### 1. Rewrite AgentChat.tsx
This is the main integration task. Replace the current `AgentChat.tsx` (250 lines) with the new structure.
The current structure:
```
AgentChat
├── ChatHeader (keep)
├── Scroll container with message render loop (REPLACE → OpenSwarmThread)
│ ├── renderItems.map (messages, tool groups, tool pairs) (REPLACE)
│ ├── Streaming message (REPLACE — handled by runtime)
│ ├── ThinkingBubble (REPLACE — handled by Thread)
│ ├── Resume bubble (KEEP)
│ └── Scroll-to-bottom button (REPLACE — Thread handles this)
├── ApprovalBar / BatchApprovalBar (REPLACE → approval-tools components)
├── Glow CTA (keep)
└── MessageQueue > ChatInput (REPLACE ChatInput → OpenSwarmComposer)
```
The new structure:
```tsx
import { AssistantRuntimeProvider } from '@assistant-ui/react';
import { useOpenSwarmRuntime } from './runtime/useOpenSwarmRuntime';
import { toolkit } from './toolkit';
import { OpenSwarmThread } from './thread/OpenSwarmThread';
import { OpenSwarmComposer } from './composer/OpenSwarmComposer';
import { ToolApproval, ToolQuestion, BatchApproval } from './toolkit/approval-tools';
import ChatHeader from './ChatHeader';
import MessageQueue from './MessageQueue';
const AgentChat = ({ sessionId, ... }) => {
const runtime = useOpenSwarmRuntime(sessionId);
const { session, handleApprove, handleDeny, handleStop, ... } = useAgentChat({ sessionId });
return (
<AssistantRuntimeProvider runtime={runtime}>
<Box sx={{ display: 'flex', height: '100%' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
{!embedded && <ChatHeader session={session} ... />}
{/* Thread replaces the scroll container + message loop */}
<OpenSwarmThread />
{/* Resume bubble (keep existing) */}
{showResumeBubble && session.status === 'stopped' && (
<ResumeButton onClick={handleResume} />
)}
{/* Approvals area — now using Tool UI components */}
{session.pending_approvals.length > 1 ? (
<BatchApproval requests={session.pending_approvals} onApprove={handleApprove} onDeny={handleDeny} />
) : (
session.pending_approvals.map((req) => (
req.tool_name === 'AskUserQuestion'
? <ToolQuestion key={req.id} request={req} onApprove={handleApprove} onDeny={handleDeny} />
: <ToolApproval key={req.id} request={req} onApprove={handleApprove} onDeny={handleDeny} />
))
)}
{/* Composer replaces ChatInput */}
{isGlowing ? (
<GlowCTA onClick={onDismissGlow} />
) : (
<MessageQueue ...>
<OpenSwarmComposer
mode={mode}
onModeChange={handleModeChange}
model={model}
onModelChange={handleModelChange}
isRunning={agentBusy}
onStop={handleStop}
contextEstimate={contextEstimate}
sessionId={id}
autoFocus={autoFocus}
/>
</MessageQueue>
)}
</Box>
</Box>
</AssistantRuntimeProvider>
);
};
```
### 2. Register the toolkit
Check how assistant-ui registers the toolkit. It may be via:
- `Tools` component or `useAui` hook (check current docs)
- Props on `AssistantRuntimeProvider`
- A separate context provider
Use `assistantUIDocs` to look up:
- `copilots/model-context` — how to provide tools/toolkit
- `guides/tool-ui` — how toolkit is registered
Make sure ALL toolkit entries (native + approvals + MCP + custom) are registered so tool calls render correctly.
### 3. Clean up useAgentChat hook
The `useAgentChat` hook (`hooks/useAgentChat.ts`) currently manages:
- Session state and WebSocket lifecycle (KEEP)
- Scroll container ref + scroll handlers (REMOVE — Thread handles scroll)
- `handleSend` (KEEP — still used by runtime adapter or Composer)
- Approval handlers (KEEP)
- Stop/resume handlers (KEEP)
Remove the scroll-related code:
- `scrollContainerRef` — Thread manages its own scroll
- `showScrollButton` — Thread has built-in scroll-to-bottom
- `handleScroll`, `scrollToBottom` — Thread handles these
Also remove `chatInputRef` if the Composer no longer uses it (replaced by ComposerRuntime API).
### 4. Migrate SkillBuilderChat
Read `frontend/src/app/pages/Skills/SkillBuilderChat.tsx` (403 lines).
It embeds `AgentChat` in `embedded` mode:
```tsx
<AgentChat
sessionId={draftSessionId}
embedded
autoFocus
initialContextPaths={...}
/>
```
Since `AgentChat` is being rewritten with assistant-ui, `SkillBuilderChat` should work as-is **if** the `embedded` prop behavior is preserved. Verify:
- `embedded` hides the `ChatHeader`
- The Composer still renders in embedded mode
- The Thread fills the available space
If `SkillBuilderChat` used `ChatInputHandle` ref to programmatically set content:
- Check if Agent 3 created a `useComposerHandle` hook
- Update `SkillBuilderChat` to use the new API (ComposerRuntime's `setText()`)
### 5. Delete all remaining dead files
After integration, these files should no longer be imported anywhere:
**Already deleted by Phase 2 agents** (verify they're gone):
- Agent 2: `MessageBubble`, `UserBubbleContent`, `AssistantBubbleContent`, `MessageActionBar`, `BranchNavigator`, `ThinkingBubble`, `MessageImageThumbnails`, `messageBubbleUtils`, `useMessageRendering`
- Agent 3: `ChatInput`, `useChatSubmit`, `CommandPicker`, `commandPickerTypes`, `useCommandPickerItems`, `CommandPickerIcons`, `SlashCommandPicker`, `AttachmentChips`, `ImageAttachments`
- Agent 4: `ToolCallBubble`, `toolCallColors`, `ElapsedTimer`, `ToolGroupBubble`
- Agent 5: `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`, `approvalUtils`
- Agent 6: `GmailCard`, `McpServiceCards`, `BrowserAgentInlineFeed`, `BrowserFeedEntryRow`, `browserFeedUtils`, `DiffViewer`
**Files that may have been left due to shared imports** — delete now if no longer needed:
- `toolCallUtils.ts` — functions may have been ported to toolkit files
- `richEditorUtils.ts` — functions may have been moved to RichPromptEditor
- `AttachedContextSection.tsx` — check if Thread's UserMessage uses it
Run a grep/search for any remaining imports of deleted files. Fix any broken imports.
### 6. Remove unused npm packages
After the migration, some packages may no longer be needed:
- `react-markdown` — if assistant-ui's Markdown component replaces all usage. **Check**: `RichPromptEditor` or other non-chat pages may still use it.
- `remark-gfm` — same as above
Only remove if truly unused across the entire codebase.
### 7. Clean up agentsSlice streaming reducers
The `streamStart`, `streamDelta`, `streamEnd` reducers in the agents Redux slice may be simplified since the ExternalStoreRuntime adapter handles streaming display state. However, keep them if:
- The WebSocketManager still dispatches them
- The runtime adapter reads `session.streamingMessage` from Redux
Don't remove them if removing would break the data flow. Just leave them — they're small.
### 8. Verify the build
```bash
cd frontend
npm run build
```
Fix any TypeScript errors, missing imports, or broken references.
### 9. Manual smoke test
Start the dev server and verify:
- [ ] Chat page loads
- [ ] Existing sessions show their message history
- [ ] Can type in the Composer and send a message
- [ ] `@` trigger opens the mention popover with categories
- [ ] `/` trigger opens command categories (templates, skills, modes)
- [ ] Streaming responses animate token-by-token
- [ ] Tool calls render with Terminal/CodeBlock/CodeDiff components
- [ ] MCP tool results render (Gmail, Calendar, Drive)
- [ ] Approval requests show ApprovalCard
- [ ] AskUserQuestion shows OptionList/QuestionFlow
- [ ] Branch navigation works
- [ ] Copy/edit/regenerate actions work
- [ ] Scroll-to-bottom works
- [ ] SkillBuilderChat works in embedded mode
- [ ] Dashboard page still works (no regressions)
- [ ] Settings/Modes/Templates pages still work
## Files Modified
| File | Change |
|------|--------|
| `AgentChat.tsx` | Rewritten to use assistant-ui Thread + Composer + toolkit |
| `hooks/useAgentChat.ts` | Removed scroll-related code |
| `SkillBuilderChat.tsx` | Updated to work with new AgentChat (if needed) |
## Files Deleted (remaining dead code)
| File | Reason |
|------|--------|
| `toolCallUtils.ts` | Functions ported to toolkit files |
| `richEditorUtils.ts` | Functions moved to RichPromptEditor (if still needed) |
| `AttachedContextSection.tsx` | Context rendering moved to UserMessage in Thread |
| Any other orphaned files | Verify with import search |
## Verification Checklist
- [ ] `npm run build` succeeds with no errors
- [ ] `npm run dev` starts the dev server
- [ ] Chat page renders with assistant-ui Thread
- [ ] Messages display correctly (user + assistant)
- [ ] Tool calls render with Tool UI components
- [ ] Approvals render with ApprovalCard
- [ ] Questions render with OptionList/QuestionFlow
- [ ] Composer works with Mention popover
- [ ] Streaming works
- [ ] Branching works
- [ ] SkillBuilderChat works
- [ ] No console errors
- [ ] No broken imports (grep for imports of deleted files)
- [ ] Non-chat pages (Dashboard, Settings, Modes, Templates, Tools, Views) still work
+4 -3
View File
@@ -11,9 +11,10 @@ import os
import sys
from backend.apps.agents.models import AgentSession
from backend.apps.agents.prompt_builder import (
resolve_mode, compose_system_prompt, build_connected_tools_context,
build_outputs_context, build_browser_context, get_pre_selected_browser_ids,
from backend.apps.agents.prompt_builder import resolve_mode, compose_system_prompt
from backend.apps.agents.prompt_context import (
build_connected_tools_context, build_outputs_context,
build_browser_context, get_pre_selected_browser_ids,
)
from backend.apps.agents.mcp_builder import (
FULL_TOOLS, build_mcp_servers, get_all_tool_names,
@@ -0,0 +1,92 @@
"""Tool schema definitions for the browser agent delegation MCP server."""
TOOLS = [
{
"name": "CreateBrowserAgent",
"description": (
"Create a new browser card and run a task on it. A dedicated browser agent "
"will autonomously perform the task (navigating, clicking, typing, etc.) "
"and return a summary of actions taken plus a final screenshot. "
"Use this when you need a fresh browser for a new task."
),
"inputSchema": {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": (
"The task for the browser agent to perform. Be specific and "
"detailed about what you want accomplished."
),
},
"url": {
"type": "string",
"description": (
"Optional starting URL. The new browser will navigate here "
"before beginning the task."
),
},
},
"required": ["task"],
},
},
{
"name": "BrowserAgent",
"description": (
"Delegate a browser task to a dedicated browser agent on an existing "
"browser card. The browser agent will autonomously perform the task "
"(navigating, clicking, typing, etc.) and return a summary of actions "
"taken plus a final screenshot."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The ID of the existing browser card to use.",
},
"task": {
"type": "string",
"description": (
"The task for the browser agent to perform. Be specific and "
"detailed about what you want accomplished."
),
},
},
"required": ["browser_id", "task"],
},
},
{
"name": "BrowserAgents",
"description": (
"Delegate multiple browser tasks to run in parallel, each on an existing "
"browser card. All tasks execute concurrently and results are returned "
"together. Use this when you need to perform tasks on multiple web pages "
"simultaneously."
),
"inputSchema": {
"type": "object",
"properties": {
"tasks": {
"type": "array",
"description": "Array of browser tasks to run in parallel.",
"items": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The ID of the existing browser card to use.",
},
"task": {
"type": "string",
"description": "The task for this browser agent.",
},
},
"required": ["browser_id", "task"],
},
},
},
"required": ["tasks"],
},
},
]
@@ -20,6 +20,8 @@ try:
except ImportError:
HAS_PIL = False
from browser_agent_mcp_schemas import TOOLS # noqa: E402 (sibling script import)
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/agents/browser-agent/run"
MODEL = os.environ.get("OPENSWARM_AGENT_MODEL", "sonnet")
@@ -27,97 +29,6 @@ DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
PRE_SELECTED_BROWSER_IDS = os.environ.get("OPENSWARM_PRE_SELECTED_BROWSER_IDS", "")
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
TOOLS = [
{
"name": "CreateBrowserAgent",
"description": (
"Create a new browser card and run a task on it. A dedicated browser agent "
"will autonomously perform the task (navigating, clicking, typing, etc.) "
"and return a summary of actions taken plus a final screenshot. "
"Use this when you need a fresh browser for a new task."
),
"inputSchema": {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": (
"The task for the browser agent to perform. Be specific and "
"detailed about what you want accomplished."
),
},
"url": {
"type": "string",
"description": (
"Optional starting URL. The new browser will navigate here "
"before beginning the task."
),
},
},
"required": ["task"],
},
},
{
"name": "BrowserAgent",
"description": (
"Delegate a browser task to a dedicated browser agent on an existing "
"browser card. The browser agent will autonomously perform the task "
"(navigating, clicking, typing, etc.) and return a summary of actions "
"taken plus a final screenshot."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The ID of the existing browser card to use.",
},
"task": {
"type": "string",
"description": (
"The task for the browser agent to perform. Be specific and "
"detailed about what you want accomplished."
),
},
},
"required": ["browser_id", "task"],
},
},
{
"name": "BrowserAgents",
"description": (
"Delegate multiple browser tasks to run in parallel, each on an existing "
"browser card. All tasks execute concurrently and results are returned "
"together. Use this when you need to perform tasks on multiple web pages "
"simultaneously."
),
"inputSchema": {
"type": "object",
"properties": {
"tasks": {
"type": "array",
"description": "Array of browser tasks to run in parallel.",
"items": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The ID of the existing browser card to use.",
},
"task": {
"type": "string",
"description": "The task for this browser agent.",
},
},
"required": ["browser_id", "task"],
},
},
},
"required": ["tasks"],
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
+212
View File
@@ -0,0 +1,212 @@
"""Tool schema definitions for the browser MCP server."""
TAB_ID_PROP = {
"type": "string",
"description": "Optional tab ID within the browser card. If omitted, targets the active tab.",
}
TOOLS = [
{
"name": "BrowserScreenshot",
"description": (
"Capture a screenshot of the browser page. Returns the screenshot as a "
"base64-encoded PNG image. Use this to see what is currently displayed."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserGetText",
"description": (
"Get the visible text content of the browser page. Returns the page's "
"innerText (up to 15000 characters)."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserNavigate",
"description": "Navigate the browser to a URL.",
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"url": {
"type": "string",
"description": "The URL to navigate to.",
},
},
"required": ["browser_id", "url"],
},
},
{
"name": "BrowserClick",
"description": (
"Click an element in the browser page identified by a CSS selector."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the element to click.",
},
},
"required": ["browser_id", "selector"],
},
},
{
"name": "BrowserType",
"description": (
"Type text into an input element in the browser page. Clears the "
"existing value first, then types the new text."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the input element.",
},
"text": {
"type": "string",
"description": "The text to type.",
},
},
"required": ["browser_id", "selector", "text"],
},
},
{
"name": "BrowserEvaluate",
"description": (
"Evaluate a JavaScript expression in the browser page and return the result. "
"The expression is run via executeJavaScript on the webview."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate.",
},
},
"required": ["browser_id", "expression"],
},
},
{
"name": "BrowserGetElements",
"description": (
"Get a list of interactive elements on the page with their CSS selectors. "
"Returns clickable elements, inputs, links, and buttons with selector paths "
"you can use with BrowserClick and BrowserType. Call this BEFORE attempting "
"to click or type so you know which selectors are valid."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": (
"Optional CSS selector to scope the search "
"(e.g. 'form', '#main'). Defaults to 'body'."
),
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserScroll",
"description": (
"Scroll the page up or down. Automatically finds the correct scrollable "
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
"containers instead of window-level scrolling). Returns scroll position info "
"including whether top/bottom has been reached."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"direction": {
"type": "string",
"enum": ["up", "down"],
"description": "Scroll direction. Defaults to 'down'.",
},
"amount": {
"type": "number",
"description": "Pixels to scroll. Defaults to 500.",
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserWait",
"description": (
"Wait for a specified duration. Useful after navigation or actions that "
"trigger page loads, animations, or async content rendering. "
"Min 100ms, max 10000ms."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"milliseconds": {
"type": "number",
"description": "Duration to wait in milliseconds. Defaults to 1000.",
},
},
"required": ["browser_id"],
},
},
]
+2 -211
View File
@@ -21,220 +21,11 @@ try:
except ImportError:
HAS_PIL = False
from browser_mcp_schemas import TOOLS # noqa: E402 (sibling script import)
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/agents/browser/command"
TAB_ID_PROP = {
"type": "string",
"description": "Optional tab ID within the browser card. If omitted, targets the active tab.",
}
TOOLS = [
{
"name": "BrowserScreenshot",
"description": (
"Capture a screenshot of the browser page. Returns the screenshot as a "
"base64-encoded PNG image. Use this to see what is currently displayed."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserGetText",
"description": (
"Get the visible text content of the browser page. Returns the page's "
"innerText (up to 15000 characters)."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
},
"required": ["browser_id"],
},
},
{
"name": "BrowserNavigate",
"description": "Navigate the browser to a URL.",
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"url": {
"type": "string",
"description": "The URL to navigate to.",
},
},
"required": ["browser_id", "url"],
},
},
{
"name": "BrowserClick",
"description": (
"Click an element in the browser page identified by a CSS selector."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the element to click.",
},
},
"required": ["browser_id", "selector"],
},
},
{
"name": "BrowserType",
"description": (
"Type text into an input element in the browser page. Clears the "
"existing value first, then types the new text."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": "CSS selector of the input element.",
},
"text": {
"type": "string",
"description": "The text to type.",
},
},
"required": ["browser_id", "selector", "text"],
},
},
{
"name": "BrowserEvaluate",
"description": (
"Evaluate a JavaScript expression in the browser page and return the result. "
"The expression is run via executeJavaScript on the webview."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate.",
},
},
"required": ["browser_id", "expression"],
},
},
{
"name": "BrowserGetElements",
"description": (
"Get a list of interactive elements on the page with their CSS selectors. "
"Returns clickable elements, inputs, links, and buttons with selector paths "
"you can use with BrowserClick and BrowserType. Call this BEFORE attempting "
"to click or type so you know which selectors are valid."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"selector": {
"type": "string",
"description": (
"Optional CSS selector to scope the search "
"(e.g. 'form', '#main'). Defaults to 'body'."
),
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserScroll",
"description": (
"Scroll the page up or down. Automatically finds the correct scrollable "
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
"containers instead of window-level scrolling). Returns scroll position info "
"including whether top/bottom has been reached."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"direction": {
"type": "string",
"enum": ["up", "down"],
"description": "Scroll direction. Defaults to 'down'.",
},
"amount": {
"type": "number",
"description": "Pixels to scroll. Defaults to 500.",
},
},
"required": ["browser_id"],
},
},
{
"name": "BrowserWait",
"description": (
"Wait for a specified duration. Useful after navigation or actions that "
"trigger page loads, animations, or async content rendering. "
"Min 100ms, max 10000ms."
),
"inputSchema": {
"type": "object",
"properties": {
"browser_id": {
"type": "string",
"description": "The browser card ID.",
},
"tab_id": TAB_ID_PROP,
"milliseconds": {
"type": "number",
"description": "Duration to wait in milliseconds. Defaults to 1000.",
},
},
"required": ["browser_id"],
},
},
]
def send_response(id_, result=None, error=None):
msg = {"jsonrpc": "2.0", "id": id_}
+2 -201
View File
@@ -1,4 +1,4 @@
"""Prompt-building helpers extracted from AgentManager.
"""Prompt composition helpers extracted from AgentManager.
All functions are stateless — they accept data as parameters instead of
relying on ``self``.
@@ -6,15 +6,12 @@ relying on ``self``.
from __future__ import annotations
import json as _json
import logging
import os
from typing import Any
from backend.apps.agents.models import AgentSession
from backend.apps.modes.modes import load_mode
from backend.apps.outputs.outputs import _load_all as load_all_outputs
from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name
from backend.apps.agents.prompt_context import resolve_context_paths
logger = logging.getLogger(__name__)
@@ -44,202 +41,6 @@ def compose_system_prompt(
return "\n\n".join(parts) if parts else None
def build_connected_tools_context(
allowed_tools: list[str],
load_all_tools_fn,
get_all_tool_names_fn,
is_fully_denied_fn,
get_denied_tool_names_fn,
) -> str | None:
all_tools = load_all_tools_fn()
mcp_tools = [t for t in all_tools if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")]
sections: list[str] = []
for tool in mcp_tools:
tool_ref = f"mcp:{tool.name}"
if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names_fn():
continue
if is_fully_denied_fn(tool):
continue
server_name = _sanitize_server_name(tool.name)
denied = get_denied_tool_names_fn(tool)
tool_descs = {
k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items()
if k not in denied
}
if not tool_descs:
continue
lines = [f"MCP Server: {server_name}"]
lines.append(f" Status: {tool.auth_status}")
if tool.connected_account_email:
lines.append(f" Connected account: {tool.connected_account_email}")
lines.append(
f" IMPORTANT: When calling tools from this server that require an email "
f"parameter (e.g. user_google_email, user_email), always use "
f"\"{tool.connected_account_email}\" automatically — do NOT ask the user."
)
tool_names = list(tool_descs.keys())
if tool_names:
lines.append(f" Available tools ({len(tool_names)}): {', '.join(tool_names)}")
sections.append("\n".join(lines))
not_connected = [
t for t in all_tools
if t.mcp_config and t.enabled
and t.auth_type in ("oauth2", "env_vars")
and t.auth_status != "connected"
]
if not_connected:
nc_lines = ["Tools installed but not yet connected (user needs to authorize in Settings → Tools):"]
for t in not_connected:
nc_lines.append(f" - {t.name}")
sections.append("\n".join(nc_lines))
if not sections:
return None
return (
"<connected_mcp_tools>\n"
"The following MCP tool servers are connected and available. "
"Use them directly when relevant to the user's request.\n\n"
+ "\n\n".join(sections)
+ "\n</connected_mcp_tools>"
)
def build_outputs_context() -> str | None:
all_outputs = load_all_outputs()
if not all_outputs:
return None
sections: list[str] = []
for out in all_outputs:
lines = [f"- **{out.name}** (id: `{out.id}`)"]
if out.description:
lines.append(f" Description: {out.description}")
schema_str = _json.dumps(out.input_schema, indent=2)
lines.append(f" Input schema:\n```json\n{schema_str}\n```")
sections.append("\n".join(lines))
return (
"<available_views>\n"
"The following reusable View artifacts are available. "
"Use the RenderOutput tool to invoke one by providing its output_id "
"and the required input_data matching its schema.\n\n"
+ "\n\n".join(sections)
+ "\n</available_views>"
)
def build_browser_context(
dashboard_id: str | None,
selected_browser_ids: list[str] | None = None,
) -> str | None:
if not dashboard_id:
return None
try:
from backend.apps.dashboards.dashboards import _load as load_dashboard
dashboard = load_dashboard(dashboard_id)
except Exception:
return None
raw = dashboard.model_dump(mode="json")
browser_cards = raw.get("layout", {}).get("browser_cards", {})
lines = [
"<browser_agent_instructions>",
"You have access to browser automation through the CreateBrowserAgent, BrowserAgent, and BrowserAgents tools.",
"",
"- **CreateBrowserAgent(task, url?)**: Create a new browser card and run a task on it. "
"Use this when you need a fresh browser. Optionally provide a starting URL.",
"- **BrowserAgent(browser_id, task)**: Delegate a task to an existing browser card. "
"The browser agent will autonomously navigate, click, type, and interact with the page, then return a summary and screenshot.",
"- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel on existing browser cards. "
"Each task requires a browser_id.",
"",
"You do NOT have direct access to low-level browser tools (click, type, screenshot, etc.). "
"Instead, describe what you want accomplished and the browser agent will handle the details.",
]
if browser_cards and selected_browser_ids:
visible_cards = [
card for card in browser_cards.values()
if card.get("browser_id", "") in selected_browser_ids
]
if visible_cards:
lines.append("")
lines.append("The user selected these browser cards for you to work with:")
for card in visible_cards:
bid = card.get("browser_id", "")
tabs = card.get("tabs", [])
active_tab_id = card.get("activeTabId", "")
active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None)
url = (active_tab or {}).get("url", card.get("url", ""))
title = (active_tab or {}).get("title", "")
lines.append(f"- browser_id: \"{bid}\"")
if title:
lines.append(f" Title: {title}")
if url:
lines.append(f" URL: {url}")
lines.append("</browser_agent_instructions>")
return "\n".join(lines)
def get_pre_selected_browser_ids(dashboard_id: str | None) -> list[str]:
if not dashboard_id:
return []
try:
from backend.apps.dashboards.dashboards import _load as load_dashboard
dashboard = load_dashboard(dashboard_id)
except Exception:
return []
raw = dashboard.model_dump(mode="json")
browser_cards = raw.get("layout", {}).get("browser_cards", {})
return [card.get("browser_id", "") for card in browser_cards.values() if card.get("browser_id")]
def resolve_context_paths(context_paths: list | None) -> str:
if not context_paths:
return ""
sections: list[str] = []
for cp in context_paths:
path = cp.get("path", "")
cp_type = cp.get("type", "file")
if not path or not os.path.exists(path):
sections.append(f"[Context: {path} — not found]")
continue
if cp_type == "file" and os.path.isfile(path):
try:
with open(path, "r", errors="replace") as f:
content = f.read(512_000)
sections.append(f"<context_file path=\"{path}\">\n{content}\n</context_file>")
except Exception as e:
sections.append(f"[Context: {path} — error reading: {e}]")
elif cp_type == "directory" and os.path.isdir(path):
tree_lines = build_dir_tree(path, max_depth=4)
sections.append(f"<context_directory path=\"{path}\">\n{chr(10).join(tree_lines)}\n</context_directory>")
else:
sections.append(f"[Context: {path} — type mismatch]")
return "\n\n".join(sections)
def build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
lines: list[str] = []
try:
entries = sorted(os.listdir(root))
except PermissionError:
return [f"{prefix}[permission denied]"]
dirs = [e for e in entries if not e.startswith(".") and os.path.isdir(os.path.join(root, e))]
files = [e for e in entries if not e.startswith(".") and os.path.isfile(os.path.join(root, e))]
for f in files:
lines.append(f"{prefix}{f}")
for d in dirs:
lines.append(f"{prefix}{d}/")
if max_depth > 1:
sub = build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ")
lines.extend(sub)
return lines
def resolve_forced_tools(
forced_tools: list[str] | None,
load_all_tools_fn,
+212
View File
@@ -0,0 +1,212 @@
"""Context-building helpers for agent prompts.
Assembles tool context, output schemas, browser instructions,
file/directory context, and directory trees.
"""
from __future__ import annotations
import json as _json
import logging
import os
from backend.apps.outputs.outputs import _load_all as load_all_outputs
from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name
logger = logging.getLogger(__name__)
def build_connected_tools_context(
allowed_tools: list[str],
load_all_tools_fn,
get_all_tool_names_fn,
is_fully_denied_fn,
get_denied_tool_names_fn,
) -> str | None:
all_tools = load_all_tools_fn()
mcp_tools = [t for t in all_tools if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")]
sections: list[str] = []
for tool in mcp_tools:
tool_ref = f"mcp:{tool.name}"
if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names_fn():
continue
if is_fully_denied_fn(tool):
continue
server_name = _sanitize_server_name(tool.name)
denied = get_denied_tool_names_fn(tool)
tool_descs = {
k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items()
if k not in denied
}
if not tool_descs:
continue
lines = [f"MCP Server: {server_name}"]
lines.append(f" Status: {tool.auth_status}")
if tool.connected_account_email:
lines.append(f" Connected account: {tool.connected_account_email}")
lines.append(
f" IMPORTANT: When calling tools from this server that require an email "
f"parameter (e.g. user_google_email, user_email), always use "
f"\"{tool.connected_account_email}\" automatically — do NOT ask the user."
)
tool_names = list(tool_descs.keys())
if tool_names:
lines.append(f" Available tools ({len(tool_names)}): {', '.join(tool_names)}")
sections.append("\n".join(lines))
not_connected = [
t for t in all_tools
if t.mcp_config and t.enabled
and t.auth_type in ("oauth2", "env_vars")
and t.auth_status != "connected"
]
if not_connected:
nc_lines = ["Tools installed but not yet connected (user needs to authorize in Settings → Tools):"]
for t in not_connected:
nc_lines.append(f" - {t.name}")
sections.append("\n".join(nc_lines))
if not sections:
return None
return (
"<connected_mcp_tools>\n"
"The following MCP tool servers are connected and available. "
"Use them directly when relevant to the user's request.\n\n"
+ "\n\n".join(sections)
+ "\n</connected_mcp_tools>"
)
def build_outputs_context() -> str | None:
all_outputs = load_all_outputs()
if not all_outputs:
return None
sections: list[str] = []
for out in all_outputs:
lines = [f"- **{out.name}** (id: `{out.id}`)"]
if out.description:
lines.append(f" Description: {out.description}")
schema_str = _json.dumps(out.input_schema, indent=2)
lines.append(f" Input schema:\n```json\n{schema_str}\n```")
sections.append("\n".join(lines))
return (
"<available_views>\n"
"The following reusable View artifacts are available. "
"Use the RenderOutput tool to invoke one by providing its output_id "
"and the required input_data matching its schema.\n\n"
+ "\n\n".join(sections)
+ "\n</available_views>"
)
def build_browser_context(
dashboard_id: str | None,
selected_browser_ids: list[str] | None = None,
) -> str | None:
if not dashboard_id:
return None
try:
from backend.apps.dashboards.dashboards import _load as load_dashboard
dashboard = load_dashboard(dashboard_id)
except Exception:
return None
raw = dashboard.model_dump(mode="json")
browser_cards = raw.get("layout", {}).get("browser_cards", {})
lines = [
"<browser_agent_instructions>",
"You have access to browser automation through the CreateBrowserAgent, BrowserAgent, and BrowserAgents tools.",
"",
"- **CreateBrowserAgent(task, url?)**: Create a new browser card and run a task on it. "
"Use this when you need a fresh browser. Optionally provide a starting URL.",
"- **BrowserAgent(browser_id, task)**: Delegate a task to an existing browser card. "
"The browser agent will autonomously navigate, click, type, and interact with the page, then return a summary and screenshot.",
"- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel on existing browser cards. "
"Each task requires a browser_id.",
"",
"You do NOT have direct access to low-level browser tools (click, type, screenshot, etc.). "
"Instead, describe what you want accomplished and the browser agent will handle the details.",
]
if browser_cards and selected_browser_ids:
visible_cards = [
card for card in browser_cards.values()
if card.get("browser_id", "") in selected_browser_ids
]
if visible_cards:
lines.append("")
lines.append("The user selected these browser cards for you to work with:")
for card in visible_cards:
bid = card.get("browser_id", "")
tabs = card.get("tabs", [])
active_tab_id = card.get("activeTabId", "")
active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None)
url = (active_tab or {}).get("url", card.get("url", ""))
title = (active_tab or {}).get("title", "")
lines.append(f"- browser_id: \"{bid}\"")
if title:
lines.append(f" Title: {title}")
if url:
lines.append(f" URL: {url}")
lines.append("</browser_agent_instructions>")
return "\n".join(lines)
def get_pre_selected_browser_ids(dashboard_id: str | None) -> list[str]:
if not dashboard_id:
return []
try:
from backend.apps.dashboards.dashboards import _load as load_dashboard
dashboard = load_dashboard(dashboard_id)
except Exception:
return []
raw = dashboard.model_dump(mode="json")
browser_cards = raw.get("layout", {}).get("browser_cards", {})
return [card.get("browser_id", "") for card in browser_cards.values() if card.get("browser_id")]
def resolve_context_paths(context_paths: list | None) -> str:
if not context_paths:
return ""
sections: list[str] = []
for cp in context_paths:
path = cp.get("path", "")
cp_type = cp.get("type", "file")
if not path or not os.path.exists(path):
sections.append(f"[Context: {path} — not found]")
continue
if cp_type == "file" and os.path.isfile(path):
try:
with open(path, "r", errors="replace") as f:
content = f.read(512_000)
sections.append(f"<context_file path=\"{path}\">\n{content}\n</context_file>")
except Exception as e:
sections.append(f"[Context: {path} — error reading: {e}]")
elif cp_type == "directory" and os.path.isdir(path):
tree_lines = build_dir_tree(path, max_depth=4)
sections.append(f"<context_directory path=\"{path}\">\n{chr(10).join(tree_lines)}\n</context_directory>")
else:
sections.append(f"[Context: {path} — type mismatch]")
return "\n\n".join(sections)
def build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
lines: list[str] = []
try:
entries = sorted(os.listdir(root))
except PermissionError:
return [f"{prefix}[permission denied]"]
dirs = [e for e in entries if not e.startswith(".") and os.path.isdir(os.path.join(root, e))]
files = [e for e in entries if not e.startswith(".") and os.path.isfile(os.path.join(root, e))]
for f in files:
lines.append(f"{prefix}{f}")
for d in dirs:
lines.append(f"{prefix}{d}/")
if max_depth > 1:
sub = build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ")
lines.extend(sub)
return lines
+6 -135
View File
@@ -1,17 +1,14 @@
"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
import asyncio
import json
import logging
import os
import platform
from collections import Counter
from contextlib import asynccontextmanager
from datetime import datetime
from backend.config.Apps import SubApp
from backend.config.paths import SESSIONS_DIR
from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
from backend.apps.analytics.usage_summary import load_all_sessions, compute_session_stats, enrich_with_nine_router
logger = logging.getLogger(__name__)
@@ -30,7 +27,6 @@ async def _heartbeat_loop():
"active_session_count": len(agent_manager.sessions),
}
# Include 9Router cost snapshot if available
try:
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
if _9r_running():
@@ -40,7 +36,6 @@ async def _heartbeat_loop():
props["nine_router_total_prompt_tokens"] = stats.get("totalPromptTokens", 0)
props["nine_router_total_completion_tokens"] = stats.get("totalCompletionTokens", 0)
props["nine_router_total_requests"] = stats.get("totalRequests", 0)
# Per-model breakdown as flat properties for PostHog
for model_name, model_data in (stats.get("byModel") or {}).items():
safe_name = model_name.replace(".", "_").replace("-", "_")[:40]
props[f"cost_model_{safe_name}"] = model_data.get("cost", 0)
@@ -50,7 +45,6 @@ async def _heartbeat_loop():
record("app.heartbeat", props)
# Also fire a dedicated cost snapshot for cleaner dashboards
if "nine_router_total_cost" in props:
record("cost.snapshot", {
"total_cost_usd": props["nine_router_total_cost"],
@@ -73,7 +67,6 @@ async def analytics_lifespan():
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
# Track first open
is_first_open = settings.first_opened_at is None
if is_first_open:
settings.first_opened_at = datetime.now().isoformat()
@@ -117,19 +110,16 @@ async def analytics_lifespan():
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
# Auto-start 9Router for subscription access
try:
from backend.apps.nine_router import ensure_running as ensure_9router
await ensure_9router()
except Exception as e:
logger.debug(f"9Router auto-start skipped: {e}")
logger.warning(f"9Router auto-start failed: {e}")
# Start heartbeat
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
yield
# Stop heartbeat
if _heartbeat_task:
_heartbeat_task.cancel()
try:
@@ -138,7 +128,6 @@ async def analytics_lifespan():
pass
_heartbeat_task = None
# Stop 9Router
try:
from backend.apps.nine_router import stop as stop_9router
stop_9router()
@@ -152,137 +141,19 @@ async def analytics_lifespan():
analytics = SubApp("analytics", analytics_lifespan)
def _load_all_sessions() -> list[dict]:
"""Load all persisted session JSON files."""
results = []
if not os.path.exists(SESSIONS_DIR):
return results
for fname in os.listdir(SESSIONS_DIR):
if fname.endswith(".json"):
try:
with open(os.path.join(SESSIONS_DIR, fname)) as f:
results.append(json.load(f))
except Exception:
pass
return results
@analytics.router.get("/usage-summary")
async def usage_summary():
"""Compute usage stats from persisted sessions for the Settings page."""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
# Combine persisted + active sessions
sessions = _load_all_sessions()
sessions = load_all_sessions()
for s in agent_manager.get_all_sessions():
sessions.append(s.model_dump(mode="json"))
total_sessions = len(sessions)
total_cost = sum(s.get("cost_usd", 0) for s in sessions)
total_messages = 0
total_tool_calls = 0
total_duration = 0.0
model_counts: Counter = Counter()
provider_counts: Counter = Counter()
tool_counts: Counter = Counter()
status_counts: Counter = Counter()
for s in sessions:
messages = s.get("messages", [])
user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
total_messages += len(user_msgs)
total_tool_calls += len(tool_msgs)
model_counts[s.get("model", "unknown")] += 1
provider_counts[s.get("provider", "anthropic")] += 1
status_counts[s.get("status", "unknown")] += 1
# Duration
created = s.get("created_at")
closed = s.get("closed_at")
if created and closed:
try:
c_str = created[:19]
cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
if dur > 0:
total_duration += dur
except Exception:
pass
# Count individual tools
for m in tool_msgs:
content = m.get("content", {})
if isinstance(content, dict):
tool_name = content.get("tool", "")
if tool_name:
tool_counts[tool_name] += 1
avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
completed = status_counts.get("completed", 0)
completion_rate = completed / total_sessions if total_sessions > 0 else 0
# Fetch 9Router usage data for accurate cost/token tracking
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
stats = compute_session_stats(sessions)
nine_router_stats = await get_usage_stats() if _9r_running() else None
# Determine best cost source
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
cost_source = "9router"
total_cost = nine_router_stats["totalCost"]
elif total_cost > 0:
cost_source = "sdk"
else:
cost_source = "none"
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
# Extract 9Router breakdowns
cost_by_model = {}
cost_by_provider = {}
total_prompt_tokens = 0
total_completion_tokens = 0
total_requests = 0
if nine_router_stats:
total_prompt_tokens = nine_router_stats.get("totalPromptTokens", 0)
total_completion_tokens = nine_router_stats.get("totalCompletionTokens", 0)
total_requests = nine_router_stats.get("totalRequests", 0)
for key, val in (nine_router_stats.get("byModel") or {}).items():
cost_by_model[key] = {
"cost": val.get("cost", 0),
"requests": val.get("count", 0),
"prompt_tokens": val.get("promptTokens", 0),
"completion_tokens": val.get("completionTokens", 0),
}
for key, val in (nine_router_stats.get("byProvider") or {}).items():
cost_by_provider[key] = {
"cost": val.get("cost", 0),
"requests": val.get("count", 0),
}
return {
"total_sessions": total_sessions,
"total_cost_usd": round(total_cost, 4),
"total_messages": total_messages,
"total_tool_calls": total_tool_calls,
"avg_duration_seconds": round(avg_duration, 1),
"avg_cost_per_session": round(avg_cost, 4),
"completion_rate": round(completion_rate, 3),
"models_used": dict(model_counts.most_common(10)),
"providers_used": dict(provider_counts.most_common(10)),
"top_tools": dict(tool_counts.most_common(15)),
"status_breakdown": dict(status_counts),
# 9Router enrichment
"total_prompt_tokens": total_prompt_tokens,
"total_completion_tokens": total_completion_tokens,
"cost_by_model": cost_by_model,
"cost_by_provider": cost_by_provider,
"cost_source": cost_source,
"nine_router_available": nine_router_stats is not None,
"total_requests": total_requests,
}
return enrich_with_nine_router(stats, nine_router_stats)
@analytics.router.get("/cost-breakdown")
+139
View File
@@ -0,0 +1,139 @@
"""Session-level aggregation logic for the usage-summary endpoint."""
from __future__ import annotations
import json
import os
from collections import Counter
from datetime import datetime
from backend.config.paths import SESSIONS_DIR
def load_all_sessions() -> list[dict]:
"""Load all persisted session JSON files."""
results = []
if not os.path.exists(SESSIONS_DIR):
return results
for fname in os.listdir(SESSIONS_DIR):
if fname.endswith(".json"):
try:
with open(os.path.join(SESSIONS_DIR, fname)) as f:
results.append(json.load(f))
except Exception:
pass
return results
def compute_session_stats(sessions: list[dict]) -> dict:
"""Aggregate counters and durations from a list of session dicts."""
total_sessions = len(sessions)
total_cost = sum(s.get("cost_usd", 0) for s in sessions)
total_messages = 0
total_tool_calls = 0
total_duration = 0.0
model_counts: Counter = Counter()
provider_counts: Counter = Counter()
tool_counts: Counter = Counter()
status_counts: Counter = Counter()
for s in sessions:
messages = s.get("messages", [])
user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
total_messages += len(user_msgs)
total_tool_calls += len(tool_msgs)
model_counts[s.get("model", "unknown")] += 1
provider_counts[s.get("provider", "anthropic")] += 1
status_counts[s.get("status", "unknown")] += 1
created = s.get("created_at")
closed = s.get("closed_at")
if created and closed:
try:
c_str = created[:19]
cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
if dur > 0:
total_duration += dur
except Exception:
pass
for m in tool_msgs:
content = m.get("content", {})
if isinstance(content, dict):
tool_name = content.get("tool", "")
if tool_name:
tool_counts[tool_name] += 1
avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
completed = status_counts.get("completed", 0)
completion_rate = completed / total_sessions if total_sessions > 0 else 0
return {
"total_sessions": total_sessions,
"total_cost_usd": total_cost,
"total_messages": total_messages,
"total_tool_calls": total_tool_calls,
"avg_duration_seconds": avg_duration,
"completion_rate": completion_rate,
"models_used": dict(model_counts.most_common(10)),
"providers_used": dict(provider_counts.most_common(10)),
"top_tools": dict(tool_counts.most_common(15)),
"status_breakdown": dict(status_counts),
}
def enrich_with_nine_router(stats: dict, nine_router_stats: dict | None) -> dict:
"""Merge 9Router cost/token data into the aggregated stats dict."""
total_cost = stats["total_cost_usd"]
total_sessions = stats["total_sessions"]
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
cost_source = "9router"
total_cost = nine_router_stats["totalCost"]
elif total_cost > 0:
cost_source = "sdk"
else:
cost_source = "none"
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
cost_by_model = {}
cost_by_provider = {}
total_prompt_tokens = 0
total_completion_tokens = 0
total_requests = 0
if nine_router_stats:
total_prompt_tokens = nine_router_stats.get("totalPromptTokens", 0)
total_completion_tokens = nine_router_stats.get("totalCompletionTokens", 0)
total_requests = nine_router_stats.get("totalRequests", 0)
for key, val in (nine_router_stats.get("byModel") or {}).items():
cost_by_model[key] = {
"cost": val.get("cost", 0),
"requests": val.get("count", 0),
"prompt_tokens": val.get("promptTokens", 0),
"completion_tokens": val.get("completionTokens", 0),
}
for key, val in (nine_router_stats.get("byProvider") or {}).items():
cost_by_provider[key] = {
"cost": val.get("cost", 0),
"requests": val.get("count", 0),
}
return {
**stats,
"total_cost_usd": round(total_cost, 4),
"avg_duration_seconds": round(stats["avg_duration_seconds"], 1),
"avg_cost_per_session": round(avg_cost, 4),
"completion_rate": round(stats["completion_rate"], 3),
"total_prompt_tokens": total_prompt_tokens,
"total_completion_tokens": total_completion_tokens,
"cost_by_model": cost_by_model,
"cost_by_provider": cost_by_provider,
"cost_source": cost_source,
"nine_router_available": nine_router_stats is not None,
"total_requests": total_requests,
}
+9 -201
View File
@@ -1,7 +1,8 @@
"""MCP Registry SubApp: caches community + Google servers, enriches with GitHub stars."""
import asyncio
import logging
import os
import re
import time
from contextlib import asynccontextmanager
from typing import Optional
@@ -9,11 +10,12 @@ from typing import Optional
import httpx
from fastapi import Query
from backend.config.Apps import SubApp
from backend.apps.mcp_registry.registry_fetcher import (
extract_gh_repo, fetch_all_servers, fetch_google_servers,
)
logger = logging.getLogger(__name__)
REGISTRY_BASE = "https://registry.modelcontextprotocol.io/v0.1"
PAGE_LIMIT = 100
REFRESH_INTERVAL_S = 3600
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
@@ -26,200 +28,6 @@ _refresh_task: Optional[asyncio.Task] = None
_stars_cache: dict[str, int] = {}
def _extract_gh_repo(repo_url: str) -> Optional[str]:
"""Parse 'owner/repo' from a GitHub URL."""
if not repo_url or "github.com" not in repo_url:
return None
parts = repo_url.rstrip("/").split("/")
try:
idx = next(i for i, p in enumerate(parts) if "github.com" in p)
if len(parts) > idx + 2:
owner = parts[idx + 1]
repo = parts[idx + 2].removesuffix(".git")
return f"{owner}/{repo}"
except StopIteration:
pass
return None
def _extract_server(entry: dict) -> Optional[dict]:
"""Extract a flat server record from a registry entry, keeping only latest versions."""
meta = entry.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
if not meta.get("isLatest"):
return None
srv = entry.get("server", {})
name = srv.get("name", "")
if not name:
return None
remotes = srv.get("remotes", [])
remote_url = ""
remote_type = ""
if remotes:
remote_url = remotes[0].get("url", "")
remote_type = remotes[0].get("type", "")
repo = srv.get("repository", {})
packages = srv.get("packages", [])
env_vars = []
if packages:
env_vars = packages[0].get("environmentVariables", [])
pub_meta = srv.get("_meta", {}).get("io.modelcontextprotocol.registry/publisher-provided", {})
icons = srv.get("icons", [])
icon_url = icons[0]["src"] if icons else ""
repo_url = repo.get("url", "") if isinstance(repo, dict) else ""
if not icon_url and repo_url and "github.com" in repo_url:
parts = repo_url.rstrip("/").split("/")
gh_idx = next((i for i, p in enumerate(parts) if "github.com" in p), -1)
if gh_idx >= 0 and len(parts) > gh_idx + 1:
icon_url = f"https://github.com/{parts[gh_idx + 1]}.png?size=64"
return {
"name": name,
"title": srv.get("title", ""),
"description": srv.get("description", ""),
"version": srv.get("version", ""),
"websiteUrl": srv.get("websiteUrl", ""),
"repositoryUrl": repo_url,
"remoteUrl": remote_url,
"remoteType": remote_type,
"iconUrl": icon_url,
"environmentVariables": env_vars,
"keywords": pub_meta.get("keywords", []),
"license": pub_meta.get("license", ""),
"stars": None,
"source": "community",
}
async def _fetch_all_servers() -> dict[str, dict]:
"""Paginate through the full registry and return a dict keyed by server name."""
servers: dict[str, dict] = {}
cursor: Optional[str] = None
pages = 0
async with httpx.AsyncClient(timeout=30.0) as client:
while True:
params: dict = {"limit": PAGE_LIMIT}
if cursor:
params["cursor"] = cursor
try:
resp = await client.get(f"{REGISTRY_BASE}/servers", params=params)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.warning(f"MCP registry fetch failed on page {pages}: {e}")
break
entries = data.get("servers", [])
if not entries:
break
for entry in entries:
record = _extract_server(entry)
if record:
servers[record["name"]] = record
pages += 1
next_cursor = data.get("metadata", {}).get("nextCursor")
if not next_cursor:
break
cursor = next_cursor
logger.info(f"MCP registry cache refreshed: {len(servers)} servers from {pages} pages")
return servers
GOOGLE_README_URL = "https://raw.githubusercontent.com/google/mcp/main/README.md"
GOOGLE_ICON_URL = "https://github.com/google.png?size=64"
_ENTRY_RE = re.compile(r"\[\*\*(.+?)\*\*\]\((.+?)\)(?:[,\s]*(.+))?")
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def _parse_google_readme(text: str) -> dict[str, dict]:
servers: dict[str, dict] = {}
section: Optional[str] = None
for line in text.splitlines():
stripped = line.strip()
if "remote mcp servers" in stripped.lower() and stripped.startswith("#"):
section = "remote"
continue
if "open-source mcp servers" in stripped.lower() and stripped.startswith("#"):
section = "open-source"
continue
if stripped.startswith("#") and section is not None:
# Hit a new top-level section (e.g. Examples, Resources), stop parsing
if not stripped.lower().startswith("### **"):
section = None
continue
if section is None:
continue
m = _ENTRY_RE.search(stripped)
if not m:
continue
title = m.group(1).strip()
url = m.group(2).strip()
desc_raw = (m.group(3) or "").strip().rstrip(".")
slug = _slugify(title)
key = f"google/{slug}"
is_github = "github.com" in url or "go.dev" in url
repo_url = url if is_github else ""
website_url = url if not is_github else ""
if section == "remote":
remote_type = "google-cloud-remote"
description = desc_raw or f"Google Cloud managed MCP server for {title}"
else:
remote_type = "open-source"
description = desc_raw or f"Google open-source MCP server for {title}"
servers[key] = {
"name": key,
"title": title,
"description": description,
"version": "",
"websiteUrl": website_url,
"repositoryUrl": repo_url,
"remoteUrl": "",
"remoteType": remote_type,
"iconUrl": GOOGLE_ICON_URL,
"environmentVariables": [],
"keywords": ["google", section],
"license": "Apache-2.0",
"stars": None,
"source": "google",
}
return servers
async def _fetch_google_servers() -> dict[str, dict]:
"""Fetch and parse Google's MCP server catalog from their GitHub README."""
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(GOOGLE_README_URL)
resp.raise_for_status()
servers = _parse_google_readme(resp.text)
logger.info(f"Google MCP catalog: parsed {len(servers)} servers")
return servers
except Exception as e:
logger.warning(f"Google MCP catalog fetch failed: {e}")
return {}
async def _fetch_github_stars(servers: dict[str, dict]):
"""Batch-fetch GitHub star counts for servers with GitHub repos.
@@ -230,7 +38,7 @@ async def _fetch_github_stars(servers: dict[str, dict]):
needed: list[str] = []
for srv in servers.values():
gh = _extract_gh_repo(srv.get("repositoryUrl", ""))
gh = extract_gh_repo(srv.get("repositoryUrl", ""))
if gh and gh not in _stars_cache and gh not in needed:
needed.append(gh)
@@ -285,7 +93,7 @@ async def _fetch_github_stars(servers: dict[str, dict]):
def _apply_stars(servers: dict[str, dict]):
for srv in servers.values():
gh = _extract_gh_repo(srv.get("repositoryUrl", ""))
gh = extract_gh_repo(srv.get("repositoryUrl", ""))
srv["stars"] = _stars_cache.get(gh) if gh else None
@@ -295,8 +103,8 @@ async def _refresh_loop():
while True:
try:
community, google = await asyncio.gather(
_fetch_all_servers(),
_fetch_google_servers(),
fetch_all_servers(),
fetch_google_servers(),
)
_cache = {**community, **google}
await _fetch_github_stars(_cache)
@@ -0,0 +1,204 @@
"""Data-fetching and parsing logic for MCP registry servers."""
import logging
import re
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
REGISTRY_BASE = "https://registry.modelcontextprotocol.io/v0.1"
PAGE_LIMIT = 100
GOOGLE_README_URL = "https://raw.githubusercontent.com/google/mcp/main/README.md"
GOOGLE_ICON_URL = "https://github.com/google.png?size=64"
_ENTRY_RE = re.compile(r"\[\*\*(.+?)\*\*\]\((.+?)\)(?:[,\s]*(.+))?")
def extract_gh_repo(repo_url: str) -> Optional[str]:
"""Parse 'owner/repo' from a GitHub URL."""
if not repo_url or "github.com" not in repo_url:
return None
parts = repo_url.rstrip("/").split("/")
try:
idx = next(i for i, p in enumerate(parts) if "github.com" in p)
if len(parts) > idx + 2:
owner = parts[idx + 1]
repo = parts[idx + 2].removesuffix(".git")
return f"{owner}/{repo}"
except StopIteration:
pass
return None
def _extract_server(entry: dict) -> Optional[dict]:
"""Extract a flat server record from a registry entry, keeping only latest versions."""
meta = entry.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
if not meta.get("isLatest"):
return None
srv = entry.get("server", {})
name = srv.get("name", "")
if not name:
return None
remotes = srv.get("remotes", [])
remote_url = ""
remote_type = ""
if remotes:
remote_url = remotes[0].get("url", "")
remote_type = remotes[0].get("type", "")
repo = srv.get("repository", {})
packages = srv.get("packages", [])
env_vars = []
if packages:
env_vars = packages[0].get("environmentVariables", [])
pub_meta = srv.get("_meta", {}).get("io.modelcontextprotocol.registry/publisher-provided", {})
icons = srv.get("icons", [])
icon_url = icons[0]["src"] if icons else ""
repo_url = repo.get("url", "") if isinstance(repo, dict) else ""
if not icon_url and repo_url and "github.com" in repo_url:
parts = repo_url.rstrip("/").split("/")
gh_idx = next((i for i, p in enumerate(parts) if "github.com" in p), -1)
if gh_idx >= 0 and len(parts) > gh_idx + 1:
icon_url = f"https://github.com/{parts[gh_idx + 1]}.png?size=64"
return {
"name": name,
"title": srv.get("title", ""),
"description": srv.get("description", ""),
"version": srv.get("version", ""),
"websiteUrl": srv.get("websiteUrl", ""),
"repositoryUrl": repo_url,
"remoteUrl": remote_url,
"remoteType": remote_type,
"iconUrl": icon_url,
"environmentVariables": env_vars,
"keywords": pub_meta.get("keywords", []),
"license": pub_meta.get("license", ""),
"stars": None,
"source": "community",
}
async def fetch_all_servers() -> dict[str, dict]:
"""Paginate through the full registry and return a dict keyed by server name."""
servers: dict[str, dict] = {}
cursor: Optional[str] = None
pages = 0
async with httpx.AsyncClient(timeout=30.0) as client:
while True:
params: dict = {"limit": PAGE_LIMIT}
if cursor:
params["cursor"] = cursor
try:
resp = await client.get(f"{REGISTRY_BASE}/servers", params=params)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.warning(f"MCP registry fetch failed on page {pages}: {e}")
break
entries = data.get("servers", [])
if not entries:
break
for entry in entries:
record = _extract_server(entry)
if record:
servers[record["name"]] = record
pages += 1
next_cursor = data.get("metadata", {}).get("nextCursor")
if not next_cursor:
break
cursor = next_cursor
logger.info(f"MCP registry cache refreshed: {len(servers)} servers from {pages} pages")
return servers
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def _parse_google_readme(text: str) -> dict[str, dict]:
servers: dict[str, dict] = {}
section: Optional[str] = None
for line in text.splitlines():
stripped = line.strip()
if "remote mcp servers" in stripped.lower() and stripped.startswith("#"):
section = "remote"
continue
if "open-source mcp servers" in stripped.lower() and stripped.startswith("#"):
section = "open-source"
continue
if stripped.startswith("#") and section is not None:
if not stripped.lower().startswith("### **"):
section = None
continue
if section is None:
continue
m = _ENTRY_RE.search(stripped)
if not m:
continue
title = m.group(1).strip()
url = m.group(2).strip()
desc_raw = (m.group(3) or "").strip().rstrip(".")
slug = _slugify(title)
key = f"google/{slug}"
is_github = "github.com" in url or "go.dev" in url
repo_url = url if is_github else ""
website_url = url if not is_github else ""
if section == "remote":
remote_type = "google-cloud-remote"
description = desc_raw or f"Google Cloud managed MCP server for {title}"
else:
remote_type = "open-source"
description = desc_raw or f"Google open-source MCP server for {title}"
servers[key] = {
"name": key,
"title": title,
"description": description,
"version": "",
"websiteUrl": website_url,
"repositoryUrl": repo_url,
"remoteUrl": "",
"remoteType": remote_type,
"iconUrl": GOOGLE_ICON_URL,
"environmentVariables": [],
"keywords": ["google", section],
"license": "Apache-2.0",
"stars": None,
"source": "google",
}
return servers
async def fetch_google_servers() -> dict[str, dict]:
"""Fetch and parse Google's MCP server catalog from their GitHub README."""
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(GOOGLE_README_URL)
resp.raise_for_status()
servers = _parse_google_readme(resp.text)
logger.info(f"Google MCP catalog: parsed {len(servers)} servers")
return servers
except Exception as e:
logger.warning(f"Google MCP catalog fetch failed: {e}")
return {}
+13 -27
View File
@@ -79,8 +79,6 @@ async def ensure_running():
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
if is_running():
# In dev mode, kill stale standalone servers (from previous builds)
# so we can start `next dev` which always uses latest source code
if not _is_packaged:
import subprocess as _sp
try:
@@ -89,73 +87,62 @@ async def ensure_running():
capture_output=True, text=True, timeout=3,
)
if result.stdout.strip():
logger.info("Dev mode: killing stale standalone 9Router to use next dev instead")
print("9Router: killing stale standalone to use next dev", flush=True)
_sp.run(["pkill", "-f", "next-server"], timeout=5)
import asyncio
await asyncio.sleep(2)
else:
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
except Exception:
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
else:
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
return
_9router_dir = _find_9router_dir()
if _is_packaged and _9router_dir:
# Production mode — use pre-built standalone server
# In packaged app, build-staging copies .next/standalone/ contents to 9router/
# So server.js is at 9router/server.js (not 9router/.next/standalone/server.js)
standalone_server = os.path.join(_9router_dir, "server.js")
if not os.path.exists(standalone_server):
# Fallback: check nested path in case build layout changes
standalone_server = os.path.join(_9router_dir, ".next", "standalone", "server.js")
if not os.path.exists(standalone_server):
logger.warning("9Router standalone build not found in %s", _9router_dir)
print("9Router: standalone build not found in", _9router_dir, flush=True)
return
node = _find_node()
if not node:
logger.warning("Node.js not found cannot start 9Router in packaged mode.")
print("9Router: Node.js not found, cannot start in packaged mode", flush=True)
return
logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT)
print(f"9Router: starting (production) on port {NINE_ROUTER_PORT}...", flush=True)
cmd = [node, standalone_server]
cwd = os.path.dirname(standalone_server)
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"}
# If using Electron binary as node, enable ELECTRON_RUN_AS_NODE
if node == os.environ.get("OPENSWARM_ELECTRON_PATH"):
env["ELECTRON_RUN_AS_NODE"] = "1"
elif _9router_dir:
# Dev mode with bundled 9Router — use next dev
npx = shutil.which("npx")
if not npx:
logger.warning("npx not found cannot auto-start 9Router.")
print("9Router: npx not found, cannot auto-start", flush=True)
return
# Install deps if needed
if not os.path.isdir(os.path.join(_9router_dir, "node_modules")):
logger.info("Installing 9Router dependencies...")
print("9Router: installing dependencies...", flush=True)
npm = shutil.which("npm")
if npm:
subprocess.run([npm, "install"], cwd=_9router_dir,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120)
logger.info("Starting 9Router (dev) on port %d...", NINE_ROUTER_PORT)
print(f"9Router: starting (dev) on port {NINE_ROUTER_PORT}...", flush=True)
cmd = [npx, "next", "dev", "--webpack", "-p", str(NINE_ROUTER_PORT)]
cwd = _9router_dir
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)}
else:
# No bundled 9Router — try npx 9router as last resort
npx = shutil.which("npx")
if not npx:
logger.warning("npx not found and no bundled 9Router — cannot start.")
print("9Router: npx not found and no bundled 9router directory", flush=True)
return
logger.info("Starting 9Router (npx) on port %d...", NINE_ROUTER_PORT)
print(f"9Router: starting (npx) on port {NINE_ROUTER_PORT}...", flush=True)
cmd = [npx, "9router"]
cwd = None
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)}
@@ -169,17 +156,16 @@ async def ensure_running():
env=env,
)
# Wait up to 30 seconds for startup (production standalone is faster)
timeout = 20 if _is_packaged else 30
for _ in range(timeout * 2):
await asyncio.sleep(0.5)
if is_running():
logger.info("9Router started successfully")
print("9Router: started successfully", flush=True)
return
logger.warning("9Router did not start within %ds", timeout)
print(f"9Router: did not start within {timeout}s", flush=True)
except Exception as e:
logger.warning(f"Failed to start 9Router: {e}")
print(f"9Router: failed to start: {e}", flush=True)
def stop():
+9 -1
View File
@@ -5,6 +5,7 @@ Moved from agents.py and main.py to a dedicated sub-app.
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
@@ -16,12 +17,16 @@ from backend.config.Apps import SubApp
logger = logging.getLogger(__name__)
_pending_oauth: dict[str, dict] = {}
_ensure_task: asyncio.Task | None = None
@asynccontextmanager
async def subscriptions_lifespan():
logger.info("Subscriptions sub-app starting")
yield
global _ensure_task
if _ensure_task and not _ensure_task.done():
_ensure_task.cancel()
logger.info("Subscriptions sub-app shutting down")
@@ -31,8 +36,11 @@ subscriptions = SubApp("subscriptions", subscriptions_lifespan)
@subscriptions.router.get("/status")
async def subscriptions_status():
"""Check if 9Router is running and list connected providers."""
from backend.apps.nine_router import is_running, get_providers, get_models
global _ensure_task
from backend.apps.nine_router import is_running, ensure_running, get_providers, get_models
if not is_running():
if _ensure_task is None or _ensure_task.done():
_ensure_task = asyncio.create_task(ensure_running())
return {"running": False, "providers": [], "models": []}
providers = await get_providers()
models = await get_models()
+2 -45
View File
@@ -4,7 +4,6 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { AgentMessage, editMessage, switchBranch, duplicateSession, setActiveSession } from '@/shared/state/agentsSlice';
import MessageBubble from './MessageBubble';
import MessageActionBar from './MessageActionBar';
@@ -12,9 +11,9 @@ import ToolCallBubble from './ToolCallBubble';
import ToolGroupBubble, { isToolGroup, isToolPair } from './ToolGroupBubble';
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
import ChatInput from './ChatInput';
import ThinkingBubble from './ThinkingBubble';
import ChatHeader from './ChatHeader';
import MessageQueue from './MessageQueue';
import StreamingSection from './StreamingSection';
import { useAgentChat } from './hooks/useAgentChat';
import { useMessageRendering } from './hooks/useMessageRendering';
import { ContextPath } from '@/app/components/DirectoryBrowser';
@@ -136,49 +135,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, aut
</Box>
);
})}
{session.streamingMessage && (
session.streamingMessage.role === 'tool_call' ? (
<ToolCallBubble
key={`streaming-${session.streamingMessage.id}`}
isStreaming
isPending
sessionId={session.id}
call={{
id: session.streamingMessage.id, role: 'tool_call',
content: { tool: session.streamingMessage.tool_name || '', input: session.streamingMessage.content },
timestamp: new Date().toISOString(), branch_id: session.active_branch_id || 'main', parent_id: null,
}}
/>
) : (
<MessageBubble
key={`streaming-${session.streamingMessage.id}`}
isStreaming
message={{
id: session.streamingMessage.id, role: session.streamingMessage.role,
content: session.streamingMessage.content, timestamp: new Date().toISOString(),
branch_id: session.active_branch_id || 'main', parent_id: null,
}}
/>
)
)}
{(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && <ThinkingBubble />}
{showResumeBubble && session.status === 'stopped' && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<Box
onClick={handleResume}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1.5, py: 0.75,
borderRadius: '12px', cursor: 'pointer',
bgcolor: `${c.accent.primary}10`, border: `1px solid ${c.accent.primary}30`,
transition: 'all 0.15s',
'&:hover': { bgcolor: `${c.accent.primary}1a`, border: `1px solid ${c.accent.primary}50` },
}}
>
<PlayArrowIcon sx={{ fontSize: 14, color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.78rem', fontWeight: 500, color: c.accent.primary }}>Resume Agent Response</Typography>
</Box>
</Box>
)}
<StreamingSection session={session} awaitingResponse={awaitingResponse} showResumeBubble={showResumeBubble} handleResume={handleResume} />
</Box>
{showScrollButton && (
<Tooltip title="Scroll to bottom">
@@ -0,0 +1,68 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import ToolCallBubble from './ToolCallBubble';
import MessageBubble from './MessageBubble';
import ThinkingBubble from './ThinkingBubble';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
interface StreamingSectionProps {
session: any;
awaitingResponse: boolean;
showResumeBubble: boolean;
handleResume: () => void;
}
const StreamingSection: React.FC<StreamingSectionProps> = ({ session, awaitingResponse, showResumeBubble, handleResume }) => {
const c = useClaudeTokens();
return (
<>
{session.streamingMessage && (
session.streamingMessage.role === 'tool_call' ? (
<ToolCallBubble
key={`streaming-${session.streamingMessage.id}`}
isStreaming
isPending
sessionId={session.id}
call={{
id: session.streamingMessage.id, role: 'tool_call',
content: { tool: session.streamingMessage.tool_name || '', input: session.streamingMessage.content },
timestamp: new Date().toISOString(), branch_id: session.active_branch_id || 'main', parent_id: null,
}}
/>
) : (
<MessageBubble
key={`streaming-${session.streamingMessage.id}`}
isStreaming
message={{
id: session.streamingMessage.id, role: session.streamingMessage.role,
content: session.streamingMessage.content, timestamp: new Date().toISOString(),
branch_id: session.active_branch_id || 'main', parent_id: null,
}}
/>
)
)}
{(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && <ThinkingBubble />}
{showResumeBubble && session.status === 'stopped' && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<Box
onClick={handleResume}
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1.5, py: 0.75,
borderRadius: '12px', cursor: 'pointer',
bgcolor: `${c.accent.primary}10`, border: `1px solid ${c.accent.primary}30`,
transition: 'all 0.15s',
'&:hover': { bgcolor: `${c.accent.primary}1a`, border: `1px solid ${c.accent.primary}50` },
}}
>
<PlayArrowIcon sx={{ fontSize: 14, color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.78rem', fontWeight: 500, color: c.accent.primary }}>Resume Agent Response</Typography>
</Box>
</Box>
)}
</>
);
};
export default StreamingSection;
@@ -0,0 +1,88 @@
import React from 'react';
import { Box, Typography, Button, CircularProgress } from '@mui/material';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
export const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
];
export type SubscriptionProvider = typeof SUBSCRIPTION_PROVIDERS[0];
interface SubscriptionCardProps {
provider: SubscriptionProvider;
connected: boolean;
onConnect: () => void;
onDisconnect: () => void;
connecting: boolean;
userCode?: string;
disconnecting?: boolean;
}
const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
const c = useClaudeTokens();
const isPreview = (provider as any).preview;
return (
<Box sx={{
p: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
opacity: isPreview ? 0.5 : 1,
transition: 'all 0.3s ease',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
transition: 'background-color 0.3s ease',
...(connecting ? {
animation: 'pulse-dot 1.5s ease-in-out infinite',
'@keyframes pulse-dot': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
},
} : {}),
}} />
<Box>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
{connecting ? 'Waiting for authorization...' : provider.desc}
</Typography>
</Box>
</Box>
{isPreview ? (
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
Coming soon
</Typography>
) : connected ? (
disconnecting ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : (
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
Disconnect
</Typography>
)
) : connecting && userCode ? (
<Box sx={{ textAlign: 'right' }}>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
</Box>
) : connecting ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
</Box>
) : (
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
Connect
</Button>
)}
</Box>
</Box>
);
};
export default SubscriptionCard;
@@ -1,78 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography, Button, CircularProgress } from '@mui/material';
import React, { useState, useEffect, useRef } from 'react';
import { Box, Typography, CircularProgress } from '@mui/material';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
];
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
const c = useClaudeTokens();
const isPreview = (provider as any).preview;
return (
<Box sx={{
p: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
opacity: isPreview ? 0.5 : 1,
transition: 'all 0.3s ease',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
transition: 'background-color 0.3s ease',
...(connecting ? {
animation: 'pulse-dot 1.5s ease-in-out infinite',
'@keyframes pulse-dot': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
},
} : {}),
}} />
<Box>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
{connecting ? 'Waiting for authorization...' : provider.desc}
</Typography>
</Box>
</Box>
{isPreview ? (
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
Coming soon
</Typography>
) : connected ? (
disconnecting ? (
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
) : (
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
Disconnect
</Typography>
)
) : connecting && userCode ? (
<Box sx={{ textAlign: 'right' }}>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
</Box>
) : connecting ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
</Box>
) : (
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
Connect
</Button>
)}
</Box>
</Box>
);
};
import SubscriptionCard, { SUBSCRIPTION_PROVIDERS } from './SubscriptionCard';
const SubscriptionCards: React.FC = () => {
const c = useClaudeTokens();
@@ -81,13 +11,24 @@ const SubscriptionCards: React.FC = () => {
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const [userCode, setUserCode] = useState('');
const [pollTimer, setPollTimer] = useState<any>(null);
const retryRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchStatus = () => {
fetch(`${API_BASE}/subscriptions/status`)
.then(r => r.json())
.then(setStatus)
.catch(() => setStatus({ running: false, providers: [], models: [] }));
};
useEffect(() => { fetchStatus(); }, []);
useEffect(() => {
fetchStatus();
retryRef.current = setInterval(fetchStatus, 3000);
return () => { if (retryRef.current) clearInterval(retryRef.current); };
}, []);
useEffect(() => {
if (status?.running && retryRef.current) {
clearInterval(retryRef.current);
retryRef.current = null;
}
}, [status?.running]);
const isConnected = (providerId: string) => {
if (!status?.providers) return false;
const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []);