Mission Control
Mission Control (implementation/maxq-orbit-agent-webapp) is the operator
console for the Orbit agent. It is where you
submit requests to the agent’s team lead, watch the plan fan out into tasks,
follow each task’s live execution log message by message, and inspect
afterwards what the agent actually did — down to every tool call, token count,
and dollar spent.
It is a pure client of the agent’s HTTP API: it owns no data of its own. Everything it shows — the task list, log lines, request summaries, chat sessions — is fetched from or streamed by the agent. It never touches the solution repository.
Tech stack
Versions from codebase/package.json:
| Dependency | Version | Role |
|---|---|---|
next | 16.2.4 | App Router; dev and prod both serve on port 3001 |
react / react-dom | 19.2.4 | UI |
typescript | ^5 | Language |
tailwindcss | ^4 | Styling (via @tailwindcss/postcss) |
next-mdx-remote | ^6.0.0 | Server-side Markdown/MDX rendering of task brief/plan/summary docs |
remark-gfm | ^4.0.1 | GitHub-flavoured Markdown in those docs |
@dagrejs/dagre | ^3.0.0 | Layout engine for the workflow timeline DAG |
d3 | ^7.9.0 | Edge path rendering (d3.line().curve(d3.curveBasis)) in the timeline |
Connecting to the agent
All agent access goes through src/lib/agent-client.ts, which resolves two
base URLs — one for the browser, one for the Next.js server:
| Constant | Resolution order | Used by |
|---|---|---|
AGENT_URL (browser) | window.__AGENT_URL__ → NEXT_PUBLIC_AGENT_URL → http://localhost:3000 | All client-side fetches, EventSource subscriptions |
AGENT_URL_SERVER | process.env.AGENT_URL → AGENT_URL_INTERNAL → the browser default | Server components / route handlers (task-details-server.ts) |
Why two mechanisms exist:
- Local dev / docker-compose — the browser reads the build-time
NEXT_PUBLIC_AGENT_URL(compose defaults it tohttp://localhost:${AGENT_PORT:-3010}, reachable from the host) and calls the agent directly. Server-side fetches inside the container can’t uselocalhost, so compose setsAGENT_URL_INTERNAL: http://orbit-agent:3000(the docker-network service name). - Azure (per-solution deploys) — since 2026-07-03 the agent is
internal-only (ACA internal ingress, no Cloudflare subdomain), so the
browser cannot reach it at all. Mission Control therefore ships a
same-origin streaming proxy route,
src/app/agent/[...path]/route.ts, which forwards fetch and SSE to the agent’s in-env address. The Mission Control container (mc-<hash>) receives that address at runtime as theAGENT_URLenv var (http://agent-<h>— the app name resolves via the environment’s internal DNS);NEXT_PUBLIC_*variables can’t carry it because they are inlined atnext buildwhile all solutions share one image. The root layout (src/app/layout.tsx, markedexport const dynamic = "force-dynamic") injects the literal prefixwindow.__AGENT_URL__ = "/agent"as a classic inline script — it runs before the deferred app bundles, soagent-client.tssees it on first evaluation and every browser call goes to the same origin. Server code readsprocess.env.AGENT_URLdirectly and talks to the agent in-env. WhenAGENT_URLis unset (local dev), the injected value is empty and everything falls back to the build-time variable — the proxy sits idle.
(In local dev the browser skips the proxy and hits the agent directly via
NEXT_PUBLIC_AGENT_URL.)
An edge guard in src/proxy.ts (Next 16’s renamed middleware) enforces
Cloudflare-only access in Azure: when EDGE_SHARED_SECRET is set, requests
that arrive without the secret header Cloudflare injects (default name
x-orbit-edge-secret) are rejected with 403, closing off the naked
*.azurecontainerapps.io origin. Since 2026-07-03 the edge lockdown covers
only the two public apps (Mission Control and the reader) — the agent is
internal and needs no edge secret. Local dev doesn’t set the secret, so the
app stays open.
Screens
The app shell (src/components/shell/mission-shell.tsx) wraps every page with
a collapsible left rail and a header. The header shows the solution name and
customer (fetched from the agent’s GET /solution) plus a global
live/offline indicator driven by the event stream. The rail links to the
four console pages and has a bottom “Orbit App” link that opens the read-only
Orbit webapp in a named browser tab (suffixed with the solution id so two
solutions open side by side don’t steal each other’s tab).
| Route | Page | What it shows |
|---|---|---|
/ | — | Redirects to /requests |
/requests | Requests | Master/detail: request list (rolled up from tasks) + composer / request detail |
/tasks | Tasks | Flat task history from .orbit/tasklist.json, updated live |
/tasks/[id]/log | Task detail | Tabs: Overview · Plan · Activities · Summary — the deep-inspection view |
/chat | Chat | Streaming chat sessions with the agent (list, rename, delete) |
/events | Events | Raw feed of the agent’s SSE event stream (last 500 rows) |
Requests
A request is a prompt to the agent’s team lead; the lead plans it and
dispatches one or more tasks to worker agents, which the agent executes
strictly one at a time. The page derives its request list by grouping the
agent’s task list by requestId — each row shows the first task’s title, a
task count, a done/total counter, and a roll-up status pill (Pending /
Running / Completed / Failed / Mixed).
The right pane is either the composer (a textarea that POSTs
/requests/tasks) or the request detail: the status mix, the task list
(each linking to /tasks/[id]/log), and — once every task has finished — the
RequestSummaryPanel, which fetches GET /requests/:id/summary. The agent
returns 404 there until the request is finalised and summary.yaml is
written, so the panel renders nothing for in-flight requests.
Tasks
A flat, ordered list of every task the agent has run, rendered as
TaskCards. Loaded once via GET /requests/tasks, then patched in place from
the live event stream (task.created/started/completed/failed/summary), with
a live/offline badge in the header.
Chat
A session-based chat console against the agent’s /requests/chats endpoints:
list sessions, resume one, rename (PATCH) or delete (DELETE) it. Sending a
message POSTs to /requests/chats with Accept: text/event-stream and
consumes the reply via a manual SSE parser (src/lib/sse.ts) — the
browser’s EventSource cannot POST — yielding chat.session, chat.delta,
chat.done, and chat.error frames.
Events
A debugging view: subscribes to /events/subscribe and prints every event as
a row, newest first, capped at 500. The last event ID is persisted in
localStorage so a reload replays what was missed.
The task detail page
/tasks/[id]/log is the heart of Mission Control. The server component
(page.tsx) fetches GET /requests/tasks/:id/details server-side and
compiles the task’s three documents (brief task.md, result/plan.md,
result/summary.md) to MDX; the client shell (task-log-shell.tsx) provides
four tabs:
- Overview — a metadata panel (id, request, assignee, status, timestamps, message count, duration, cost) beside the rendered brief.
- Plan / Summary — the rendered plan and summary documents.
- Activities — the live execution view, a resizable split:
Left: the workflow timeline (agent-timeline.tsx). src/lib/timeline.ts
walks the log stream and derives a DAG: the main agent is split into segments
at every Task tool spawn, each spawn becomes a sub-agent node, and edges
record spawn → sub and sub → next-main-segment flow. Dagre lays the graph out;
d3 draws the curved edges; node/edge styling reflects run status, and
sub-agents get stable persona colours. Clicking a node pins it; otherwise
the view auto-follows the live node (“Follow live ↓” resumes auto-follow).
Right: the messages panel (agent-messages-panel.tsx) shows the log lines
bucketed under the selected node, with auto-scroll that disengages when you
scroll up. Two view modes (persisted in localStorage): logical merges
each tool result into its originating tool_use card (and hides the duplicate
standalone result), raw shows the stream as-is.
Side: the todo panel — the latest TodoWrite payload for the selected
node, rendered as a checklist. Main-agent segments share one trajectory, so
any main node shows the latest todos across all main segments; sub-agents only
see their own.
The header aggregates token usage and the SDK-reported total cost across the
whole log, shows a live/offline badge, and — for failed or cancelled tasks —
offers Continue task (POST /requests/tasks/:id/continue), which resumes
the agent from its last SDK session into the same log.
The message rendering pipeline
Every log line is rendered by src/components/log-line.tsx, a deliberately
layered pipeline. LogLine first classifies the message (assistant / tool
result / system / result), stamps a timestamp + per-message token usage, and
filters out Task tool calls entirely — spawns are already nodes in the
timeline, so rendering them as messages would duplicate the navigation. The
remaining content blocks flow through five layers, in order:
decodeEscapes(s)— defensive first pass on every string value. Some tool inputs arrive double-encoded (pre-stringified by the agent), so literal two-character\n/\r/\t/\"/\\sequences are decoded in a single regex pass with a callback — never via a sentinel- character round-trip, which a past bug proved unsafe.prettyJson(value)— a custom pretty-printer, notJSON.stringify. String values containing newlines, tabs, quotes, or backslashes are emitted as backtick-delimited blocks in which real newlines render as real newlines; the only escapes inside a block are\\and\`.highlightJson(text)— tokenizes the pretty-printed output and colours it with theme CSS variables:--accentfor keys,--okfor strings (both quoted and backtick blocks),--accent-2for numbers/booleans,--fg-mutedfor null. Its regex explicitly matches backtick blocks with escaped backticks inside.renderCustomToolInput(block)— a dispatcher that returns a domain-specific view for known tool names andnullotherwise, in which case the generic highlighted-JSON fallback renders the raw input.ExpandablePre— wraps everyprewith click-to-expand. The expand affordance (bottom gradient + “Click to expand ▾”) only appears when the content actually overflows its max height — re-measured on every render so streaming content flips it on as it grows. Clicking opens a modal (closes on backdrop, Escape, or ✕) with a copy button that uses an explicitcopyTextprop rather than the DOM’stextContent, because aprewhose children aredivrows loses inter-line newlines and would include UI-only prefix markers.
Per-tool renderers
| Tool | View | Copy text contract |
|---|---|---|
Bash | Command with $ prompt marker + optional description | Raw command (no $) |
Read | File path + offset/limit/pages | — |
Glob / Grep | Pattern + path/glob/output mode | — |
Write | File path + content block | The file content |
Edit | Old/new string diff (with +/- markers) | new_string |
TodoWrite | Checklist with ✓ / ◐ / ○ status icons | — |
Skill | Skill name + args | — |
| anything else | Highlighted-JSON fallback | — |
Adding a renderer means adding one case to the dispatcher and one small view component mirroring the Bash/Write style. Renderer priority was informed by measured tool frequency in real task logs (Read and Write dominate, followed by Bash and Glob).
Design contracts
These invariants are documented in project memory and protect against well-intentioned “simplifications”:
- Multi-line strings must never display as literal
\n— that is the entire point of the backtick-block format; reverting toJSON.stringifyis a regression. - Backtick blocks must survive backticks in the payload. Real YAML
payloads contain inline code spans regularly; the fix is escaping
`inside the block, not falling back to JSON escaping (a past bug made whole payloads revert to\n-escaped form). - Tool renderers own the copy-text contract — users must paste usable
text, not
$ npm installwith the prompt marker baked in. - Task spawns are filtered, not rendered — they live in the timeline.
Live updates
Mission Control uses Server-Sent Events, not polling, over three channels:
- The global event stream —
subscribeEvents()opens anEventSourceonGET /events/subscribeand listens for typed events:request.accepted/rejected/planning/in_progress/done/failed,task.created/started/progress/completed/failed/summary,chat.delta/done, andagents.memory.updated. Every page persists the last event ID inlocalStorage(keymaxq-orbit-events-last-id) and passes it as?since=on the next subscribe for an explicit replay;EventSourcealso sendsLast-Event-IDautomatically on reconnect. The Requests and Tasks pages patch their lists from these events; the header’s live badge and the Events page are driven by the same stream. - The per-task log stream —
streamTaskLog()opens anEventSourceonGET /requests/tasks/:id/log, receiving onelog.lineevent per appended.jsonlentry. Each (re)subscribe replays the file from offset 0, so a page reload rebuilds the full log and then tails it live. - Chat streaming —
POST /requests/chatsconsumed viafetchand the manual SSE parser described above.
The task detail page combines channels 1 and 2 deliberately: the terminal
result log line is streamed before the agent persists the task’s final
status, so a refetch triggered by it could read a stale in_progress. The
global events (task.completed / task.failed) fire after the status is
written and are therefore the authoritative driver for the header’s status
badge.
Mission Control keeps almost all view state client-side and persisted in
localStorage — the timeline splitter width, raw/logical view mode, active
tab, list sort directions, rail collapse, and the active chat session all
survive reloads without any server round-trip.