This document describes the existing web client located at /home/gmikcon/Projects/navi-1/client/. It serves as a reference baseline for building the new client.
| Concern | Solution |
|---|---|
| Language | Vanilla JavaScript (ES modules, no transpilation) |
| Build | None — served as static files directly |
| Markdown rendering | marked.js via esm.sh CDN |
| Syntax highlighting | highlight.js via esm.sh CDN |
| Styling | Plain CSS with custom properties (no framework) |
| Server | Served from GET /static/** by the FastAPI backend |
No bundler, no TypeScript, no framework. Everything is a native browser API or an ES module imported from CDN.
client/
├── index.html # Single-page app shell
├── style.css # All styles (968 lines, dark theme)
└── js/
├── app.js # Main entry point — state, routing, WS event dispatch
├── chat.js # DOM component builders (bubbles, cards, etc.)
├── sidebar.js # Sidebar rendering (profiles, session list)
├── ws.js # WebSocket wrapper class
└── api.js # REST API wrapper
Two-column layout (no responsive/mobile breakpoints in the existing client):
┌──────────────────────────────────────────────────────┐ │ Sidebar (300px, fixed) │ Chat area (flex: 1) │ │ │ │ │ [Profile selector ▼] │ ┌──── Chat header ─────┐ │ │ │ │ Session name | Profile│ │ │ [+ New session] │ └───────────────────────┘ │ │ │ │ │ Session list: │ Messages: │ │ ● Session A [🗑] │ (scrollable container) │ │ ● Session B [🗑] │ │ │ ... │ │ │ │ ┌──── Input bar ────────┐ │ │ │ │ [📎] [textarea] [Send]│ │ │ │ │ [file previews...] │ │ │ │ └───────────────────────┘ │ └──────────────────────────────────────────────────────┘
<select> populated from GET /agents/profiles. Selecting a profile sets state.selectedProfileId and is used when creating a new session.state.selectedProfileId, then switches to it.renderSessions(). Each item shows session preview text and a delete icon. Click → load session. Pinned sessions appear first (server-sorted).Shows the current session name (generated from session_id) and the active profile name.
Scrollable <div id="messages">. All message components are appended here via DOM helpers in chat.js. Auto-scrolls to bottom on new content.
<textarea> for text input. Sends on Enter (Shift+Enter for newline).📎) → hidden <input type="file" multiple> → uploads via POST /sessions/{id}/files.paste event, reads image files via FileReader, converts to base64.state.streaming = true).app.js)All mutable state is held in a single object:
const state = {
profiles: [], // AgentProfile[] from GET /agents/profiles
sessions: [], // Session[] from GET /sessions
currentId: null, // Active session_id
ws: null, // WsClient instance
streaming: false, // True while agent is running
pendingImages: [], // Base64 strings waiting to be sent
pendingFiles: [], // File objects from POST /sessions/{id}/files
uploadCount: 0, // Tracks in-flight uploads
pendingToolCard: null, // DOM element for the in-progress tool call
pendingSubStep: null, // DOM element for the in-progress subagent step
selectedProfileId: null, // Profile chosen in the selector (for new sessions)
};
Draft persistence: the textarea content is saved to localStorage keyed by session_id and restored when the session is reloaded.
Session ID is stored in the URL hash: http://host/#<session_id>.
location.hash. If valid → load that session. Else → load most recent from GET /sessions.location.hash without page reload.hashchange event triggers session switch.No server-side routing — the server always serves index.html from /.
One WebSocket connection per active session. Managed by WsClient (ws.js):
class WsClient {
connect(sessionId) // Opens WS, sets up handlers
send(payload) // JSON.stringify + ws.send
disconnect() // ws.close
get ready() // ws.readyState === WebSocket.OPEN
}
Events dispatched in app.js in a switch on event.type:
| Event | Handler |
|---|---|
stream_start |
Disable input, state.streaming = true, create streaming bubble |
thinking_delta |
Accumulate delta in thinking card, auto-scroll |
thinking_end |
Collapse thinking card, mark as done |
turn_thinking |
Render a collapsible "turn thinking" card (complete block) |
plan_ready |
Render a collapsible plan card |
tool_started |
Create pending tool card (spinner) or subagent step |
tool_call |
Finalize tool card with result and success/failure indicator |
stream_delta |
Append delta to streaming bubble, parse markdown, auto-scroll |
stream_end |
Finalize bubble content, re-enable input, update token indicator |
stream_stopped |
Re-enable input |
profile_switched |
Update chat header profile indicator |
context_compressed |
Append compression notice to messages |
error |
Append error message bubble |
chat.js)All components are built as DOM nodes and appended to #messages.
Plain text (no markdown). Shows role label "You". Optionally contains:
Created on stream_start. Text content is accumulated from stream_delta events and rendered with marked.parse() on each delta. On stream_end the bubble is finalized: markdown re-rendered once cleanly, code blocks highlighted with hljs.highlightAll().
appendThinkingCard)Collapsible <details> element. Created on first thinking_delta. Delta text is accumulated as plain text. On thinking_end the card is auto-closed (collapsed). User can re-open it at any time.
appendTurnThinkingCard)Same visual as thinking card but arrives as a single complete block (turn_thinking event). Collapsed by default. is_subagent: true → nested under the parent tool card.
appendPlanCard)Collapsible <details> element. Shows the plan text from plan_ready event, rendered as markdown. Collapsed by default after a brief open period (or immediately, depending on UX preference).
appendPendingToolCard → finalizeToolCard)Two-phase render:
tool_started → create card with tool name, arguments (JSON), and a spinner.tool_call → update card: remove spinner, show result, color-code success/failure.Collapsed by default, expandable to see arguments and result. Tool-specific icon shown (dict of tool name → emoji/icon in chat.js).
appendSubagentStep)Nested card inside a spawn_agent tool card. Created on tool_started with is_subagent: true. Same finalization flow as a regular tool card. Multiple subagent steps stack vertically inside the parent card.
appendSummaryCard)Rendered when a message has is_summary: true in the history. Shows "[Context compressed — summary]" header with the summary text collapsed inside.
appendCompressionNotice)Inline notice: "Context compressed (42 → 12 messages)". Appended after stream_end when context_compressed event arrives.
chat.js contains a dictionary mapping tool names to display icons:
const TOOL_ICONS = {
web_search: "🔍",
filesystem: "📁",
code_exec: "⚙️",
terminal: "💻",
ssh_exec: "🖥️",
spawn_agent:"🤖",
// ... etc
};
Unknown tools fall back to a generic icon.
FileReader.readAsDataURL() → base64 string added to state.pendingImages.images array of the WebSocket message.data:image/...;base64, prefix automatically.POST /sessions/{id}/files (multipart).{ name, path, size, content_type }.state.pendingFiles. Preview pill shown in input area.files array of the WebSocket message.Both pending images and files are cleared after send.
POST /sessions with { profile_id } → get session_id. Add to session list, switch to it, update URL hash, open WebSocket.
GET /sessions/{id} → render full message history by iterating messages[] and calling the appropriate chat.js helper for each message role/type. Then open WebSocket.
DELETE /sessions/{id}. Remove from sidebar. If it was the active session, switch to most recent remaining session (or show empty state).
PATCH /sessions/{id}/pin with { pinned: true/false }. Re-render session list.
POST /sessions/{id}/stop via fetch() (not WebSocket). Called when user clicks the stop button shown during streaming.
The agent can switch profiles via the switch_profile tool. When the client receives profile_switched:
stream_end includes context_tokens and max_context_tokens. The existing client does not render a visible progress bar, but these values are available for use. Compression fires automatically at ≥80% fill and emits context_compressed.
--bg-primary: #1a1a1a /* Page background */ --bg-secondary: #252525 /* Sidebar, cards */ --bg-tertiary: #2d2d2d /* Input field, hover states */ --bg-elevated: #333333 /* Dropdowns, tooltips */ --text-primary: #e8e8e8 --text-secondary: #a0a0a0 --text-muted: #666666 --accent: #6b8cff /* Primary action color */ --accent-hover: #5a7ae8 --success: #4caf50 --error: #f44336 --warning: #ff9800 --border: #383838 --border-focus: #6b8cff --radius-sm: 6px --radius-md: 12px --radius-lg: 16px
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)"JetBrains Mono", "Fira Code", Consolas, monospace--bg-elevated background, rounded corners--radius-lg rounding--bg-secondary--accent (pending), --success or --error (completed)--bg-tertiary)thinking_endCustom thin scrollbar via ::-webkit-scrollbar (4px wide, rounded, --bg-tertiary track, --border thumb).
marked.js (latest, via esm.sh CDN)highlight.js with github-dark theme1. Fetch GET /agents/profiles → populate profile selector
2. Fetch GET /sessions → populate session list
3. Read location.hash
├── Hash present & matches a known session_id → load that session
└── No hash or unknown → load sessions[0] (most recent)
4. loadSession(id):
├── GET /sessions/{id} → render message history
├── Update location.hash to #id
├── Connect WebSocket ws://host/ws/sessions/{id}
└── Restore draft from localStorage