# 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
