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

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

  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: textassistant:text, tool_usetool:use
    • user → loops content blocks: tool_resulttool: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

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

  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