Skip to Content
ApplicationsMission Control

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:

DependencyVersionRole
next16.2.4App Router; dev and prod both serve on port 3001
react / react-dom19.2.4UI
typescript^5Language
tailwindcss^4Styling (via @tailwindcss/postcss)
next-mdx-remote^6.0.0Server-side Markdown/MDX rendering of task brief/plan/summary docs
remark-gfm^4.0.1GitHub-flavoured Markdown in those docs
@dagrejs/dagre^3.0.0Layout engine for the workflow timeline DAG
d3^7.9.0Edge 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:

ConstantResolution orderUsed by
AGENT_URL (browser)window.__AGENT_URL__NEXT_PUBLIC_AGENT_URLhttp://localhost:3000All client-side fetches, EventSource subscriptions
AGENT_URL_SERVERprocess.env.AGENT_URLAGENT_URL_INTERNAL → the browser defaultServer 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 to http://localhost:${AGENT_PORT:-3010}, reachable from the host) and calls the agent directly. Server-side fetches inside the container can’t use localhost, so compose sets AGENT_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 the AGENT_URL env 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 at next build while all solutions share one image. The root layout (src/app/layout.tsx, marked export const dynamic = "force-dynamic") injects the literal prefix window.__AGENT_URL__ = "/agent" as a classic inline script — it runs before the deferred app bundles, so agent-client.ts sees it on first evaluation and every browser call goes to the same origin. Server code reads process.env.AGENT_URL directly and talks to the agent in-env. When AGENT_URL is 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).

RoutePageWhat it shows
/Redirects to /requests
/requestsRequestsMaster/detail: request list (rolled up from tasks) + composer / request detail
/tasksTasksFlat task history from .orbit/tasklist.json, updated live
/tasks/[id]/logTask detailTabs: Overview · Plan · Activities · Summary — the deep-inspection view
/chatChatStreaming chat sessions with the agent (list, rename, delete)
/eventsEventsRaw 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:

  1. 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.
  2. prettyJson(value) — a custom pretty-printer, not JSON.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 \`.
  3. highlightJson(text) — tokenizes the pretty-printed output and colours it with theme CSS variables: --accent for keys, --ok for strings (both quoted and backtick blocks), --accent-2 for numbers/booleans, --fg-muted for null. Its regex explicitly matches backtick blocks with escaped backticks inside.
  4. renderCustomToolInput(block) — a dispatcher that returns a domain-specific view for known tool names and null otherwise, in which case the generic highlighted-JSON fallback renders the raw input.
  5. ExpandablePre — wraps every pre with 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 explicit copyText prop rather than the DOM’s textContent, because a pre whose children are div rows loses inter-line newlines and would include UI-only prefix markers.

Per-tool renderers

ToolViewCopy text contract
BashCommand with $ prompt marker + optional descriptionRaw command (no $)
ReadFile path + offset/limit/pages
Glob / GrepPattern + path/glob/output mode
WriteFile path + content blockThe file content
EditOld/new string diff (with +/- markers)new_string
TodoWriteChecklist with ✓ / ◐ / ○ status icons
SkillSkill name + args
anything elseHighlighted-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 to JSON.stringify is 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 install with 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:

  1. The global event streamsubscribeEvents() opens an EventSource on GET /events/subscribe and listens for typed events: request.accepted/rejected/planning/in_progress/done/failed, task.created/started/progress/completed/failed/summary, chat.delta/done, and agents.memory.updated. Every page persists the last event ID in localStorage (key maxq-orbit-events-last-id) and passes it as ?since= on the next subscribe for an explicit replay; EventSource also sends Last-Event-ID automatically 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.
  2. The per-task log streamstreamTaskLog() opens an EventSource on GET /requests/tasks/:id/log, receiving one log.line event per appended .jsonl entry. Each (re)subscribe replays the file from offset 0, so a page reload rebuilds the full log and then tails it live.
  3. Chat streamingPOST /requests/chats consumed via fetch and 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.