feat(web,desktop): inbox items are blocks and a response, replacing the fixed kinds

Merged
DA
7d ago
+4596-560

Builds on the plan Inbox stack (#2161 → #2162 → #2163). Two later PRs (code-reference blocks, image blocks) stack on this one.

Summary

An inbox item used to be a fixed kind (choice, approval, question, task), one Markdown details field, and a choice's options/recommended. It is now a title, blocks, and a response, each a closed tagged union the wire declares (@sidecar/hosted/inbox-wire), so a later block or response type is one more member rather than a new column.

  • Blocks: markdown (text) and link (url, label). Links must be http(s); up to 8 blocks per item. Image, code, and diff blocks are left to the stacked PRs.
  • Responses: choice (2–6 options, multiple for several, recommended by label), approval, text (optional placeholder), task (Done / Can't do this), form (1–8 typed fields: text, number, select with options, boolean; each with a label and required), and none (the developer acknowledges).
  • Answers: choices (one or several labels) replaces choice; values carries a form's typed values as {label, value} pairs (the wire emitter refuses a record); submitted and acknowledged join the reply kinds.
  • Versioning and degrade: INBOX_WIRE_VERSION is 2 and is stored on the row at post time. The item a desktop reads admits a block or a response of a type it does not know as its type alone (a refined fallback member, so a known type that fails its own shape is still a refused read, not a "newer" one). The board draws "Open this in a newer Luke to see the rest." for such a block and "Answer this in a newer Luke." with no controls for such a response, and the rest of the inbox reads.
  • Migration 0088 (hand-written, with its journal entry):
    • adds version, blocks, response, converts every row (details → one markdown block or none; kind/options/recommended → the response; an answer's choice → choices), and stamps them version 2;
    • leaves the old columns in place and declared by no module, listed in drizzle-schema.test.ts's PENDING_DROP per the README's two-deploy rule; the next PR drops them with DROP COLUMN IF EXISTS and deletes the entry, together with the trigger below;
    • the deploy overlap: the previous deployment keeps serving while this one migrates. Its inserts name the old columns and none of the new, so the new columns have defaults and a BEFORE INSERT OR UPDATE trigger keeps the two shapes in step both ways: a row that lands or changes in the old shape has blocks/response/version filled from the old columns, a row this deployment writes has kind/details/options/recommended projected from its response and blocks (so the old deployment reads an approval as an approval and a choice with its options; a form and a none read as a question, which is the nearest it has), and an answer's choice is rewritten to choices. kind keeps NOT NULL with a default so nothing the old vocabulary decodes goes null. tests/inbox-migration.test.ts migrates a fresh PGlite to 0087, writes old-shape rows, applies 0087, and reads them through the store, then writes and answers one more row the old way and reads two rows the new deployment posts back through the old columns.
  • Store, routes, tools: inboxReplyRefusal checks a pick against the options (one unless multiple, no repeats), a form's values against its fields (named, typed, required filled, finite numbers), and acknowledges only a none; the answer route's body bound grows for a form. The agent's tools post {key, title, blocks?, response, needsReply?}; every read-back line carries blocks and response while open and the answer once settled. A choice's or a select's minimum of two options is in the Schema (minItems), so the model sees it; cross-field rules (recommended names an option, labels unique, only a select carries options) stay in postRefusal with their own refusal words.
  • Desktop: the board draws blocks (Markdown as the transcript does; a link as the transcript draws one, opened on GitHub alone, inert with its address elsewhere), a choice's options pressed one at a time or several, a form's fields with the controls Settings already draws (settings-input, settings-segments, switch), approval/task/none by footer presses, and the rest by the box's Send. A switch left alone sends false; a form's values go in the form's own order. Settled cards list a form's values one per line. The transcript row for post_to_inbox still names the item, and lists blocks and options or fields in its body.

What agents are told (changed)

  • post_to_inbox description now describes blocks and the six responses, when to use each, and that needsReply: false fits a task and a none.
  • Instructions and the tool description: never ask for a credential or a secret in a form, since the answer reaches the agent's transcript; adding a secret stays a task the developer does by hand (coder-agent.test.ts holds both).
  • Instructions, "The developer's inbox": one item per need, "with a title, the context as blocks (short Markdown, and a link to the page a task is done on), and the response you ask for: a choice, an approval, text, a task, a form of typed fields where you need several values at once, or none for something they should only know."
  • New sentence (plan-briefed agents only), replacing the closed #2196: "When an answer settles how to build, a choice made or a step approved or declined, take that decision into the plan with edit_plan, where it belongs in the plan's text, as a note of your own." An agent handed a task is not told it (it sees no plan). coder-agent.test.ts holds both.
  • The refusal words the model reads changed accordingly (UNREADABLE_POST, NO_OPTIONS, new FIELD_LABELS, SELECT_OPTIONS).

JSON Schema goldens (re-recorded with LUKE_UPDATE_FIXTURES=1)

Every change is this PR's own vocabulary change; nothing else moved.

  • inbox-wire-inboxPostInputSchema: kind, details, options, recommended removed; blocks (array of the two block objects, maxItems: 8) and response (anyOf of the six response objects) added; response joins required. This is the tool schema in every coding agent's prompt cache: 1.1 KB → 5.7 KB pretty-printed, which the six-way response union makes inherent; fields carry no descriptions, and the words stay in the tool description.
  • inbox-wire-inboxReplySchema, inbox-wire-inboxAnswerRequestSchema, coding-agent-view-codingAgentInboxAnswerParamsSchema: choice (string) → choices (string array, maxItems: 6); values added (array of {label, value: string | number | boolean}, maxItems: 8); reply enum gains submitted and acknowledged.
  • inbox-wire-inboxItemSchema, inbox-wire-inboxAnswerSchema, inbox-wire-inboxAnsweredSchema, coding-agent-view-codingAgentInboxAnswerViewSchema, coding-agent-view-codingAgentInboxAnsweredViewSchema: the item gains version, blocks, and response (each union carrying the {type: string} fallback member last) and loses kind, details, options, recommended.
  • New goldens: inboxBlockSchema, inboxBlocksSchema, inboxFieldSchema, inboxFieldValueSchema, inboxFormValueSchema, inboxFormValuesSchema, inboxResponseSchema, inboxReadBlockSchema, inboxReadResponseSchema.
  • inboxOptionSchema is byte-identical (the label bound is still 160).

Evidence

  • Platform-independent checks: ./scripts/check.sh green on this branch (knip, lint, discarded-effect, typecheck, every test shard, and the desktop build within its bundle budget).
  • pnpm --filter @luke/web test:store run locally against a real PostgreSQL 16 (db:migrate from empty, then the store suite): green, including drizzle-schema.test.ts with the four pending drops and inbox-migration.test.ts with the plpgsql trigger.
  • macOS Electron verification (./scripts/verify.sh): not run. Built in a Linux cloud workspace. CI cannot verify this UI; a Mac break would be caught at the release rehearsal on a tag, not at review.
  • Tests: +20, none removed (inbox-board 9→11, inbox-model 5→8, coder-inbox 7→8, coding-agents-app 57→59, coder-agent 14→16, inbox-migration new 1, nine new goldens). Existing inbox tests were moved to the new shape, none loosened.

Visual evidence

Not verify.sh captures: the real InboxBoard with the real stylesheet, drawn in headless Chrome over a mock inbox (dark mode).

Open items: choice with a recommendation, multi-choice, form, task with links, FYI, and an item from a newer service

Settled items, a filled form opened

Physical-device evidence

  • Screenshot or screen recording: not attached
  • Device/display configuration: not recorded

Invariants

  • ./scripts/check.sh green. ./scripts/verify.sh not run (no Mac; see above).
  • JSON Schema goldens: LUKE_UPDATE_FIXTURES=1 run; the diff is explained above.
  • 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.
  • No Effect.runPromise/runSync/runFork outside the runtime edges and recorded shims.
  • No new raw async primitives outside the recorded files.
  • No Effect, Stream, or Layer written as a statement and dropped (pnpm discarded-effect passes).
  • Test count delta listed above.
  • No hand-rolled file replaced.
  • No CLAUDE.md/AGENTS.md sentence names a changed part; root pair untouched.
  • PRIVACY.md changed: "A plan's inbox" now says an item holds the context as Markdown and links and the way it asks to be answered, with any options or form fields. A product decision, called out here.
  • Package deps match imports; effect via catalog:.
  • Barrel/door rule kept: the renderer reaches @sidecar/hosted/inbox-wire (Schema only) and @sidecar/wire's value guards, as it already did.

Notes

  • Follow-up (next deploy): drop inbox_item.kind, details, options, recommended, the inbox_item_0088_fill trigger, and the five inbox_item_0088_* functions; delete the PENDING_DROP entry.
  • Devin's "older desktops lose their inbox" finding is answered on its thread: there is no released desktop yet, and the wire's own rule is that a missing key a client requires is a refused read.
  • The stacked PRs add block types by adding a union member and bumping INBOX_WIRE_VERSION.

🤖 Generated with Claude Code