# Frontend ## HTTP Server Axum router assembled in `src/frontend/server.rs` (`WebServer::build_router_with_plugins`): - `/api/*` — the app's HTTP handlers (`State>`), plus per-plugin routers nested under `/api/plugin//`. - `/data/*` — the on-disk `data/` directory (served via `tower_http::services::ServeDir`, so e.g. `/data/gmail_attachments/...` resolves to a file URL). - Static fallback — the `web.static_dir` directory (`ServeDir`), i.e. the SPA assets. **Compression.** A global `tower_http::compression::CompressionLayer` (gzip + brotli, enabled via the `compression-gzip` / `compression-br` features) wraps the whole router. Encoding is negotiated from the client's `Accept-Encoding` (no-op for clients that don't advertise one and for already-compressed media). The main motivation is the **mobile relay path**: the native WebView's HTTP traffic is reverse-proxied byte-for-byte over a relay pipe (`http-local-proxy`), so shrinking text assets (JS/CSS/HTML) ~70-90% means far fewer bytes cross the slow link. Desktop browsers benefit the same way. **Caching.** Static responses (SPA assets + `/data/*`) carry `Cache-Control: no-cache` (applied via a `tower_http::set_header::SetResponseHeaderLayer` on the two `ServeDir`s). The browser may store the asset but **must revalidate before use** — so after a self-rewrite/restart the client never serves a stale asset (no heuristic caching), and revalidation yields cheap `304`s (`ETag`/`Last-Modified` from `ServeDir`). `/api/*` is deliberately left without the header (dynamic data, not cached). Note: because the mobile loopback proxy listens on an OS-assigned port, WKWebView's URLCache is keyed by a port that changes across app/tab restarts — so cross-session cache hits depend on that port being stable (tracked as a separate, app-side follow-up). ## WebSocket Endpoint `GET /api/ws?source=` `source` identifies the conversation: `web` (default, desktop copilot), `mobile` (mobile chat page), or `project-{id}` (a project's interactive chat — see below). The same endpoint serves all; ChatHub maintains one independent, persistent session per source. One connection per source. The connection is upgraded by Axum's WS handler in `src/frontend/api/ws.rs`. The client sends one `ClientMessage`, receives a stream of `ServerEvent`s, then can send additional messages (cancel, approval) while events are in flight. History for a source: `GET /api//messages` (or the legacy alias `/api/web/messages`). ### Project chats A project's chat is a persistent session bound to source `project-{id}` and driven by the `project-coordinator` agent. `POST /api/projects/{id}/session` provisions (or resumes) it, seeding the session's `RunContext` with the project's working directory, fs-write grants, and context — then returns `{ source, session_id }`. The frontend connects the WS to that source. Because the session is **not** ephemeral and ChatHub reuses the existing session for a source, the conversation persists and is resumed on reopen. Resetting it (`POST /api/sessions?source=project-{id}`) recreates it with the coordinator agent, not `main` (the handler resolves agent + RunContext per source via `provisioning_for_source`). In the desktop copilot these appear as browser-style **tabs**: `General` (the `web` source, always present, not closable) plus one tab per open project chat. The board's **Open Chat** button dispatches a `project-chat-open` window event (`{source, label}`); the copilot adds/focuses the tab and switches the live connection via `ChatSession._switchSource(source)`. Closing a project tab is UI-only — the session persists and can be reopened from the board. --- ## ClientMessage Fields | Field | Type | Description | |---|---|---| | `content` | `String` | The user's prompt text | | `client` | `Option` | Named LLM model override (or `"auto"`) | | `attachments` | `Attachment[]` | Files uploaded beforehand via `POST /api/{source}/uploads`; each `{ path, name, mimetype?, filesize? }`. See [Attachments](#attachments) | --- ## ServerEvent Types All events are JSON objects with a `"type"` tag (snake_case). | type | Key fields | When emitted | |---|---|---| | `tool_start` | `tool_call_id`, `message_id`, `name`, `arguments`, `label_short`, `label_full`, `path?` | Tool call recorded, about to execute. `path` (optional) is the viewable file the call targets — rendered as a clickable link to the file viewer | | `tool_done` | `tool_call_id`, `result` | Tool executed successfully | | `tool_error` | `tool_call_id`, `error` | Tool execution failed | | `agent_start` | `stack_id`, `parent_tool_call_id`, `agent_id`, `depth` | Sub-agent stack frame opened | | `agent_done` | `stack_id` | Sub-agent stack frame closed | | `thinking` | `message_id`, `content`, `input_tokens`, `output_tokens` | LLM produced text before tool calls | | `pending_write` | `request_id`, `tool_call_id`, `path`, `old_content`, `new_content` | Approval required for write/command | | `agent_question` | `request_id`, `tool_call_id`, `title`, `question`, `suggested_answers` | Sub-agent needs user clarification | | `file_changed` | `path` | A tool wrote to a file | | `open_file` | `path` | Agent-driven file open: the file viewer supports Markdown, source code, plain text, raster images (PNG/JPG/GIF/WebP/…), SVG, PDF, LaTeX (`.tex` / `.latex` — compiled to PDF automatically on the server), and HTML (`.html` / `.htm` — rendered live in an origin-isolated `