feat(web,desktop): inbox items are blocks and a response, replacing the fixed kinds
Merged
DA
7d ago+4596+4596+0123456789012345678901234567890123456789-560-560-012345678901234567890123456789
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) andlink(url, label). Links must behttp(s); up to 8 blocks per item. Image, code, and diff blocks are left to the stacked PRs. - Responses:
choice(2–6 options,multiplefor several,recommendedby 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 andrequired), andnone(the developer acknowledges). - Answers:
choices(one or several labels) replaceschoice;valuescarries a form's typed values as{label, value}pairs (the wire emitter refuses a record);submittedandacknowledgedjoin the reply kinds. - Versioning and degrade:
INBOX_WIRE_VERSIONis 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'schoice→choices), and stamps them version 2; - leaves the old columns in place and declared by no module, listed in
drizzle-schema.test.ts'sPENDING_DROPper the README's two-deploy rule; the next PR drops them withDROP COLUMN IF EXISTSand 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 UPDATEtrigger keeps the two shapes in step both ways: a row that lands or changes in the old shape hasblocks/response/versionfilled from the old columns, a row this deployment writes haskind/details/options/recommendedprojected from its response and blocks (so the old deployment reads an approval as an approval and a choice with its options; a form and anoneread as a question, which is the nearest it has), and an answer'schoiceis rewritten tochoices.kindkeeps NOT NULL with a default so nothing the old vocabulary decodes goes null.tests/inbox-migration.test.tsmigrates 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.
- adds
- Store, routes, tools:
inboxReplyRefusalchecks a pick against the options (one unlessmultiple, no repeats), a form's values against its fields (named, typed, required filled, finite numbers), and acknowledges only anone; the answer route's body bound grows for a form. The agent's tools post{key, title, blocks?, response, needsReply?}; every read-back line carriesblocksandresponsewhile 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 inpostRefusalwith 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 sendsfalse; a form's values go in the form's own order. Settled cards list a form's values one per line. The transcript row forpost_to_inboxstill names the item, and lists blocks and options or fields in its body.
What agents are told (changed)
post_to_inboxdescription now describes blocks and the six responses, when to use each, and thatneedsReply: falsefits a task and anone.- 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.tsholds 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.tsholds both. - The refusal words the model reads changed accordingly (
UNREADABLE_POST,NO_OPTIONS, newFIELD_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,recommendedremoved;blocks(array of the two block objects,maxItems: 8) andresponse(anyOfof the six response objects) added;responsejoinsrequired. 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);valuesadded (array of{label, value: string | number | boolean},maxItems: 8);replyenum gainssubmittedandacknowledged.inbox-wire-inboxItemSchema,inbox-wire-inboxAnswerSchema,inbox-wire-inboxAnsweredSchema,coding-agent-view-codingAgentInboxAnswerViewSchema,coding-agent-view-codingAgentInboxAnsweredViewSchema: the item gainsversion,blocks, andresponse(each union carrying the{type: string}fallback member last) and loseskind,details,options,recommended.- New goldens:
inboxBlockSchema,inboxBlocksSchema,inboxFieldSchema,inboxFieldValueSchema,inboxFormValueSchema,inboxFormValuesSchema,inboxResponseSchema,inboxReadBlockSchema,inboxReadResponseSchema. inboxOptionSchemais byte-identical (the label bound is still 160).
Evidence
- Platform-independent checks:
./scripts/check.shgreen on this branch (knip, lint, discarded-effect, typecheck, every test shard, and the desktop build within its bundle budget). pnpm --filter @luke/web test:storerun locally against a real PostgreSQL 16 (db:migratefrom empty, then the store suite): green, includingdrizzle-schema.test.tswith the four pending drops andinbox-migration.test.tswith 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-board9→11,inbox-model5→8,coder-inbox7→8,coding-agents-app57→59,coder-agent14→16,inbox-migrationnew 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).


Physical-device evidence
- Screenshot or screen recording:
not attached - Device/display configuration:
not recorded
Invariants
-
./scripts/check.shgreen../scripts/verify.shnot run (no Mac; see above). - JSON Schema goldens:
LUKE_UPDATE_FIXTURES=1run; 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
astype assertion outside the allowlisted files. - No
Effect.runPromise/runSync/runForkoutside 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-effectpasses). - Test count delta listed above.
- No hand-rolled file replaced.
- No
CLAUDE.md/AGENTS.mdsentence names a changed part; root pair untouched. -
PRIVACY.mdchanged: "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;
effectviacatalog:. - 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, theinbox_item_0088_filltrigger, and the fiveinbox_item_0088_*functions; delete thePENDING_DROPentry. - 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