15 KiB
Orchestrator dashboard, in-chat task widget, and secrets widgets — design
Date: 2026-06-05. Companion to
orchestrator-buildout-followups.md.
This is a code-grounded, intentionally small design for three connected gaps the goal hook calls out, in priority order:
- Orchestrator view should read as a dashboard of ongoing tasks — quick active/finished/needs-attention counts at the top, a minimal card list underneath, click a card to drill into the existing full inspector.
- Task creation should drop a live
TaskWidgetinto the chat that shows status and is clickable. The chat conversation continues; the agent keeps task context; the orchestrator view's action bar stays view-dependent on the currently-selected task. - Sensitive-request widgets in chat (form / OAuth / secret) must actually work and be tested, with no secret material echoed to chat.
Scope is intentionally narrow. The existing
OrchestratorWorkbench.tsx (4142 LOC) is
mature — rail, inspector, action bar, timeline, operator drawer, plan editor.
We do not redesign it. We add a thin glance strip, fix the gaps, and lock
behavior in tests.
What exists today (ground truth)
OrchestratorWorkbenchrenders/orchestrator. Header has Pause All / Resume All / New Task; a filter dropdown shows status + count; rail rendersTaskCardListcards. Selecting a card swaps the rail for a full-pane task room with timeline + inspector (data-testid="orchestrator-inspector"). Inspector action bar is already view-dependent: validating → Approve/Reject, archived → Reopen, active → Pause/Resume, plus Fork/Restart/Add Agent.MessageContent.tsxdispatches 7 segment kinds:text,config,ui-spec,choice,followups,form,permission,analysis-xml. There is notask-widgetkind. A[TASK:id]title[/TASK]block in an assistant message renders as plain text today.SensitiveRequestBlock(inMessageContent.tsx) renders inline-secret form widgets driven bymessage.secretRequest. It is tested (MessageContent.sensitive-request.test.tsx). It supportsform.kind === "secret"only; OAuth is not wired —owner-app-inline-adapter.tsexplicitly rejects any non-secret kind.task-coordinator-gui-interactions.spec.tscovers list, search, open detail, archive, reopen.orchestrator-gui-workbench.spec.tscovers the rich rail and inspector controls (priority change, restart-with-plan, add agent, pause/resume, retry/rerun). Neither covers a chat task widget or any sensitive-request widget.
Design
1. OrchestratorGlanceStrip — at-a-glance dashboard tile
A single pinned row above the rail, four count tiles + the existing usage chip.
No new state — derived from the same /api/orchestrator/status payload the
header reads. Each tile is a button that sets the filter and scrolls the rail
to the top.
┌─────────────────────────────────────────────────────────────────┐
│ ● 3 Active ◐ 1 Validating ⚠ 0 Blocked ✓ 12 Done 12.3K · $0.42 │
└─────────────────────────────────────────────────────────────────┘
Rules:
- One row, fixed height. ~32px. No subtitle text under counts.
- Color = state, not decoration.
okfor Active,accentfor Validating,warnfor Blocked,mutedfor Done. - Zero =
mutedtext + de-emphasized tile. Zero counts never animate. - Click filters. Click "Active" → filter "active". Click "Blocked" →
filter "blocked" (rolls up
waiting_on_userper existing status fan-out). - Hidden when there are zero tasks total — the empty state already covers this case.
Test ids: orchestrator-glance, orchestrator-glance-active,
orchestrator-glance-validating, orchestrator-glance-blocked,
orchestrator-glance-done.
Implementation: new component OrchestratorGlanceStrip.tsx rendered in
OrchestratorWorkbench.tsx directly above the rail. It reads
taskCount/activeTaskCount/blockedTaskCount/validatingTaskCount from
the same status payload WorkbenchHeader already consumes; we promote a small
counts derivation out so both call sites stay in sync.
2. TaskWidget — inline chat widget for an orchestrator task
A new segment kind in MessageContent. The agent emits
[TASK:<threadId>]<title>[/TASK] after a successful TASKS_CREATE, and the
chat renderer replaces it with a live widget.
Anatomy (one compact card, ~64px tall):
┌────────────────────────────────────────────────────────┐
│ ▶ Build Kanban planner app [Open] │
│ ● active · 2/2 agents · 3m ago · ~12.3K │
└────────────────────────────────────────────────────────┘
Rules:
- One title line + one status line. No goal, no acceptance criteria, no artifact list. Those live in the orchestrator inspector.
- Status line is structured, not prose. Status dot + label, agents
active/total, relative last activity, token total. Tokens hidden when
usageState === "unavailable". - Click → navigate
/orchestrator?taskId=<id>(the workbench already reads?task=viareadInitialTaskId; we alias?taskId=to it). - Live updates by polling, not WebSocket. Reuses the workbench's
POLL_INTERVAL_MS = 5000(kept in one place — exported, not duplicated). On unmount, polling stops. When the underlying task is terminal (done/failed/archived/closed), polling stops and the status freezes. - One outstanding fetch per widget. Concurrent renders share a tiny per-thread cache so two widgets in scrollback don't quadruple traffic.
- No action buttons inline. Action belongs to the workbench's view-dependent action bar (we are explicit about this — the goal asks the workbench to own actions when on a task).
- Failure mode is silent. If the task 404s (deleted), the widget shrinks to "Task removed." in muted text; it never throws into the chat.
Backend hook: after TASKS_CREATE succeeds, the action's response text gets
[TASK:${task.id}]${task.title}[/TASK] appended. This is a one-line change in
tasks.ts (the create runner already returns a callback with prose).
Segment parser: extend the existing regex sweep in
MessageContent.parseSegments with a [TASK:...]...[/TASK] pattern, ordered
after [FORM] and before plain text. The matcher requires threadId to be a
UUID-shaped string (lowercase a–f0-9-) to avoid trivial injection from
unrelated text.
Test ids: task-widget, task-widget-status, task-widget-open.
3. View-dependent action bar in the workbench
Already exists in TaskInspector (OrchestratorWorkbench.tsx lines
2007–2187). It is correctly state-conditional but every button is always
rendered (just disabled when N/A). The minimal change is grouping and hiding:
- Hide the entire "Edit" group (Fork, Restart, Add Agent, Edit Plan) when
task.statusis in {done,failed,archived,closed}. Terminal tasks should only expose Reopen (and Delete). - Hide priority dropdown for terminal tasks. Priority is meaningless once closed.
- Keep validating-task Approve/Reject as the only primary affordance. Other buttons collapse into an overflow.
This is small — three if guards. Locked in by an inspector test that
toggles status and asserts visibility.
4. Sensitive-request widgets — what's working and what we add
Working today: inline form for kind: "secret". We tighten and extend.
a. SensitiveRequestBlock polish (existing component, small fix)
- Distinguish
pending(gray "Pending"),saving(spinner + "Saving…"),saved(green "Saved" + redacted preview "•••• (saved)"),failed(red "Failed — retry"),expired(muted "Expired"). - On
saved, the form never re-renders the password input for the same request id, even if the message re-mounts. Backed by a tiny per-requestId saved-state cache in component scope. - Reason and instruction text already render; we make sure they never include
the secret value (server-side sanitization is the actual guarantee; the
widget asserts
valueis never substituted intoinstructionorreasonviaMessageContent.sensitive-request.test.tsx).
b. OAuthRequestBlock (new, in MessageContent.tsx)
Renders when message.secretRequest.form.kind === "oauth". Shape:
┌────────────────────────────────────────────┐
│ Connect GitHub │
│ Needed for: managing PRs on this task │
│ │
│ [ Connect with GitHub → ] Pending │
└────────────────────────────────────────────┘
Rules:
- One button. It calls
client.startSensitiveRequestOAuth(requestId)which returns{ authorizationUrl, state }and opens it in a popup (window.openwith a sized rect). Same-origin fallback for embedded environments where popups are blocked. - No token ever passed through chat. The OAuth callback fulfills the
request server-side; the widget polls
GET /api/sensitive-requests/:id/statusevery 2s, up toexpiresAt, and stops on terminal states. - Visible state machine:
Pending→Authorizing…→Saved|Failed — retry|Expired. - Cancel. A small
Cancellink sendsDELETE /api/sensitive-requests/:idand flips toCancelled.
Backend wiring is a sibling of the inline adapter:
packages/app-core/src/services/sensitive-requests/owner-app-oauth-adapter.ts.
The adapter's deliver() accepts request.target.kind === "oauth" requests
(new target kind), builds an envelope with form: { kind: "oauth", provider, scopes, label, submitLabel: "Connect …" }, and sends the inline chat content.
The OAuth callback handler lands tokens in sharedVault exactly like the
existing GitHub OAuth flow; only the trigger differs.
We do not ship a generic OAuth backend in this slice. We ship the widget +
its parser + a fixture-backed e2e + a unit test for envelope construction.
A follow-up wires production providers (per
orchestrator-buildout-followups.md
section B).
c. FormRequest (existing [FORM] widget) — unchanged
The non-sensitive [FORM] widget already works
(form-request.tsx). It is referenced here only to draw the line: that
widget's submission is echoed to chat because it carries non-secret data.
Sensitive requests must never reuse it.
Security guarantees we explicitly maintain
- The secret/OAuth widgets must never push the value or token into the chat
message stream. The current inline adapter calls
client.updateSecrets(secrets)directly and ignores its message channel for the value; we keep that, and add an integration test that scrapes the recorded chat-message stream after a successful save and asserts no field value appears. - OAuth callbacks must never include tokens in URL fragments delivered to the
chat surface. The popup posts the result to the server; the chat surface
only sees
statustransitions. - Widget rendering is gated on
delivery.canCollectValueInCurrentChannel === true. Public/group surfaces continue to render the existing status-only card pointing at the owner app.
Tests
All under existing infra (packages/ui vitest for components,
packages/app/test/ui-smoke Playwright for the route).
-
Component (vitest, packages/ui):
MessageContent.task-widget.test.tsx—[TASK:id]title[/TASK]parses to aTaskWidget; renders title; polls once and renders status; click dispatches navigation.MessageContent.sensitive-request.test.tsx(extend) — status transitions; saved-state cache survives remount; instruction never contains the entered value.MessageContent.oauth-request.test.tsx— OAuth widget shows Connect, opens popup (mockedwindow.open), polls status, lands onSaved.
-
Plugin (vitest, plugin-agent-orchestrator):
create-task-emits-task-block.test.ts—TASKS_CREATErunner output contains[TASK:${id}]${title}[/TASK].owner-app-oauth-adapter.test.ts— envelope shape; rejects non-OAuth kind; emits an inline content withform.kind === "oauth".
-
Playwright (packages/app):
- Extend
orchestrator-gui-workbench.spec.tswith aglance striptest — fixture seeds 3 active / 1 validating / 12 done; assert tile counts; click Validating → filter switches; assert rail shows only the validating task. - New
task-widget-in-chat.spec.ts— boot the chat route, send a "create task X" prompt, mock the orchestrator POST to return a fixture task, assert atask-widgetappears, status flips to active when the mocked poll returns active, clicktask-widget-openlands on/orchestrator?taskId=…with that task selected and its inspector visible. - New
sensitive-request-in-chat.spec.ts— (a) seed a secret request, fill the password, submit, assertclient.updateSecretswas called with that field, assert the password is never posted to any chat-message endpoint; (b) seed an OAuth request, click Connect, mock popuppostMessagecompletion, assert status flips to Saved without any token appearing in the chat-message stream.
- Extend
-
Scenario runner (
packages/scenario-runner): add atask-creationscenario that drives the e2e create → widget → click → workbench path against the real orchestrator routes, using the fake-ACP transport already used by the orchestrator unit tests. Gated undertest:e2e:manualbecause it needs the ACP transport pinned, matching the existing convention.
Out of scope (explicitly)
- Replacing the 5s poll with SSE/WS — that's
orchestrator-buildout-followups.mdsection D and design-touching. - A general "remote/mobile orchestrator" — same followups section D.
- Re-anchoring planner follow-ups to durable goal — section F.
- Real production OAuth providers beyond the widget — see followups B.
Rollout
One PR per slice keeps blast radius small and lets the visual review run on each:
- Slice 1:
OrchestratorGlanceStrip+ counts derivation + tests. - Slice 2:
task-widgetsegment +TaskWidgetcomponent +TASKS_CREATEemission + unit tests. - Slice 3:
task-widget-in-chat.spec.ts. - Slice 4: Sensitive-request polish + OAuth widget + adapter +
sensitive-request-in-chat.spec.ts. - Slice 5: View-dependent inspector cleanup (hide Edit group on terminal, hide priority on terminal) + inspector test.
Each slice ships and gets an audit pass. No slice is "done" until the
relevant Playwright spec is green locally and the chat / /orchestrator
route renders cleanly under the workbench audit pass.