feat(desktop): a box keeps its unsent words per agent and per task on this Mac, and a pencil marks where a draft waits

Merged
DA
3d ago
+1007-76

Summary

Dean: "If I'm typing something and switch tabs or tasks, the text goes away." This PR makes every agent composer keep an unsent draft per destination, the way Slack, Linear, Discord, and GitHub comments do. A small pencil marks where a draft waits.

  • One draft store (apps/desktop/src/renderer/composer-drafts.ts): one keep-alive atom, saved per Mac under luke.composerDrafts in the renderer's storage, the same way luke.planLooks and the sidebar folds are saved. Each draft is keyed by its destination's own ids, a structured place ({ kind: "agent", planId, agentId }, { kind: "newAgent", planId, draftId }) compared by Equal.equals, never a concatenated key. A write starts from what storage holds, because another panel window saves its drafts there too. The store keeps the newest 200 drafts across accounts. An entry keeps its place until it is rewritten, and each account's in-memory drafts are an atom of their own.
  • Per account: each saved draft carries the id of the account that wrote it, or its email where the identity was stored without an id. The panel's root sets the signed-in account as the drafts' owner (useDraftsOwner, in a layout effect, before paint), and a window reads only that account's drafts. A teammate signing in on the same Mac sees none of another member's unsent words; those come back when their writer signs in again.
  • Agent composer (use-agent-composer.ts), used by task agents and by one-off and automation agents (DesktopAgentPage): the words and files live in the agent's draft instead of the tab. Switching tabs or tasks and coming back restores them. Sending clears the draft, and emptying the box does too. A message that doesn't go comes back into the agent's draft, even if its tab has since closed.
  • New agent tab (use-coding-agents.ts): the first message is saved per plan under the draft's Start key. The words now survive a relaunch: the plan reopens on its New agent tab under the same key, so a Start asked again is still the same agent. The model and MCP-server choices stay in memory as before. A window holding a draft key reads only the words saved under that key, so two windows drafting a New agent on one task never swap words. The newest draft for the plan is used only when the window holds no key, as after a relaunch.
  • Mentions and skills are part of the composer's string ([@name](target), /name), so they come back as chips.
  • What is dropped: attached files are kept for the window's lifetime only. A message's files can reach 3 MB and storage holds a few MB, so after a relaunch the words come back without the files. Drafts holding only files are never saved.
  • Caret: the editor remounts with the caret at the end of the restored words, not where it was left.
  • Pencil: a muted 12px PencilLine named "Draft" (draft-mark.tsx):
    • On the sidebar task row it sits in the time slot before the status glyph, so the order is [repo] [face] [Draft] [status] [time]. It steps out with the rest of the slot under the pointer, as worker 324 suggested for their stack.
    • On an agent's tab it sits at the tab's end, in the Ɨ's place, the way an editor shows an unsaved dot. A failed mark takes precedence there.
    • It is glyph-only with an aria-label. Neither slot can carry a hover tooltip, because both give way to buttons under the pointer.
LightDark
lightdark

These are the real SidebarPlan rows and DesktopPlans column with two drafts seeded, rendered with the built styles.css in headless Chrome (the evidence run needs a Mac). Shown: "Teammate invitations" (open) holds a draft on its "Invite expiry" agent, and "Billing export" (unread, a teammate's, agent working) holds one too.

Privacy: PRIVACY.md's "On your Mac" paragraph said Luke keeps only the window's layout on disk. It now also names unsent words to a coding agent, kept per agent until sent or emptied and shown only while the account that wrote them is signed in, with files in memory only. This is a product decision to call out: Dean's brief asks for drafts that survive a relaunch on this Mac. Nothing leaves the Mac; session replay masks every word, and the pencil carries no text.

Still to come in the next PR: Inbox answer drafts. They already survive a tab or task switch within one view, but are lost when the view changes or the window reloads. There is no Call-tab text box, plan comment composer, or New task prompt (New task is only a name field).

Evidence

  • pnpm check:affected: green at the head commit.
  • pnpm exec vitest run --project desktop after rebasing on main: 124 files, 963 tests, all passing.
  • New tests through the public components, each failing on main:
    • composer-drafts.test.ts (DesktopPlans with SidebarPlan rows):
      • Type in task A's agent box, open task B, then back: the words are there, and A's row and agent tab wear the Draft pencil meanwhile.
      • A draft survives a reload: a new registry and a remount bring back the words and the mark.
      • Send clears the draft and the mark.
      • Emptying the box by hand clears the draft and the mark.
      • A draft belongs to its account: another account signing in sees none of it, even after a relaunch, and its writer finds it again.
      • Accounts stored without an id are told apart by their email, and their drafts become the id's once it is learned.
      • Past 200 drafts, the oldest written goes, whichever account wrote it, and the window lets go of it as storage does.
    • use-coding-agents.test.ts:
      • A New agent draft's words survive a relaunch under the same key, without its files or chips, and a discarded draft stays gone.
      • A draft of files alone waits while another plan is open, but is not brought back after a relaunch.
      • Two windows' New agent drafts on one plan each keep their own words under their own key.
  • Existing composer tests now mount each box on its own registry and clear storage after each test, because the draft store is per registry and per storage, as a window's is.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged.
  • 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.
  • No Effect.runPromise/runSync/runFork outside the runtime edges.
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch in production code.
  • No Effect, Stream, or Layer written as a statement and dropped.
  • Test count delta: +12 (9 in composer-drafts.test.ts, 3 in use-coding-agents.test.ts).
  • No hand-rolled file replaced.
  • Every CLAUDE.md/AGENTS.md sentence naming a changed part is edited (none names these).
  • PRIVACY.md changed: a product decision, called out above.
  • Package deps unchanged.
  • Barrel/door rule unaffected.

Notes

  • Blockers or follow-up verification: the Inbox answer drafts follow in a second PR.

šŸ¤– Generated with Claude Code