9.8 KiB
9.8 KiB
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
- User sends prompt via browser → WebSocket JSON message
{ type: 'chat', prompt, sessionId? } - Server calls
query()from Claude Agent SDK with the prompt (andresume: sessionIdif continuing) - SDK returns an async generator of
SDKMessageobjects - Server iterates the generator, translating each SDK message into our protocol and sending over WS
- 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
localStoragekeyed by session ID (claude_session_{id}) - Session index: A separate
claude_sessionskey 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()callsquery()withpermissionMode: 'bypassPermissions',systemPrompt: { type: 'preset', preset: 'claude_code' },settingSources: ['project'],includePartialMessages: true- Iterates the async generator, maps SDK message types to our protocol:
system(subtypeinit) →session:initassistant→ loops content blocks:text→assistant:text,tool_use→tool:useuser→ loops content blocks:tool_result→tool:resultstream_event(content_block_delta/text_delta) →assistant:partialresult→ sendsresult.resultasassistant:textfallback, thenresult
stopmessage aborts viaAbortController- Has
console.logdebug 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()fromsrc/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
initialSessionIdfrom 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
requestAnimationFrameto avoid render thrashing sendPrompt()uses asessionIdRef(always current, no stale closure) to send the sessionIdsession:init→ stores sessionId, registers in session index, updates URL viahistory.replaceStatenewSession()→ 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
type ClientMessage =
| { type: 'chat'; prompt: string; sessionId?: string }
| { type: 'stop' };
Server → Client
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
- Debug logging:
websocket.tshasconsole.log('[claude-ws]')statements for debugging SDK message types. Can be removed once stable. as anycasts: The websocket bridge uses(message as any).message?.contentand(message as any).eventbecause the SDK types don't perfectly match at compile time. Works at runtime.- Text display: Initially the assistant text wasn't showing at all. Fixed by adding
result.resultas a fallbackassistant:textbefore sending theresultmessage. The root cause (whether streaming partials or assistant content blocks aren't being relayed properly) should be investigated further. - Session list doesn't auto-refresh: The
SessionListcomponent 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