Files
platform/HANDOFF.md
T
2026-02-16 19:34:35 +00:00

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