The Beidou web UI is a Svelte 5 + Vite + TypeScript single-page application that lives under
beidou/web/frontend/. It is a pure consumer of the JSONL event log: it reads events
delivered by the WebSocket relay and derives all state by reduction; it never writes to
the JSONL file or to the SQLite database.
PR 1 introduced a persistent left spine (240px) and a shared topbar (44px). The remaining viewport is occupied by a workspace chosen via the spine. Routes are bookmarkable:
| Route | Workspace | Status |
|---|---|---|
/ |
task list (home) | shipped |
/tasks/{id}/agents |
preserved 3-pane (TaskOverview / PinnedAgentPanel / TeamTree) |
shipped (PR 1) |
/tasks/{id}/timeline |
milestone event timeline | shipped (PR 3) |
/tasks/{id}/tools |
primitive card explorer | shipped (PR 2) |
/tasks/{id}/stats |
dashboard | disabled; P1 deferred |
/tasks/{id}/overview |
summary tiles | disabled; P1 deferred |
/tasks/{id}/questions |
Q&A history | disabled; P1 deferred |
/tasks/{id} (legacy) |
302 → /tasks/{id}/agents |
redirect |
┌─ TopBar ─ ◆ BEIDOU · tsk_abc123 · elapsed 14:25 · 7 agents · cost $0.42 · ⚠ 1 question ─┐
│ ┌─ Spine (240px) ──┐ ┌────────────── Workspace area ─────────────────────────────────┐ │
│ │ TASKS │ │ /agents (preserved 3-pane): │ │
│ │ ○ tsk_abc123 │ │ ┌──────────────┬──────────────────────────┬──────────────┐ │ │
│ │ ○ tsk_def456 │ │ │ TaskOverview │ Question Banner + │ TeamTree │ │ │
│ │ │ │ │ + Activity │ PinnedAgent stream │ │ │ │
│ │ WORKSPACE │ │ │ │ + Composer │ │ │ │
│ │ ▤ Agents ● │ │ └──────────────┴──────────────────────────┴──────────────┘ │ │
│ │ ≡ Timeline │ │ /timeline = plan-task milestone tree (PR 3) │ │
│ │ ▦ Tools │ │ /tools = primitive card explorer (PR 2) │ │
│ │ ◔ Stats ·P1 │ │ │ │
│ │ ○ Overview·P1 │ │ │ │
│ │ ○ Q&A ·P1 │ │ │ │
│ └──────────────────┘ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
components/TopBar.svelte— brand glyph (◆ inreviewcyan), task_id (mono), elapsed (HH:MM:SS, live-updating), agents-alive count, cost (tabular-nums), amber pending-question pulse chip (clicking the chip jumps to/agentsfor that task).components/spine/Spine.svelte— 240px left rail composingSpineTaskList(top group) andWorkspaceNav(bottom group). The active workspace gets a cyan left border + tinted background; disabled·P1items are visually muted and non-interactive.components/Layout.svelte— 2-column shell (spine | workspace area). Used only while a task is selected. The home view (/) renders the task list directly under the topbar without the spine.components/workspaces/AgentsWorkspace.svelte— preserved 3-pane verbatim (280pxTaskOverviewPanel/ fluidPinnedAgentPanel/320pxTeamTreePanel). No internal change toPinnedAgentPanel,TeamTree,QuestionBanner,TaskOverview, orGlobalActivityFeedin PR 1.components/workspaces/ToolsWorkspace.svelte—/toolsworkspace (PR 2). Aggregates everytoolstream item across all agents whose name resolves to a known primitive (lib/primitives.ts), sorts by ts, and renders each viacomponents/primitives/PrimitiveCard.svelte. Filter pills cover the five categories (coordination / lifecycle / plan / review / human), plus an agent dropdown and an errors-only toggle. A category legend strip below the filters documents the classification rule for each.components/primitives/— one Svelte component per primitive (13 total) sharingPCardShell.svelte(top accent stripe, head with category label + badges + ts/duration, body grid).FlowStrip.svelterenders the prominent FROM/TO band onsend_message,spawn_agent,terminate_child, andask_user.AgentChip.svelterenders the monogram + id + role chip used in caller fields.PrimitiveCard.svelteis a thin dispatcher that routes aToolStreamItemto the matching component by bare tool name. The same components are reused inline bycomponents/middle/stream/ToolCard.svelte, which delegates toPrimitiveCardwheneverlib/primitives.ts:isPrimitive()matches and falls back to its compact collapsible card for non-primitive tools (Bash, Read, …).components/workspaces/TimelineWorkspace.svelte—/timelineworkspace (PR 3). ProjectsReducerState.timelineEvents(a flat, append-only feed of milestone events captured by the reducer) into a tree of plan-task sections derived inlib/timeline.ts. Setup section opens at the run start and ends at the firsttask_spawnedof the root plan; each declared root-plan task becomes its own section bounded by the spawn and the matchingtask_done/task_failedclosing event; nesteddeclare_plancalls open L1 sub-plan-task sections recursively (60px indent + dashed left rail). Default expansion: 3 most-active sub-tasks per parent; rest collapse into a "▸ N more sub-plan tasks" summary. Pending plan tasks (deps unresolved) render as collapsed pending stubs. Filter pills surface five categories (primitive / lifecycle / plan / question / error); a "Show all events" toggle reveals the firehose (turn.usage, tool_called, status, send_message, agent_input, completion.*, liveness.*).components/workspaces/PlaceholderWorkspace.svelte— parameterized stub used by any P1-deferred workspace reachable by URL.
lib/router.svelte.ts parses hash routes:
/tasks/{id}/{workspace}→task_workspace(App dispatches by workspace name)./tasks/{id}→task(App$effectrewrites to/tasks/{id}/agents)./agents/{id}→agent(legacy info screen — agent-only deep links are no longer top-level; pin via the team tree inside/agents)./→home.
The shell pulls in JetBrains Mono for IDs, durations, and counts (font-mono / .tabnum).
Density CSS variables (--row-h, --pad-x, --pad-y, --lh) default to comfortable
(/agents); future workspaces (/timeline, /tools, /stats) opt into Bloomberg-dense
via data-density="dense".
When a pending question is surfaced in the middle panel, QuestionBanner.svelte renders
1..4 sub-questions from the raw questions array. Each sub-question is displayed with:
- A chip-style header (the
headerfield, ≤12 chars) labelling the sub-question. - The question text (
questionfield) as the prompt. - An input control chosen by the sub-question's shape:
options.length == 0→<textarea>(free-text only).multiSelect == false→ radio buttons (single-select) with an implicit "Other" option that reveals a free-text input when selected.multiSelect == true→ checkboxes (multi-select).
The submit button is disabled until every sub-question has a complete answer (a choice selected or, for free-text controls, a non-empty string typed).
After submission, /api/questions/{qid}/answer accepts
{answers: [{selected_labels, text}, ...]} and routes the response through
QuestionBroker.resolve_answer(). The broker computes answer_text (one rendered line per
sub-question, joined by newlines), persists the answer to SQLite, and emits a
question_answered event carrying both answers and answer_text.
The web reducer handles question_answered by synthesizing a message_in stream item on
the asker agent's stream: {kind: "message_in", from: "user", from_is_user: true, content: answer_text, message_id: qid}. This creates a permanent inbound chat bubble in
the asker's conversation trace — making the human answer visible in context, without
inventing a fake inbox delivery event.
Defined in beidou/web/frontend/src/lib/types.ts:
type StreamItem =
| { kind:'text'; ts; message_id?; text; stop_reason? }
| { kind:'tool'; ts; tool_use_id; name; input?; duration_ms; is_error; error_reason?; expanded? }
| { kind:'message_in'; ts; from; from_is_user; content; message_id }
| { kind:'message_out'; ts; to; content; message_id }
| { kind:'turn_divider';ts; in_tok; out_tok; stop_reason?; model? }The per-agent stream (AgentState._stream) is capped at 500 items. To ensure late
tool_result events can still patch their paired tool_called item even after the stream
has been trimmed, the reducer maintains a per-agent _pendingTools: Map<tool_use_id, ToolStreamItem>
that is never trimmed; the tool item in _stream and the _pendingTools entry both
reference the same object, so patching either updates both.
~/.beidou/events/{task_id}.jsonl
│
▼ tailed by WebSocket relay
/ws/tasks/{task_id}?since=cursor (envelope: {type, events, cursor})
│
▼ applyEvent() reducer
Svelte 5 rune-backed stores (events.svelte.ts, questions.svelte.ts, ui.svelte.ts, …)
│
▼
Components (AgentStream, TeamTree, GlobalActivityFeed, …)
Events arrive in a WebSocket envelope {type: "events"|"resumed", events: BeidouEvent[], cursor: number}.
On reconnect the client sends ?since=<cursor> to replay missed events from the last known
position. State is derived by replaying all events in order through the applyEvent reducer
(beidou/web/frontend/src/reducer/reduce.ts).
The backend uses a strict ts > since filter. Two events sharing the same ts at the replay
boundary may be silently dropped on reconnect. Additionally, an idle task never emits a
resumed envelope (it only fires after a strictly newer event arrives), so the connection
pill can stay in replaying state indefinitely. The UI applies an idle-timeout fallback: if
the WebSocket has been silent for 3 seconds after the cursor appears to have caught up, it
flips replaying → live without waiting for a resumed envelope. Hard-refresh the tab
if you suspect event drift — this replays the full JSONL from cursor=0.
Agent text is rendered via a single-pass pipeline in beidou/web/frontend/src/lib/markdown.ts:
markedwith a customrenderer.code()that runshighlight.jsinside the markdown pass (no second HTML post-pass).DOMPurify.sanitize()strips<script>,on*handlers, andjavascript:URIs while preservingclassattributes so highlight.js spans survive.
Output is injected via Svelte's {@html} directive. The github-dark highlight.js theme CSS
is bundled via src/main.ts import.
cd beidou/web/frontend
npm install # first time only
npm run rebuild # = scripts/clean-static.sh && vite build
npm run rebuild runs scripts/clean-static.sh before Vite to git rm stale hashed assets
from the previous build, preventing orphaned filenames accumulating in the repository.
Soft target: ≤ 250 kB gzipped JS. Measure after every build:
npm run build && npx vite-bundle-visualizer
If highlight.js or Tailwind pulls the bundle over budget, revisit the language allowlist or purge configuration before merging.
The compiled output under beidou/web/static/ is committed to git. pip install -e .
does not invoke npm; the wheel ships prebuilt assets declared in pyproject.toml
[tool.setuptools.package-data] under beidou = ["web/static/*", "web/static/assets/*", ...].
There is no CI gate asserting the bundle diff (Node/npm/OS variance produces nondeterministic
hashes). Reviewers eyeball the diff for matched add/remove pairs in beidou/web/static/assets/
to catch forgotten rebuilds or stale orphan files.