feat(desktop): New coding agent in the + opens a draft agent whose first message is its task

Merged
DA
8d ago
+816-158

Summary

Dean: "I also want to be able to create a new coding agent from the + button." And, clarified: it "must NOT send the plan". It opens a new coding agent tab with an empty transcript and the composer focused. The agent isn't started on the service until the developer sends the first message, so an abandoned empty tab leaves nothing behind. That first message starts the agent with the message as its task, with the same repo, model picker, and preconditions as Start. Start and its behaviour stay exactly as they are.

This is the desktop half of the agreed split. It builds on #1958 (merged: the service starts an agent on a task instead of the plan) and #1952 (merged: agent/subagent tabs from the "+").

  • "New coding agent" row at the top of the "+" menu's Coding agents section, with the panel's own plus glyph and no shortcut. It is gated exactly as Start is: while Start can't run, the row is dimmed and shows Start's own reason ("Choose a repository first."). That reason is set exactly while Start can't run on the open plan. While a Start is out, the row reads "Starting…". With that row always listed, the "+" always opens, so its "Every tab is open" disabled state is gone. Choosing the row when the open plan already has a draft just switches to it, so there is one draft per plan.

  • Draft agent tab (planning/draft-agent-tab.tsx), named "New agent", stands after the plan's agents:

    • Where the transcript will be, one line: "Describe a task. The agent starts when you send it."
    • Below it, the agent composer (the same component), focused as the tab opens, with the placeholder "Describe a task for the agent…".
    • The model chip shows the account's default (read as the tab opens) and uses the same model menu. A choice made there goes with the start, and the service then keeps it as the default, exactly as Start's menu does.
    • Nothing is sent until the first message. Closing the tab (× or Delete) discards the draft, and since nothing was started, nothing is left on the service. The next "New coding agent" is a fresh draft.
    • The first message calls CODING_AGENTS_START with { planId, idempotencyKey: <draft's key>, task, model?/effort? } (#1958). The agent is handed those words, never the plan, and works in the plan's repository.
    • If the send doesn't start the agent, the words stay in the box, with the reason and Retry underneath. Retry asks again under the draft's key, so a lost answer can't start two agents.
    • Once it starts, the agent joins the list and its own tab opens chosen in place of the draft, through the same onStarted path Start uses.
  • Task agents draw their task as the developer's message. AgentTranscriptView reads agent.brief from #1958: a task agent's first message is a normal user bubble, and only a plan agent's is the "Plan" card. (This answers Devin's note on #1958.)

  • Panel state: a { draft } tab kind in use-side-panel.ts. It is shown only while the open plan holds that draft, and is never persisted: a chosen draft tab is saved as no tab, the same way subagent tabs are handled.

  • One neighbour rule for every closed tab. Closing the chosen tab picks:

    • the next tab of its own run (fixed tabs, open agents, the draft, the plan's subagent tabs);
    • else the previous tab in that run;
    • else the tab before it in the strip;
    • else the tab after it.

    This one helper (neighbourOf) replaces four per-kind functions that spelled out the same rule. The behaviour for fixed and subagent tabs is unchanged, and every existing test still holds. A draft's tab closes to the open agent before it, else the last fixed tab. With no other agent open, an agent's tab closes to the tab before it.

  • Control: useCodingAgents holds drafts in a map keyed by plan id. Each draft holds its words and its chip's choice (AgentDraft { id, task, choice }), so both survive the tab being hidden and visits to other plans. Each plan keeps its own draft. The hook gains four functions:

    • onNewDraft (returns the plan's draft, or mints one whose key becomes its idempotency key).
    • onDraftChange.
    • onDiscardDraft.
    • startTask, which shares one start path with Start's own press (askStart / landed).
    • Start's press, key reuse, and toolbar note are unchanged.
  • Nothing new beside Start, and a test asserts the toolbar has no New coding agent button.

  • Docs: docs/PLANNING.md ("Agent tabs"). PRIVACY.md: the agents the Mac app lists are those started from Start "or from New coding agent in its side panel".

Bundle budget (re-recorded, approved by Dean)

apps/desktop/bundle-budget.json: the renderer.js baseline is re-recorded in this PR with LUKE_UPDATE_BUNDLE_BUDGET=1, which Dean approved. This is the only PR that touches the file. whiteboard.js and the 5% slack are unchanged.

Why: the baseline was last recorded in #1861 (2026-10-09). Deliberate feature growth since then has used up the 5% slack: the side panel's tabs, the menus, light mode, and the agent tabs. Three finished PRs were each over the ceiling: #1962 (this one), #1956, and #1967. This is growth, not drift.

renderer/renderer.js (gzip -9)bytes
Old baseline (#1861)664,696
Old ceiling (+5%)697,930
main at 85d15634697,713 (217 bytes of headroom)
This PR698,487 (557 over the old ceiling)
New baseline698,487
New ceiling (+5%)733,411

renderer/whiteboard.js stays at 467,697. The build re-measured it at 468,579 and that value was put back untouched.

Evidence

  • Platform-independent checks: ./scripts/check.sh exits 0.
  • macOS Electron verification (./scripts/verify.sh): not run. CI could not verify this UI change. CI builds nothing for the Mac and this was done on a Linux VM; LUKE-159 is the manual Mac pass.

Visual evidence

Headless Chrome render of the real DesktopPlans page and side panel (stub controls, the renderer's built stylesheet).

The "+" with New coding agent at the top of Coding agents:

DarkLight

The draft tab: nothing said yet, the composer focused, and the model chip on the account's default:

DarkLight

A plan with no repository: the row is dimmed and shows Start's reason:

DarkLight

Physical-device evidence

  • Screenshot or screen recording: not attached (no Mac in this workspace)

Tests

Each new test fails without this change:

  • desktop-plans.test.ts drives the real page with the real useSidePanel and the real useCodingAgents, wired as the Plans tab wires them, over a scripted service:
    • New coding agent opens the "New agent" tab chosen, shows the draft line, and focuses the box. No Start has been asked yet. Typed words survive showing Board and coming back. The first send is unanswered: the words stay, with "The agent could not be started. Try again." and Retry. Retry starts the agent with { planId, idempotencyKey: <draft key>, task }, the same key both times, and no plan. The draft tab goes and the agent's own tab is chosen.
    • Closing the draft's tab with typed words asks for no Start, and the next New coding agent opens an empty draft.
    • With no repository, the row is disabled, shows Choose a repository first., and opens nothing. The toolbar has no New coding agent button.
  • use-coding-agents.test.ts:
    • A draft asks nothing of the service, and the plan's one draft is answered again. Its words and chip choice are held. Another plan opens a draft of its own without erasing the first, and both are there on return. After the first starts, the other plan's draft remains.
    • startTask sends the task under the draft's key, with the choice when one was made. A failure keeps the draft and returns the note without touching Start's toolbar note. Success opens the agent's tab and discards the draft.
    • A discarded draft is gone and the next is new. A plan without a repository starts nothing.
  • agent-tab.test.ts: brief task draws no plan card and a user bubble, and brief plan still draws the card.
  • use-side-panel.test.ts: a draft tab shows while its plan holds it and returns when you come back to that plan. A launch reads a chosen draft tab as the first open tab. Closing it picks the open agent before it, and an agent's tab closes to the draft after it.
  • Updated: #1950/#1952's "+" tests now include the New coding agent row. The "Every tab is open" block is gone, since the "+" always opens now.

Test count: +7.

Invariants

  • ./scripts/check.sh green; verify.sh not run (Linux VM; see above).
  • JSON Schema goldens unchanged (#1958 carried the wire changes).
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged.
  • No new as type assertion outside the allowlisted files (the one cast in the test's scripted acts carries its SAFETY: note, as in use-coding-agents.test.ts).
  • No Effect.runPromise/runSync/runFork outside the runtime edges.
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch.
  • No Effect, Stream, or Layer written as a statement and dropped.
  • Test count delta listed above.
  • No hand-rolled file replaced.
  • Docs naming the changed parts edited (docs/PLANNING.md); AGENTS.md/CLAUDE.md name none of them.
  • PRIVACY.md changed: the agents the Mac app lists now include those started from New coding agent in the side panel.
  • No dependency changes. The renderer bundle baseline is re-recorded (see "Bundle budget" above; approved by Dean).
  • No barrel/door change.

🤖 Generated with Claude Code