# 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` (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; 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