190 lines
9.8 KiB
Markdown
190 lines
9.8 KiB
Markdown
# Claude Web Interface — Handoff Document
|
|
|
|
## What Was Built
|
|
|
|
A web-based chat interface at `/claude` that lets the user talk to Claude Code through the browser. Claude Code runs on the same machine as the server via the **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`), with full access to the monorepo filesystem. Results stream back to the browser in real time over WebSocket.
|
|
|
|
Additionally, a `/plans` page renders markdown plan documents from a `plans/` directory.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Browser (officer-web) Bun Server Same Machine
|
|
┌──────────────┐ WebSocket ┌───────────────────┐ Claude Agent SDK ┌─────────────┐
|
|
│ /claude page │◄────────────►│ /api/claude/ws │◄──────────────────►│ Claude Code │
|
|
│ │ │ │ │ │
|
|
│ - Chat input │ JSON msgs │ - JWT auth on │ async generator │ - File I/O │
|
|
│ - Messages │◄────────────►│ upgrade │◄──────────────────►│ - Bash │
|
|
│ - Tool calls │ │ - Bridge: SDK ↔ WS │ │ - Search │
|
|
│ - Streaming │ │ - Session tracking │ │ - Web fetch │
|
|
└──────────────┘ └───────────────────┘ └─────────────┘
|
|
```
|
|
|
|
### Data Flow
|
|
|
|
1. User sends prompt via browser → WebSocket JSON message `{ type: 'chat', prompt, sessionId? }`
|
|
2. Server calls `query()` from Claude Agent SDK with the prompt (and `resume: sessionId` if continuing)
|
|
3. SDK returns an async generator of `SDKMessage` objects
|
|
4. Server iterates the generator, translating each SDK message into our protocol and sending over WS
|
|
5. Frontend accumulates messages into React state and renders them
|
|
|
|
### Session Persistence
|
|
|
|
- **SDK side**: The Agent SDK handles full conversation context internally via `resume: sessionId`
|
|
- **Frontend side**: Messages are persisted in `localStorage` keyed by session ID (`claude_session_{id}`)
|
|
- **Session index**: A separate `claude_sessions` key in localStorage stores `{ id, title, createdAt }[]`
|
|
- **URL**: Session ID is pushed to the URL via `window.history.replaceState` (not React Router navigate, to avoid remounting)
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
### Backend — WebSocket Bridge
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `src/servers/api/claude/types.ts` | `ClientMessage` and `ServerMessage` union types for the WS protocol |
|
|
| `src/servers/api/claude/websocket.ts` | Bun `WebSocketHandler` — bridges browser WS ↔ Claude Agent SDK `query()` |
|
|
|
|
**Key details of `websocket.ts`:**
|
|
- Per-connection state tracked in a `Map<ServerWebSocket, ConnectionState>` (abortController, currentSessionId)
|
|
- `handleChat()` calls `query()` with `permissionMode: 'bypassPermissions'`, `systemPrompt: { type: 'preset', preset: 'claude_code' }`, `settingSources: ['project']`, `includePartialMessages: true`
|
|
- Iterates the async generator, maps SDK message types to our protocol:
|
|
- `system` (subtype `init`) → `session:init`
|
|
- `assistant` → loops content blocks: `text` → `assistant:text`, `tool_use` → `tool:use`
|
|
- `user` → loops content blocks: `tool_result` → `tool:result`
|
|
- `stream_event` (content_block_delta/text_delta) → `assistant:partial`
|
|
- `result` → sends `result.result` as `assistant:text` fallback, then `result`
|
|
- `stop` message aborts via `AbortController`
|
|
- Has `console.log` debug statements (prefixed `[claude-ws]`) — can be removed once stable
|
|
|
|
### Backend — Server Wiring
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `src/server.tsx` | Added `/api/claude/ws` route for WS upgrade + `websocket: claudeWebsocket` handler |
|
|
| `src/servers/hono.ts` | Added `plansRouter` to protected routes |
|
|
|
|
**WS Auth** (in `server.tsx`):
|
|
- Browsers can't set headers on WS upgrade, so JWT is passed via `?token=` query param
|
|
- Verifies token with `verify()` from `src/servers/jwt.ts`
|
|
- Checks token blacklist (same logic as `user-middleware.ts`)
|
|
- On success, upgrades with `{ data: { userId } }`
|
|
|
|
### Backend — Plans API
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `src/servers/api/plans/plans.ts` | `GET /api/plans` lists plan names, `GET /api/plans/:name` returns markdown text |
|
|
| `plans/claude-web-interface.md` | The plan document for this feature |
|
|
|
|
### Frontend — Claude Chat
|
|
|
|
All in `src/apps/officer-web/Screens/Dashboard/Claude/`:
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `types.ts` | `SessionEntry`, `ChatMessage` (union: user/assistant/tool/result/error), `ServerMessage` |
|
|
| `useClaude.ts` | Core hook: WS connection, message state, streaming, localStorage persistence, session index |
|
|
| `index.tsx` | Screen entry: shows `SessionList` on `/claude`, `ChatPanel` on `/claude/:sessionId` |
|
|
| `ChatPanel.tsx` | Full chat UI: session bar, scrollable messages, auto-resize textarea, send/stop buttons |
|
|
| `MessageBubble.tsx` | Renders messages by role. Assistant text uses `react-markdown` + `remark-gfm` + `rehype-raw`. Includes `StreamingBubble` with blinking cursor |
|
|
| `ToolActivity.tsx` | Collapsible tool call display with per-tool icons, input/output preview, expand/collapse |
|
|
| `SessionList.tsx` | Lists previous sessions from localStorage index. Click to open, delete button on hover, "New Chat" button |
|
|
|
|
**Key details of `useClaude.ts`:**
|
|
- Accepts optional `initialSessionId` from URL params
|
|
- Loads messages from localStorage on mount if resuming
|
|
- Connects WS to `/api/claude/ws?token={bearer}` with exponential backoff reconnect
|
|
- Streaming text accumulated in a ref, flushed to state via `requestAnimationFrame` to avoid render thrashing
|
|
- `sendPrompt()` uses a `sessionIdRef` (always current, no stale closure) to send the sessionId
|
|
- `session:init` → stores sessionId, registers in session index, updates URL via `history.replaceState`
|
|
- `newSession()` → clears state, resets URL to `/claude`
|
|
- Messages auto-saved to localStorage on every change
|
|
|
|
### Frontend — Plans Page
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `src/apps/officer-web/Screens/Dashboard/Plans/index.tsx` | Fetches plan list + selected plan markdown, renders with react-markdown |
|
|
|
|
### Frontend — Routing & Navigation
|
|
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `src/apps/officer-web/App.tsx` | Added `/claude`, `/claude/:sessionId`, `/plans` routes |
|
|
| `src/apps/officer-web/Screens/Dashboard/Layout.tsx` | Added "Plans" link in header nav bar (bold, green). Added Claude (Terminal icon) and Plans (FileText icon) to avatar dropdown menu |
|
|
|
|
---
|
|
|
|
## WebSocket Protocol
|
|
|
|
### Client → Server
|
|
|
|
```ts
|
|
type ClientMessage =
|
|
| { type: 'chat'; prompt: string; sessionId?: string }
|
|
| { type: 'stop' };
|
|
```
|
|
|
|
### Server → Client
|
|
|
|
```ts
|
|
type ServerMessage =
|
|
| { type: 'session:init'; sessionId: string; model: string }
|
|
| { type: 'assistant:text'; text: string } // complete text block
|
|
| { type: 'assistant:partial'; text: string } // streaming delta
|
|
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
|
|
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
|
|
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
|
|
| { type: 'error'; message: string }
|
|
| { type: 'stopped' };
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencies Added
|
|
|
|
- `@anthropic-ai/claude-agent-sdk@0.2.41` — Claude Agent SDK for programmatic Claude Code access
|
|
|
|
Existing dependencies used: `react-markdown`, `remark-gfm`, `rehype-raw`, `lucide-react`, `@radix-ui/react-collapsible`
|
|
|
|
---
|
|
|
|
## Design Decisions
|
|
|
|
| Decision | Choice | Rationale |
|
|
|----------|--------|-----------|
|
|
| WS path | `/api/claude/ws` separate from `/api/ws` | Different protocol/lifecycle than general pub/sub |
|
|
| WS auth | JWT via `?token=` query param | Browsers can't set headers on WS upgrade |
|
|
| Permission mode | `bypassPermissions` | Personal machine, single user |
|
|
| URL updates | `window.history.replaceState` | Avoids React Router remount which kills the WS mid-stream |
|
|
| Message persistence | localStorage per session | Simple, no DB needed for v1 |
|
|
| Session index | Separate `claude_sessions` localStorage key | Avoids parsing every session's messages to build the list |
|
|
| Streaming | ref + requestAnimationFrame flush | Prevents render thrashing from rapid partial deltas |
|
|
| Text fallback | `result.result` sent as `assistant:text` | SDK's `result` message contains final text; ensures text shows even if streaming/assistant parsing has issues |
|
|
|
|
---
|
|
|
|
## Known Issues / Debug Notes
|
|
|
|
1. **Debug logging**: `websocket.ts` has `console.log('[claude-ws]')` statements for debugging SDK message types. Can be removed once stable.
|
|
2. **`as any` casts**: The websocket bridge uses `(message as any).message?.content` and `(message as any).event` because the SDK types don't perfectly match at compile time. Works at runtime.
|
|
3. **Text display**: Initially the assistant text wasn't showing at all. Fixed by adding `result.result` as a fallback `assistant:text` before sending the `result` message. The root cause (whether streaming partials or assistant content blocks aren't being relayed properly) should be investigated further.
|
|
4. **Session list doesn't auto-refresh**: The `SessionList` component loads sessions on mount. If a session is created elsewhere, the list won't update until you navigate back.
|
|
|
|
---
|
|
|
|
## What's NOT Done Yet
|
|
|
|
- Server-side session metadata storage (currently in-memory + localStorage only)
|
|
- Session search/filtering
|
|
- Cost tracking across sessions
|
|
- System prompt customization from UI
|
|
- File change preview/diff in tool activity
|
|
- Proper error recovery on WS disconnect mid-generation
|
|
- Cleaning up old sessions (no TTL or limit)
|
|
- Mobile responsive layout for the chat
|