diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index 24efcd868..fc337881a 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -235,7 +235,7 @@ export const TELEMETRY_COMMAND_USAGE = [ export const CARD_COMMAND_USAGE = [ "Usage:", " plannotator card create [--column <column>] [--say <text>] [--you <text>] [--thread <id>] [--pr <ref>] [--ref <issue or PR>] [--key <key>]", - " plannotator card create --thread <id> [...] (the thread's subject is the title)", + " plannotator card create --thread <id> [...] (the thread's subject is the title; pass a short <title> too, as subjects run long)", " plannotator card move <crd_id> <column> [--say <text>] [--you <text>] [--thread <id>] [--pr <ref>] [--ref <issue or PR>] [--reason <text>]", " plannotator card say <crd_id> [<text>] [--you <text>] [--title <text>]", " plannotator card link <crd_id> [--thread <id>] [--pr <ref>] [--ref <issue or PR>] [--say <text>]", @@ -247,6 +247,8 @@ export const CARD_COMMAND_USAGE = [ `Columns: ${INBOX_CARD_COLUMNS.join(", ")} (icebox is the person's only).`, "", "Keeps a card on the person's Board in the Plannotator Inbox for work that outlasts a reply.", + "A title is 2–5 words naming the work (\"Pi update fix\"), never a sentence; status goes in --say.", + "When a card waits on the person, link the Inbox thread where they make the call (--thread), so \"You: …\" opens it.", "Works only while the person has the Board on (Inbox Settings → Capabilities); off, it exits 1 with board_off.", "Run 'plannotator inbox --help' for the full guide (its card section, shown while the Board is on).", ].join("\n"); diff --git a/apps/hook/server/inbox-help.ts b/apps/hook/server/inbox-help.ts index 78a8b1daa..397f26f7e 100644 --- a/apps/hook/server/inbox-help.ts +++ b/apps/hook/server/inbox-help.ts @@ -160,8 +160,8 @@ export const TOOL_GUIDE: Record<InboxMcpToolName, InboxToolGuide> = { "Keep a card on the person's Board for work that outlasts the reply it was asked in: a lane or background task started, a PR opened, \"I'll send you X\". Not for a quick answer. Make it when you take such an ask, then keep it current with one short call at the moments you already note: work started, a PR opened, something sent to the person, waiting on them, shipped. The person reads the Board instead of asking each terminal where things are; each card shows how long ago anyone last wrote to it. A card is its own record: it never changes a thread or the person's list. The Board is something the person turns on (Settings → Capabilities in the Inbox); while it is off the Inbox does not list `card`, and a call answers `board_off` and writes nothing. Turned on, agents get `card` in their next session.", "- `op`: `create`, `move`, `say`, `link`, `ship`, `drop` or `list`. Every op but create and list takes `card_id` (`crd_…`, from create or list); an op refuses a field it does not take.", "- `column`: `backlog` (not started: work you were asked to do and have not begun goes here, beside the person's own ideas; a backlog card is never a signal to start, the person tells you when), `designing` (research, a plan, mocks, a sheet, options: anything that is not code), `your_look` (a non-code thing waits on the person: a sheet, options, questions, a triage call), `building` (code, or a PR not ready for them), `your_review` (code waits on the person: screenshots before a merge, a green PR to merge, a release go), `shipped` (merged or live, staging included), `dropped`. The stage is the kind of thing the next move makes: code, a PR or a release is building, anything else designing. Waiting on the person wins over work you keep doing beside it. Work you hold back yourself stays in its column, with the reason in `say`.", - "- `you`: what the person owes, one line, shown as \"You: …\" in your_look and your_review. Moving a card into either needs it; it is dropped when the card leaves them, and refused elsewhere. `say`: your last word, one line (\"lane running, sheet in ~1 h\"), shown in the other columns.", - "- create: `title`, or `thread_id` (a thread or message id of this project) to link it and take its subject as the title; `column` (default backlog; a live column or shipped), `say`, `you`, `pr`, `idempotency_key` (the same key answers the first card). The card is your session's: list shows it as yours.", + "- `you`: what the person owes, one line, shown as \"You: …\" in your_look and your_review. Moving a card into either needs it; it is dropped when the card leaves them, and refused elsewhere. Link the Inbox thread where the person makes the call (`thread_id`), so \"You: …\" opens it. `say`: your last word, one line (\"lane running, sheet in ~1 h\"), shown in the other columns.", + "- create: `title`: 2–5 words naming the work (\"Pi update fix\"), never a sentence; status goes in `say`. Or `thread_id` (a thread or message id of this project) to link it and take its subject as the title (subjects run long: pass a short `title` with it); `column` (default backlog; a live column or shipped), `say`, `you`, `pr`, `idempotency_key` (the same key answers the first card). The card is your session's: list shows it as yours.", "- move: `column`, with any of `say`, `you`, `pr` and `thread_id`; to dropped it needs `reason`. say: `say` and/or `you`, or `title` to rename. link: `thread_id`, `pr` (`owner/repo#123`, or a GitHub, GitLab or Bitbucket PR URL) and/or `ref` (an issue or PR: `owner/repo#123`, or its GitHub or GitLab URL; move, create and ship take it too). The person sees each link's live state when they open the card; it never moves the card. Every write counts as hearing from the card.", "- ship: when it is merged or live. A merged PR never moves a card by itself: ship it. Reported broken again: move it back to backlog. drop: `reason` (required from you, one line); the Board shows the person your drop and its reason until they look, and they can restore it.", "- list: your cards in this project (`asked_by: \"anyone\"` for every card here) in the live columns and the icebox; `include_resolved` adds shipped and dropped. One line each: id, title, column, `You:` or said, when it was last heard, dropped reason (and whether the person has seen your drop yet), threads, PRs, the agent it belongs to. Read it after a compaction or in a new session to pick your work back up.", diff --git a/apps/skills/core/plannotator/SKILL.md b/apps/skills/core/plannotator/SKILL.md index ccb7cc367..64db051a9 100644 --- a/apps/skills/core/plannotator/SKILL.md +++ b/apps/skills/core/plannotator/SKILL.md @@ -191,7 +191,7 @@ The Plannotator Inbox is one local window per machine where you leave the person plannotator card <create|move|say|link|ship|drop|list> ... ``` -Only while the person has turned the Inbox's Board on (Settings → Capabilities); it is off by default, and then every call answers `board_off` and writes nothing, so use it only when your inbox tool lists `card` or the person asks. The Board holds one card per piece of work that outlasts a reply, in the column of its stage (backlog, designing, your_look, building, your_review, shipped). Use the `card` action of a `plannotator_inbox` tool when you have one; this CLI is the same call from a shell. `plannotator card --help` gives the arguments, and `plannotator inbox --help` its card section while the Board is on. The icebox is the person's only. +Only while the person has turned the Inbox's Board on (Settings → Capabilities); it is off by default, and then every call answers `board_off` and writes nothing, so use it only when your inbox tool lists `card` or the person asks. The Board holds one card per piece of work that outlasts a reply, in the column of its stage (backlog, designing, your_look, building, your_review, shipped). Use the `card` action of a `plannotator_inbox` tool when you have one; this CLI is the same call from a shell. `plannotator card --help` gives the arguments, and `plannotator inbox --help` its card section while the Board is on. The icebox is the person's only. A title is 2–5 words naming the work ("Pi update fix"), never a sentence; where it stands goes in `say`. When a card waits on the person, link the Inbox thread where they make the call (`thread_id`), so "You: …" opens it. ## Other subcommands diff --git a/docs/agent-guides/inbox-compose.md b/docs/agent-guides/inbox-compose.md index daf921c26..4c1e4bf74 100644 --- a/docs/agent-guides/inbox-compose.md +++ b/docs/agent-guides/inbox-compose.md @@ -29,7 +29,7 @@ - Send to another session (`POST /api/inbox/messages/:id/to`): re-addresses a person message that no session took yet (a thread the person started, a New message, a redirected reply, or a reply in a thread the person started) to a live session of its thread's project (`replyTarget`). A new snapshot of the same message (the last line wins); `409 already_delivered` once delivered, `409 thread_resolved` on an archived thread, `422 not_addressed` for anything else. A reply in a thread the person started, moved to a session that has seen nothing of that thread (it never took the root, the root is not addressed to it, and it never wrote there), wakes it with `continues_thread: true`, and `inboxWakeText` adds `inboxContinuesLine` (read the thread first with `read_thread`, then answer with `reply_to`). The window offers it after the asker grace (`GET /api/inbox/sessions?session=<id>` or the thread route's `?asker=`). - A reply in a thread the person started, written before the agent answered, is stamped with the `to` of the message it answers (the root's current `to`) when it is written (`sendReply`), so like any addressed message it is handed only to that session and settled only by its delivery: another agent's `read_thread` or `wait_for_reply` never settles it. - The thread's name (its chip, `thread_name`): the person may give one in compose (`thread_name` on `POST /api/inbox/messages`, checked by `checkInboxThreadName`: one line, 1 to 120 visible characters, no control or bidi characters; `422 validation_error` otherwise; refused with `409 thread_name_taken`, nothing written, while another OPEN thread of the project has the name (compared by `inboxThreadNameKey`; a resolved one does not hold it); empty means none; part of the Send's idempotency). Compose sends its own suggestion the same way with `thread_name_auto: true` (the chip the person left following the message): best effort, so one that is not a valid name or that another open thread has is dropped without refusing the Send, and it is stored with `named_by: { kind: "auto", at }`, which still lets the agent name the thread (`threadNamers` ignores it) and is replaced, once, by the agent's name; a retry compares only a name the person typed. Otherwise the agent names it ("Naming a thread the person started" under "Agent connections and the knob"). The root carries `named_by` (`{ kind: "person" | "auto", at }` or `{ kind: "agent", host, session, message_id, at }`); a name the person typed or an agent gave never changes. Routing (review of #1956): a compose name, typed or suggested, routes NOTHING. Another session's `send_message` with that `thread` starts its own thread; only the addressed session's `reply_to` replies land in the person's thread, and only an AGENT's name (given once, never changed after) indexes it in `namedThreads`, from which point named sends join it as for any agent-named thread. Compose names live in `personNamed`, read only by `threadNameTaken` (with the root's current name, since an agent may replace a suggestion), so two open threads never share a name. Proved by an independent model, `packages/shared/inbox/store-routing-model.test.ts` (built from the operations alone, with readdress, delete and reopen; it asserts a named send lands in a person's thread only when an agent named it so), beside the line-walk oracle in `store.test.ts`. A retried agent reply compares its `thread` only when the stored reply kept `thread_naming` (anywhere else `thread` with `reply_to` is ignored, and older stores never recorded `reply_thread`). The bold subject stays the short name above. A thread an AGENT named is indexed under its name (`namedThreads`), so later sends with that `thread` join it; a compose name never is (see "Routing" above). -- Drafts: one file per draft, `inbox/drafts/<drf_id>.json` (0600, folder 0700, temp then rename), `{ v: 1, id, created_at, updated_at, body, to: { host, session, project_root, project_name, started_at } | null, project, files: [upload], thread_name? }` (`thread_name` as the person typed it in the chip, at most 500 characters; absent while the chip follows the message; the Send checks it). Never in the event log, so never seen by agents, the MCP, phones, the relay or search. A save is a compare-and-set on `updated_at` (`base`: the `updated_at` the writer read, null to create; a mismatch is `409 draft_conflict` with the newer draft); `updated_at` always moves forward. `GET /api/inbox/drafts` adds each draft's chip, `status: live | ended | none` (its session live now, gone, or none chosen). Sending with `draft_id` deletes the draft. A draft never sends itself and is never re-addressed on its own. Storage in Settings counts the drafts' bytes; `uninstall --purge` removes them with `inbox/`. +- Drafts: one file per draft, `inbox/drafts/<drf_id>.json` (0600, folder 0700, temp then rename), `{ v: 1, id, created_at, updated_at, body, to: { host, session, project_root, project_name, started_at } | null, project, files: [upload], thread_name?, card? }` (`thread_name` as the person typed it in the chip, at most 500 characters; absent while the chip follows the message; the Send checks it. `card`: `{ id: crd_…, title }`, the Board card a Create thread compose was for, so sending the draft later still links the thread to it; anything else is `422 validation_error` on `card`). Never in the event log, so never seen by agents, the MCP, phones, the relay or search. A save is a compare-and-set on `updated_at` (`base`: the `updated_at` the writer read, null to create; a mismatch is `409 draft_conflict` with the newer draft); `updated_at` always moves forward. `GET /api/inbox/drafts` adds each draft's chip, `status: live | ended | none` (its session live now, gone, or none chosen). Sending with `draft_id` deletes the draft. A draft never sends itself and is never re-addressed on its own. Storage in Settings counts the drafts' bytes; `uninstall --purge` removes them with `inbox/`. - Uploads (`POST /api/inbox/uploads?name=<name>&serverSession=<s>`, the raw body): streamed to a temp file in `blobs/` (never whole in memory), refused past `INBOX_UPLOAD_MAX_BYTES` (100 MB, under the 128 MiB request bound; `413 upload_too_large`), hashed and renamed to `blobs/<sha256>`, the kind decided by `decideInboxAttachmentKind`, and recorded as `inbox/uploads/pending/<upl_id>.json`. The name must be a plain file name (no `/` or `\`, none of the thread name's refused characters: C0 and C1 controls, U+2028/U+2029, bidi controls; at most 255 bytes) outside the dotenv family (`422 upload_refused`, "Not attached: .env files often hold secrets."). At send each upload becomes an ordinary `InboxAttachment` (`inboxUploadAttachments`): the blob copied (a clone where the filesystem has them; never a hard link, so an agent writing to its file cannot change the stored blob), read-only outside Windows (where a read-only file could not be swept), at `inbox/uploads/<att_id>/<name>`, its realpath the record's `path`, `named_path` null, so the tiles, viewers, download route and phone door carry it unchanged, and it never reads as changed since sent. The store's blob sweep after a deletion keeps the pending uploads' blobs and removes the `uploads/<att_id>/` files of deleted messages; an hourly sweep (and one at start) forgets pending uploads older than a day that no draft names, and the blobs only they held (`sweepInboxUploads`). - Guards: every route is a window route (same origin; `serverSession` in the body, or the upload's query); the owner's page over the tailnet may compose and upload; the phone's door carries the sessions, a new thread, Send to another session and uploads (contract 7.39 to 7.41, 7.48; uploads never through the relay), never drafts. - The window (`packages/inbox/compose/`: `ComposeDialog.tsx`, `ToPicker.tsx`, `ComposeFiles.tsx`, `DraftsPage.tsx`, `SendToAnotherSession.tsx`, the pure rules in `model.ts`, the session read in `useComposeSessions.ts`): diff --git a/docs/agent-guides/inbox-notifications-and-agents.md b/docs/agent-guides/inbox-notifications-and-agents.md index e50bdf402..79018ecc5 100644 --- a/docs/agent-guides/inbox-notifications-and-agents.md +++ b/docs/agent-guides/inbox-notifications-and-agents.md @@ -62,7 +62,7 @@ - Fields: `title`, `column`, `you` (what the person owes, shown "You: …" in Your look and Your review, cleared when the card leaves them), `said` (the agent's last word), `notes` (the person's, line breaks kept, at most `INBOX_CARD_NOTES_MAX` 4,000 characters; null on a record from step 1), `threads` (roots), `refs` (links: `{ kind: "pr" | "issue", source: "github" | "gitlab" | …, ref, url }`; a record from step 1 has only `prs`, read as refs by `inboxCardRefsOf`; `prs` is still written with the PRs among the refs, for an older Inbox), `agent` (the session the card is for: the one that made it, or the one it was sent to; null for a card the person wrote and nobody was asked about), `sent` (`{ at, to, thread_id }`: the person sent it to an agent; the Board says "Sent to <agent>" with the delivery tick while it waits in Backlog, `isInboxCardSentWaiting`), `created_at` / `created_by`, `moved_at`, `heard_at` / `heard_by` (every write), `dropped` (`{ at, by, reason, seen_at }`), `aside_from` (where restore goes back), `idempotency_key`. The wire adds `heard_seq` (`InboxCardWire`): the store seq of the line that last wrote to the card (a line that only marks a drop seen is not a write, `isInboxCardSeenOnlyChange`), for the Board's unread rule. Nothing else is derived on the wire: a card shows its last-heard age from `heard_at` (the owner dropped amber, 2026-10-20: no rule decides a card is stalled). `inboxCardHistory` turns a card's snapshots into its history (the store keeps only each card's latest snapshot in memory; `cardHistory` reads the card's lines from its project's `cards.jsonl` when a card is opened) (created, moved, restored, dropped, said, you, linked, renamed, notes, sent), newest first; a line that changes nothing shown (the person seeing a drop) adds nothing. - Who moves a card (the store's rules, `createCard` / `updateCard` / `sendCard` in `packages/shared/inbox/store.ts`, shared by the tool, the CLI and the window): an agent makes cards in any live column (Backlog by default), moves its work through them, ships it, moves a re-report back to Backlog, and drops with a `reason`; only the person puts a card in the Icebox or moves one out (`card_icebox_person_only`, `card_iceboxed`): nothing happens to a card there and no agent moves it; only the person restores (`card_person_only`) or brings back a card they dropped (`card_dropped_by_person`); an agent may still `say` and link on such a card. An agent that moves a card into Your look or Your review gives `you`. A link's state never moves a card. - Links (`packages/shared/inbox/card-refs.ts`, pluggable): one parser per source turns what an agent or the person writes into a ref, fixed hosts first: `forgeParser` reads `owner/repo#123` (an issue, or a PR when it came as `pr`) and GitHub and GitLab issue, PR and MR URLs through `parseForgeRefUrl`; `prUrlParser` reads the other PR URLs `parsePRUrl` knows (Bitbucket, GitHub Enterprise). Anything else is refused (`INBOX_CARD_REF_HINT`). The full live state (the hover card) is read only when the person opens a card: `GET /api/inbox/cards/:id/refs` runs each ref through its source's resolver (`packages/server/inbox-card-refs.ts`; the one resolver today is the forge resolver over #2187's hover cards, `createRefCards`, with its rules: github.com, gitlab.com and the hosts gh or glab report signed in only, `--hostname` always passed, read only, its cache), and a source with no resolver answers `unsupported`. The opened card shows the number and title with the state in the hover card's words ("Merged Oct 20", "Open · all 2 checks passed", `refState`); on any failure, the number (and the title when known) quietly. A new source is a parser and a resolver. -- The call: the MCP tool `card` (`packages/server/inbox-mcp-cards.ts`, in `INBOX_MCP_TOOLS` and the contract's `INBOX_TOOL_ACTIONS`, so `plannotator_inbox` action `card` on the Claude Code mod, Pi and OpenCode; the mod's contract copy and bundled tool list follow). `op`: create (a `title`, or `thread_id` to link it and take its subject; `column`, `say`, `you`, `pr`, `ref`, `idempotency_key`), move (`column`, and links), say (`say`, `you`, `title`), link (`thread_id`, `pr`, `ref`), ship, drop (`reason`), list (the caller's cards in its project, live and icebox; `asked_by: "anyone"`, `include_resolved`). The fields each op takes are `INBOX_CARD_OP_FIELDS`; an op refuses another (a `false` default is tolerated). A card call never makes a project: `list` from a folder the Inbox never had a message from answers no cards and every other op is refused `card_no_project`; a git timeout is `project_unresolved`. The card and any thread must be in the caller's scope, else `card_other_project`. Results are text-complete (one line per card, with its links and notes). The shim fills `card` like `send_message`. `plannotator inbox --help` and the `plannotator` skill have its section. +- The call: the MCP tool `card` (`packages/server/inbox-mcp-cards.ts`, in `INBOX_MCP_TOOLS` and the contract's `INBOX_TOOL_ACTIONS`, so `plannotator_inbox` action `card` on the Claude Code mod, Pi and OpenCode; the mod's contract copy and bundled tool list follow). `op`: create (a `title`, or `thread_id` to link it and take its subject; `column`, `say`, `you`, `pr`, `ref`, `idempotency_key`), move (`column`, and links), say (`say`, `you`, `title`), link (`thread_id`, `pr`, `ref`), ship, drop (`reason`), list (the caller's cards in its project, live and icebox; `asked_by: "anyone"`, `include_resolved`). The fields each op takes are `INBOX_CARD_OP_FIELDS`; an op refuses another (a `false` default is tolerated). Titles (amendment 2026-10-27): every place agents learn the Board (the board prompt, `CARD_DESCRIPTION` and the `title`/`thread_id` arguments, `plannotator card --help`, `inbox --help`, the skill) says "2–5 words naming the work, never a sentence; status goes in `say`" and to link the thread where the person makes the call; a create, or a call that sets `title`, whose title is over `INBOX_CARD_TITLE_HINT_AT` (40) characters is kept and answered with a hint (`inboxCardTitleHint`: a line after the card line, `title_hint` in the structured answer), never refused. A card call never makes a project: `list` from a folder the Inbox never had a message from answers no cards and every other op is refused `card_no_project`; a git timeout is `project_unresolved`. The card and any thread must be in the caller's scope, else `card_other_project`. Results are text-complete (one line per card, with its links and notes). The shim fills `card` like `send_message`. `plannotator inbox --help` and the `plannotator` skill have its section. - `plannotator card create|move|say|link|ship|drop|list …` (`apps/hook/server/card-command.ts`, `--ref` for an issue or PR): a thin client, one `tools/call` to `/mcp`, starting a stopped Inbox detached like the shim. Inside a Claude Code or Pi session (`inboxHostSessionFromEnv`) the card is that session's. Exit 0, 1 on a refusal (stderr `<code>: <message>`), 2 on bad usage. Listed in `plannotator --help`. - The Board switch (owner, 2026-10-26, the approval's amendment: "board should be something that is enabled ... careful with the state machine"): **off by default**. One switch in Settings → Capabilities (`#settings/capabilities`, `data-settings-board`; moved out of General, the owner 2026-10-27), config.json `inboxBoard` (boolean), written through `POST /api/inbox/settings` `{ board: { on } }` (local only over the tailnet; `422` for anything but a boolean) and read on every request (`boardOn()` in `packages/server/inbox.ts`; `startInboxServer`'s `board` option overrides it for tests). Off: no sidebar row; `#board` shows "The Board is off" with **Turn it on** (`board/BoardOff.tsx`; over the tailnet a note instead); `card` is not in `/mcp` tools/list (`INBOX_DEFAULT_SERVED_TOOLS`), and a `card` call is answered `board_off: …` (`INBOX_BOARD_OFF_TEXT`) before anything is resolved or written, at the `/mcp` route (a session whose tool list still names the action) and again in `runCardCall`; `plannotator card` exits 1 with it; `plannotator --help` and `plannotator inbox --help` say nothing of cards (`formatTopLevelHelp` and `formatInboxHelp` take `board`, which the CLI reads from config.json; `inbox-help.test.ts` guards it); the window reads no cards (`refreshCards` runs only while it is on); the first-visit dialog is never claimed (`/api/inbox/board/intro` answers `show: false`). On: the row, the page and the action are served. Off again: every card is kept on disk as it was; nothing else in the Inbox reads the switch (routing, threads, the list, sections, the wake). The window's card routes are served either way (the page is not drawn while off). `INBOX_CARD_ACTION_ON` (`packages/core/inbox-cards.ts`) stays on as the code-level switch: off, the action is never registered whatever config.json says. `INBOX_TOOL_COST` is the size without `card` (the default), `INBOX_TOOL_COST_WITH_BOARD` with it. No timer, poll or lock: the switch is one config read per request. The states: @@ -75,14 +75,14 @@ | A `card` call in flight when it turns off | the call either wrote its card (the read came first) or answered `board_off` with nothing written; never half | | | Turned on again | the cards are as they were; agents get the action from their next session | | -- The Board page (`packages/inbox/board/`: `BoardPage.tsx`, `CardPanel.tsx`, `NewCardDialog.tsx`, `BoardIntro.tsx`, `boardPrompt.ts`, the rules pure in `model.ts`): the sidebar's Board row under Inbox (`#board`), while the Board is on, with no count (the approval keeps the count in the header). The header: Board, "N in flight · N on you" (no "on you" pill on a lane), the "?" (the first-visit dialog again), the Shipped and Icebox links (`#board=shipped`, `#board=icebox`) and New card. Five lanes, each card variant C "Lean": the agent's mark (a dashed ring for the person's card) and the title; then "You: …" in Your look and Your review, "Sent to <agent>" with the delivery tick, or the agent's last word (the person's notes when nobody spoke); the age. No counts on cards. The title is the button that opens the card (a click anywhere else on the card does too); "You: …" is a button of its own, a link with an arrow (owner, 2026-10-27: "When there's a You action, it should take me to the thread where I need to make the call"): it opens the thread where the person makes the call (`youThread`: the thread the card was sent in, else the linked thread with the latest message), with the same way back as a thread opened from the card; its name starts with its words (WCAG 2.5.3: "You: Merge #14, open the thread"), and on a phone it fills the bottom row at least 44 px tall. With no thread linked, "You: …" is plain words: no second tab stop, and a click on it opens the card, as anywhere on the card. The opened card has the same thread as **Open thread** in its You band (named by its words, described by what the person owes). The lanes share the width (each at least 188 px, `BOARD_LANE_MIN`) and scroll sideways when they cannot. With a card open they narrow to as many WHOLE lanes as fit (`lanesThatFit`, measured), snap to a lane, and show the window that holds the card's lane (`laneWindowStart`: centred on it, held at the ends), so no lane is cut at either edge. +- The Board page (`packages/inbox/board/`: `BoardPage.tsx`, `CardPanel.tsx`, `NewCardDialog.tsx`, `BoardIntro.tsx`, `boardPrompt.ts`, the rules pure in `model.ts`): the sidebar's Board row under Inbox (`#board`), while the Board is on, with no count (the approval keeps the count in the header). The header: Board, "N in flight · N on you" (no "on you" pill on a lane), the "?" (the first-visit dialog again), the Shipped and Icebox links (`#board=shipped`, `#board=icebox`) and New card. Five lanes, each card a "Title row" (approval `.product/approved/board-lane-card/`, r1, 2026-10-27; mocks A and B and r2 rejected): the title, wrapping under the marks, with the link mark, the agent's mark as a small circle (a dashed ring for the person's card; its name in a tooltip) and the age floated at the right of its first line (cut at three lines, whole in its tooltip, which measures the clamping button; WebKit cuts without an ellipsis; an unbreakable word such as a URL wraps anywhere). A second line only when the person owes something ("You: …" in Your look and Your review) or a send is pending ("Sent to <agent>" with the delivery tick); the agent's last word (and the notes) are not on the lane card: the opened card has them. No empty rows, no counts on cards. On a fine pointer the You link's arrow shows on hover or focus only. The title is the button that opens the card (a click anywhere else on the card does too); "You: …" is a button of its own, a link with an arrow (owner, 2026-10-27: "When there's a You action, it should take me to the thread where I need to make the call"): it opens the thread where the person makes the call (`youThread`: the thread the card was sent in, else the linked thread with the latest message), with the same way back as a thread opened from the card; its name starts with its words (WCAG 2.5.3: "You: Merge #14, open the thread"), and on a phone it fills the bottom row at least 44 px tall. With no thread linked, "You: …" is plain words: no second tab stop, and a click on it opens the card, as anywhere on the card. The opened card has the same thread as **Open thread** at its top (named by its words, described by what the person owes). The lanes share the width (each at least 188 px, `BOARD_LANE_MIN`) and scroll sideways when they cannot. With a card open they narrow to as many WHOLE lanes as fit (`lanesThatFit`, measured), snap to a lane, and show the window that holds the card's lane (`laneWindowStart`: centred on it, held at the ends), so no lane is cut at either edge. - Unread ("type"): a card is bold while an agent wrote to it in a line after the person's last look at the Board. By the store's seq, never by clock (two writes in one millisecond are told apart): the look is `inbox/board.json` (`{ v: 2, seen_seq }`, `packages/shared/inbox/board-seen.ts`, outside the event log), the cursor of the Board read the person opened (`POST /api/inbox/cards/seen` `{ through_seq }`, clamped to the store's cursor so a look never runs ahead, never moving back), and a card is unread when its `heard_seq` is past it (`isInboxCardUnread`). The page keeps the look it opened with for the whole visit, so news stays bold while the person looks, and a card they open or move this visit stops being bold at once. The person's own writes never make a card unread. Chosen over a per-card mark because a card has no thread to open: one look per Board visit is what "read" means for a board, as for a mail folder. - The agents' drops: "N dropped by an agent" above the lanes, each title, agent and reason with Restore, until the person looks ("Got it", or the Icebox page while it is in front: `POST /api/inbox/cards/dropped/seen` `{ cards: [ids] }`, only the drops the person was shown). The Icebox page lists the iced cards and Dropped (date, who, reason, Restore). -- The opened card (`#board&card=crd_…`): a panel beside the lanes (the lanes narrow and scroll so the card's lane stays in view); on a phone a full screen with "‹ Board". It holds the column as a Move menu (Base UI radio items, `menuitemradio` with `aria-checked`: the five lanes, the current one checked, Shipped, Icebox with its snowflake; Restore when aside: the keyboard's way to move), the agent, project and working state (`agentState`, from the live sessions) with "Ask where this is" (a short message to the card's agent, in its thread when that session is the card's own, else a new thread linked to the card), the You band, the notes (Edit notes), the threads (a click opens the thread in the Inbox view with "‹ Board" and a chip naming the card; the chip goes back to the card, on the Board list it was opened from: `&from=shipped` / `icebox` on the thread's address), the links with their live state and a field to link another, the history (short refs, "Linked #2018"; a Start into the card's own thread says so), and a foot: Ship · Icebox · Drop… (an inline reason). An agent's Backlog card has **Start** (`startBody`: its agent, or a live session in its project, is sent a short message in the card's thread with the title, notes and card id; nothing moves; the card says "Sent to <agent>"); a card nobody was asked about has **Send to an agent** (New card's dialog with only the agent to pick). Restore for an iced or dropped card (every Restore button runs once at a time, `OnceButton`). Escape closes it; focus goes back to the card in its lane (where a move put it), else the Board's heading. When the card's own agent has ended, "Ask where this is" says it asks another session in the project (`data-card-ask-other`). Drag a card to another lane on a desktop (the Move menu is the same move by keyboard). Every message the opened card sends (Start, Ask where this is) keeps one key per action across retries (`messageKey`: the key, the session and the words), so a retry after a lost answer replays the message and the send instead of sending twice; `sendCard` is a no-op for the same send. +- The opened card (`#board&card=crd_…`): a panel beside the lanes (the lanes narrow and scroll so the card's lane stays in view); on a phone a full screen with "‹ Board". It holds the column as a Move menu (Base UI radio items, `menuitemradio` with `aria-checked`: the five lanes, the current one checked, Shipped, Icebox with its snowflake; Restore when aside: the keyboard's way to move) and beside it **Open thread** (amendment 2026-10-27: the thread `youThread` picks, primary while the person owes something) or, when the card links no thread, **Create thread** (owner: "create, create, create is the idea": compose, addressed to the card's agent or else a live session in its project, as Ask is, starting with the card's title so the thread's subject is the title, with a line naming the card; that start left as it was counts as nothing written, so closing asks nothing; on Send the window links the new thread to the card, `POST /api/inbox/cards/:id` `{ op: "link", thread_id }`, and Open thread takes its place; kept as a draft, the draft keeps the card (`InboxDraft.card`, `{ id, title }`, checked on save), so the Send from Drafts links it too; disabled with "No agent is running in this project." while no session is live), the agent, project and working state (`agentState`, from the live sessions), the You band, the notes (Edit notes), the threads (a click opens the thread in the Inbox view with "‹ Board" and a chip naming the card; the chip goes back to the card, on the Board list it was opened from: `&from=shipped` / `icebox` on the thread's address), the links with their live state and a field to link another, the history (short refs, "Linked #2018"; a Start into the card's own thread says so), and a foot: Ship · Icebox · Drop… (an inline reason). An agent's Backlog card has **Start** (`startBody`: its agent, or a live session in its project, is sent a short message in the card's thread with the title, notes and card id; nothing moves; the card says "Sent to <agent>"); a card nobody was asked about has **Send to an agent** (New card's dialog with only the agent to pick). Restore for an iced or dropped card (every Restore button runs once at a time, `OnceButton`). Escape closes it; focus goes back to the card in its lane (where a move put it), else the Board's heading. The history's heading has **Ask for an update** (was "Ask where this is"; a quiet link, amendment 2026-10-27, mock opened-B): a short message to the card's agent, in its thread when that session is the card's own, else a new thread linked to the card; when the card's own agent has ended its tooltip says it asks another session in the project (`data-card-ask-other`). Drag a card to another lane on a desktop (the Move menu is the same move by keyboard). Every message the opened card sends (Start, Ask for an update) keeps one key per action across retries (`messageKey`: the key, the session and the words), so a retry after a lost answer replays the message and the send instead of sending twice; `sendCard` is a no-op for the same send. - New card (`NewCardDialog`): title, notes, links, the project (In), and Backlog (default) / Send to an agent / Icebox as plain buttons; the main button names what it does ("Add to Backlog", "Send to Codex", "Add to Icebox"). The three are one radio group ("Where it goes", native radios drawn as buttons). Send uses compose's live-session picker (`ToPicker`) and compose's send path, card first: the card in Backlog in the session's project, then a thread the person starts to that session with the notes as the message naming the card (`cardMessageBody`, which tells the agent card `crd_…` is now its), then the send (`sendCard`). A dialog makes one card whatever its retries: each try that makes it has its own key and its own words, and when a try's answer is lost the next try first replays that try exactly (its key, its words), which answers the card it made, then brings the card to what the dialog says now (title, notes, added links, Backlog or Icebox; when the person chose another project after a lost answer, the try is not replayed: the card it made is looked up by its key (none: nothing reached the server, nothing to drop), read again, dropped unless already dropped, and only then made in the new project; a drop that fails stops the try, so two live cards never stand). So editing the title after a lost answer never reuses a key with other words (`idempotency_key_reused`) and never makes a second card; the send's message keeps the words of its first try, so it never goes twice (`boardSend.test.tsx`). A session in a folder the Inbox has no project for yet takes the one-step path: the thread, then `POST /api/inbox/cards` with its `thread_id`, which the store checks before it writes anything (`createCard`'s `send_thread_id`: a thread that is not the person's leaves no card, and a key replays). - The first-visit dialog (`BoardIntro`, the sound dialog's Modal): the animated mini board (at rest in Your look under reduced motion), "Your Board", "Work in flight, by stage. Agents keep their cards current.", the prompt line, Copy prompt (a check and "Copied" for 2 s; Not now becomes Done; a polite live region) and Not now. Once per machine: `POST /api/inbox/board/intro`, a compare-and-set on config.json `inboxBoardIntro.shownAt`; a page reached over the tailnet never claims it. The empty Board shows "No cards yet · Copy prompt". The prompt (`boardPrompt`) names this computer's `plannotator` (settings `mcp_command`), asks the agent to list first and make one card per real piece of work, puts work not started in Backlog and leaves the Icebox to the person; `packages/server/inbox-board-prompt.test.ts` checks every op and argument it names against `INBOX_CARD_OP_FIELDS`. - Live: a card line on the event stream reads the cards again; the Board never polls. -- Phone (the approved step 2 mock): one stacked list, each lane a rounded pill that is its fold (Shipped and Icebox folded at the end), its cards flat rows under it; a bare "+" for New card and the "?", each a 44 px hit area; an opened card is a full screen. A card with nothing to say under its title (a shipped card whose "You:" went with the move, a card nobody spoke on, no link) is one line, the age beside the title. +- Phone (the approved step 2 mock): one stacked list, each lane a rounded pill that is its fold (Shipped and Icebox folded at the end), its cards flat rows under it; a bare "+" for New card and the "?", each a 44 px hit area; an opened card is a full screen. Cards are the same title row as on a desktop: a second line only for "You: …" or a pending "Sent to …". - Window routes (window guards; the reads refuse another site, `Sec-Fetch-Site` cross-site or same-site, or a foreign Origin, since cards carry notes; the owner over the tailnet too; not on the phone's door yet): `GET /api/inbox/cards` (`{ cards, dropped_unseen, seen_seq, threads }`: `threads` the list rows of the cards' threads, archived ones included), `POST /api/inbox/cards` (New card), `GET` and `POST /api/inbox/cards/:id` (the card and its history; the person's move, edit, link, send), `GET /api/inbox/cards/:id/refs`, `POST /api/inbox/cards/marks` (local only), `POST /api/inbox/cards/seen`, `POST /api/inbox/cards/dropped/seen`, `POST /api/inbox/board/intro` (see "Inbox Server"). - The lane-card mark (owner, 2026-10-26: "a symbol … to indicate merged vs unmerged"; card design C's PR mark): a lane card with a link shows one small icon in GitHub's shapes and colours for its latest PR, else its latest issue (`inboxCardMarkRef`): a PR open (green), draft (grey), merged (purple), closed (red); an issue open (green), completed (purple), not planned (grey); a quiet neutral icon while unknown (not read yet, a failure, a host not signed in, Bitbucket and other sources with no reader). The state is in the card button's accessible name ("…. Pull request #12, merged") and in a component tooltip ("#12 vite 8 for the review app · Merged"); the opened card's links use the same icons (`board/RefMark.tsx`). Display only: a link's state never moves a card. Read by `POST /api/inbox/cards/marks` `{ cards: [crd_ ids on screen] , refresh? }` → `{ marks: { [cardId]: InboxCardMark } }` (`packages/server/inbox-card-marks.ts`, `createCardMarks`): diff --git a/packages/core/inbox-cards.ts b/packages/core/inbox-cards.ts index 03e827652..f1a611db7 100644 --- a/packages/core/inbox-cards.ts +++ b/packages/core/inbox-cards.ts @@ -65,6 +65,19 @@ export const INBOX_CARD_COLUMN_LABELS: Readonly<Record<InboxCardColumn, string>> /** The longest title, in characters. */ export const INBOX_CARD_TITLE_MAX = 120; +/** + * Over this many characters a title gets a hint in the card call's answer + * (approval "inbox-board-amendments-2026-10-27": "2–5 words naming the work, + * never a sentence"; a gentle hint, never refused). 40 holds five long words. + */ +export const INBOX_CARD_TITLE_HINT_AT = 40; + +/** The hint for a title that reads like a sentence, or null for one short enough. */ +export function inboxCardTitleHint(title: string): string | null { + if ([...title.trim()].length <= INBOX_CARD_TITLE_HINT_AT) return null; + return `The title is ${[...title.trim()].length} characters. A title is 2–5 words naming the work ("Pi update fix"), never a sentence; where it stands goes in \`say\`. Shorten it with op say and \`title\`.`; +} + /** The longest one-line `say` or `you`, in characters. */ export const INBOX_CARD_LINE_MAX = 280; /** The longest drop reason, in characters. */ diff --git a/packages/core/inbox-types.ts b/packages/core/inbox-types.ts index dcddf5879..dae95d7e9 100644 --- a/packages/core/inbox-types.ts +++ b/packages/core/inbox-types.ts @@ -1745,10 +1745,16 @@ export type InboxToolCostHost = "pi" | "opencode"; * lists `card` only while it is on, so what a user gets by default is the * entry WITHOUT `card`, and that is the stated cost. With the Board on it is * the 10,171 bytes above. + * The title rule (approval "inbox-board-amendments-2026-10-27": "2–5 words + * naming the work, never a sentence", and link the thread where the person + * decides) in the card description and its `title` and `thread_id` + * arguments: measured on Pi with the cost test and the Board on, 10,171 → + * 10,303 bytes (+132; about +35 tokens, so the rounded, high token figure + * stands). */ const INBOX_TOOL_COST_NOW = { bytes: 8841, tokens: 2500 }; /** The entry with the Board on (the `card` action listed): for the switch's words, measured on Pi. */ -export const INBOX_TOOL_COST_WITH_BOARD = { bytes: 10171, tokens: 2900 } as const; +export const INBOX_TOOL_COST_WITH_BOARD = { bytes: 10303, tokens: 2900 } as const; export const INBOX_TOOL_COST: Readonly<Record<InboxToolCostHost, { bytes: number; tokens: number }>> = { pi: INBOX_TOOL_COST_NOW, opencode: INBOX_TOOL_COST_NOW, @@ -1872,6 +1878,12 @@ export interface InboxDraft { * out again from the body when the draft opens). */ thread_name?: string; + /** + * The Board card it was started for (the opened card's Create thread): the + * Send that sends the draft links the new thread to this card. Absent for + * any other draft. + */ + card?: { id: string; title: string }; } /** A draft's chip: its chosen session is live, has ended, or none was chosen. */ diff --git a/packages/inbox/App.tsx b/packages/inbox/App.tsx index c1660fd32..965fbc092 100644 --- a/packages/inbox/App.tsx +++ b/packages/inbox/App.tsx @@ -1626,7 +1626,16 @@ function Inbox() { onUndo: () => { dismissToast(); void inboxApi - .saveDraft(draft.id, { body: draft.body, to: draft.to, project: draft.project, files: draft.files.map((f) => f.upload_id), base: null }) + // Everything the draft kept: its typed thread name and the Board card it was started for (Create thread). + .saveDraft(draft.id, { + body: draft.body, + to: draft.to, + project: draft.project, + files: draft.files.map((f) => f.upload_id), + base: null, + ...(draft.thread_name ? { thread_name: draft.thread_name } : {}), + ...(draft.card ? { card: draft.card } : {}), + }) .then(() => refreshDrafts()) .then(() => say('Draft restored.')) .catch(() => showToast({ icon: 'warn', lead: 'The draft could not be restored.', wrap: true })); @@ -3295,6 +3304,7 @@ function Inbox() { }} onSeeDrops={seeDrops} onOpenThread={(card, threadId) => go({ page: 'inbox', project: null, thread: threadId, card: card.id, board: route.board ?? 'lanes' })} + onCreateThread={(card, session, opener) => openCompose({ session, card: { id: card.id, title: card.title } }, opener)} // The lane marks run this computer's gh / glab: never asked from a page opened over the tailnet. marks={settings !== null && settings.via !== 'tailnet'} /> @@ -3783,6 +3793,13 @@ function Inbox() { onSent={(sent) => { rememberSent(sent.session); composeSent(sent); + // Create thread from a Board card: the new thread is linked to the card, so its Open thread goes there. + if (sent.cardId) { + void inboxApi + .editCard(sent.cardId, { op: 'link', thread_id: sent.threadId }) + .then(() => refreshCards()) + .catch(() => showToast({ icon: 'warn', lead: 'Sent, but not linked to the card', detail: 'Open the thread from the Inbox.' }, 7000)); + } }} onSaved={composeSaved} onDeleted={(draft) => { diff --git a/packages/inbox/api.ts b/packages/inbox/api.ts index b31da1dcc..c5b79b678 100644 --- a/packages/inbox/api.ts +++ b/packages/inbox/api.ts @@ -865,7 +865,7 @@ export const inboxApi = { pinRestore: (restore: { thread_id: string; index: number; pinned_at: string }) => post<{ pins: { thread_id: string; pinned_at: string }[] }>('/api/inbox/pins', { restore }), discardReplyDraft: (threadId: string) => post<{ ok: true; deleted: boolean }>(`/api/inbox/threads/${encodeURIComponent(threadId)}/draft/delete`, {}), - saveDraft: (id: string, input: { body: string; to: InboxDraft['to']; project: InboxDraft['project']; files: string[]; base: string | null; thread_name?: string }) => + saveDraft: (id: string, input: { body: string; to: InboxDraft['to']; project: InboxDraft['project']; files: string[]; base: string | null; thread_name?: string; card?: InboxDraft['card'] }) => post<{ draft: DraftView }>(`/api/inbox/drafts/${encodeURIComponent(id)}`, { ...input }), deleteDraft: (id: string) => post<{ ok: true; deleted: boolean }>(`/api/inbox/drafts/${encodeURIComponent(id)}/delete`, {}), sessions: (threadId: string, asker?: string | null, recipients: readonly string[] = []) => { diff --git a/packages/inbox/board/BoardPage.tsx b/packages/inbox/board/BoardPage.tsx index a6edbf1c7..a1f81c68d 100644 --- a/packages/inbox/board/BoardPage.tsx +++ b/packages/inbox/board/BoardPage.tsx @@ -4,7 +4,7 @@ import type { ForgeRefCardResult } from '@plannotator/core/forge-ref-card'; import { inboxApi, type CardsModel, type ComposeSessionsModel, type ProjectFolder } from '../api'; import { formatCount, fullTime, whenTime } from '../format'; import { AuthorMark, Icon } from '../icons'; -import { Tip } from '../components/Tip'; +import { Tip, isTextCut } from '../components/Tip'; import { DeliveryTick } from '../components/DeliveryTick'; import { SidebarTrigger } from '../shell/sidebar'; import { pageInFront } from '../sound'; @@ -73,6 +73,8 @@ export interface BoardPageProps { onSeeDrops: (ids: readonly string[]) => void; /** A thread of the opened card opens in the Inbox view, with a way back to the card. */ onOpenThread: (card: InboxCardWire, threadId: string) => void; + /** Create thread (an opened card with no thread): compose to `session` (the card's agent, else a live session in its project), linked to the card on Send. */ + onCreateThread: (card: InboxCardWire, session: string | null, opener: HTMLElement) => void; /** Read the lane cards' marks (their linked PR's or issue's state). False on a page opened over the tailnet (and in tests that do not serve them). */ marks?: boolean; } @@ -349,6 +351,7 @@ export function BoardPage(props: BoardPageProps) { onSend={() => openSend(open)} onChanged={props.onChanged} onOpenThread={(threadId) => props.onOpenThread(open, threadId)} + onCreateThread={(session, opener) => props.onCreateThread(open, session, opener)} /> ) : null; @@ -629,14 +632,21 @@ const LANE_EMPTY: Partial<Record<InboxCardColumn, string>> = { your_review: 'Nothing waits on your review.', }; +/** A lane card's title is cut (clamped at three lines, or a word cut at the edge): its button overflows. */ +const titleCut = (trigger: Element) => isTextCut(trigger.closest('.ib-bc-main')); + /** - * One card, variant C "Lean": the agent's mark and the title; then "You: …" - * (the person's columns), "Sent to <agent>" with the delivery tick (a card - * the person sent, until it moves), or the agent's last word; and how long - * ago anyone last wrote to it. Bold while unread. The title is the button that - * opens the card (a click anywhere else on the card does too); "You: …" is its - * own button, to the thread where the person makes the call (`youThread`); - * with no thread linked it is plain words, and a click on it opens the card. + * One card, "Title row" (approval board-lane-card, r1, 2026-10-27): the + * title, with the linked PR's or issue's mark, the agent's small round mark (a + * dashed ring for the person's card) and how long ago anyone last wrote to it + * at the right of its first line. A second line only when the person owes + * something ("You: …", in the person's columns) or a send is pending ("Sent to + * <agent>" with the delivery tick, until it moves). The agent's last word is + * not on the lane card: the opened card has it. Bold while unread. The title + * is the button that opens the card (a click anywhere else on the card does + * too); "You: …" is its own button, to the thread where the person makes the + * call (`youThread`); with no thread linked it is plain words, and a click on + * it opens the card. */ function BoardCard({ card, @@ -662,13 +672,23 @@ function BoardCard({ const mark = markOf(card); const label = cardLabel(card, unread, now) + (sent ? `. Sent to ${sent}` : '') + (mark ? `. ${markLabel(mark)}` : ''); const thread = line?.you ? youThread(card, rowOf) : null; - // Nothing to say under the title (a shipped card whose "You:" went with the move, a card nobody spoke - // on): one line, the age beside the title, never an empty second line. - const oneLine = !sent && !line && !mark; - const ageTip = ( - <Tip content={`Last heard ${fullTime(card.heard_at)}`}> - <span className="ib-bc-age">{age.short.replace(' ago', '')}</span> - </Tip> + // "Title row" (approval board-lane-card, r1): a card is its title row, the marks and the age at its right. + // A second line only when the person owes something ("You: …") or a send is pending ("Sent to …"); the + // agent's last word is left off the lane card and stays in the opened card. Never an empty second line. + const oneLine = !sent && !line?.you; + const owner = cardOwnerName(card); + const meta = ( + <span className="ib-bc-meta"> + {mark && <RefMark mark={mark} />} + <Tip content={owner === 'You' ? 'Your card' : owner}> + <span className="ib-bc-who" data-host={card.agent?.kind === 'agent' ? card.agent.host : 'you'}> + {card.agent ? <AuthorMark author={card.agent} /> : <span className="ib-lane-dot ib-bc-yourdot" data-col="backlog" aria-hidden="true" />} + </span> + </Tip> + <Tip content={`Last heard ${fullTime(card.heard_at)}`}> + <span className="ib-bc-age">{age.short.replace(' ago', '')}</span> + </Tip> + </span> ); return ( <li @@ -694,21 +714,15 @@ function BoardCard({ onDragEnd={onDragEnd} > <span className="ib-bc-top"> - {card.agent ? ( - <AuthorMark author={card.agent} /> - ) : ( - <span className="ib-mk ib-bc-you" aria-hidden="true"> - <span className="ib-lane-dot ib-bc-yourdot" data-col="backlog" /> - </span> - )} <button type="button" className="ib-bc-main" aria-label={label} aria-current={openId === card.id || undefined} data-card-open="" onClick={() => onOpen(card)}> - <Tip content={card.title} cut> + {meta} + {/* The clamp (three lines) is on the button, not on the inline title: measure the button. */} + <Tip content={card.title} when={titleCut}> <span className="ib-bc-t" dir="auto"> {card.title} </span> </Tip> </button> - {oneLine && <span className="ib-bc-agetop">{ageTip}</span>} </span> {!oneLine && ( <span className="ib-bc-bot"> @@ -718,24 +732,18 @@ function BoardCard({ {delivery ? <DeliveryTick delivery={delivery} /> : <Icon name="clock" size={13} />} <span className="ib-bc-sentt">Sent to {sent}</span> </span> - ) : line ? ( - line.you && thread ? ( - <button type="button" className="ib-bc-youlink" data-card-you-link={thread} aria-label={youLinkLabel(line.text)} onClick={() => onOpenThread(card, thread)}> - <b className="ib-bc-youw">You:</b> <span className="ib-bc-yout">{line.text}</span> - <Icon name="arrowUpRight" size={12} /> - </button> - ) : line.you ? ( - // No thread to go to: plain words (a click on them opens the card, as anywhere on it), no second tab stop. - <> - <b className="ib-bc-youw">You:</b> {line.text} - </> - ) : ( - line.text - ) - ) : null} + ) : line?.you && thread ? ( + <button type="button" className="ib-bc-youlink" data-card-you-link={thread} aria-label={youLinkLabel(line.text)} onClick={() => onOpenThread(card, thread)}> + <b className="ib-bc-youw">You:</b> <span className="ib-bc-yout">{line.text}</span> + <Icon name="arrowUpRight" size={12} /> + </button> + ) : ( + // No thread to go to: plain words (a click on them opens the card, as anywhere on it), no second tab stop. + <> + <b className="ib-bc-youw">You:</b> {line?.text} + </> + )} </span> - {mark && <RefMark mark={mark} />} - {ageTip} </span> )} </li> @@ -986,6 +994,7 @@ function OpenedCard({ onSend, onChanged, onOpenThread, + onCreateThread, }: { card: InboxCardWire; projectName: string; @@ -1000,6 +1009,7 @@ function OpenedCard({ onSend: () => void; onChanged: (card?: InboxCardWire) => void; onOpenThread: (threadId: string) => void; + onCreateThread: (session: string | null, opener: HTMLElement) => void; }) { const [history, setHistory] = useState<InboxCardHistoryEntry[] | null>(null); const [refs, setRefs] = useState<{ ref: InboxCardRef; result: ForgeRefCardResult }[] | null>(null); @@ -1030,7 +1040,7 @@ function OpenedCard({ }; }, [card.id, refKey]); const agentSession = card.agent?.kind === 'agent' ? card.agent.session : null; - // "Ask where this is": the card's agent while it is live, else a live session in the card's project. + // "Ask for an update" and "Create thread": the card's agent while it is live, else a live session in the card's project. const askTarget = (() => { if (!sessions) return null; const own = findTarget(sessions.projects, agentSession); @@ -1123,6 +1133,7 @@ function OpenedCard({ } }} onOpenThread={onOpenThread} + onCreateThread={(opener) => onCreateThread(askTarget?.session ?? null, opener)} /> ); } diff --git a/packages/inbox/board/CardPanel.tsx b/packages/inbox/board/CardPanel.tsx index d2ef416e5..c56439c4f 100644 --- a/packages/inbox/board/CardPanel.tsx +++ b/packages/inbox/board/CardPanel.tsx @@ -30,9 +30,9 @@ export interface CardPanelProps { projectName: string; /** The agent's session now: working, idle, ended (not live), or unknown (null: no agent, or not read yet). */ state: 'working' | 'idle' | 'ended' | null; - /** Whether "Ask where this is" has a live session to go to (the card's agent, else one in its project). */ + /** Whether "Ask for an update" and "Create thread" have a live session to go to (the card's agent, else one in its project). */ canAsk: boolean; - /** Said under "Ask where this is" when it goes to another session (the card's own agent ended). */ + /** Said with "Ask for an update" when it goes to another session (the card's own agent ended). */ askNote?: string | null; rowOf: (threadId: string) => InboxListRow | null; phone: boolean; @@ -50,17 +50,23 @@ export interface CardPanelProps { onNotes: (notes: string | null) => Promise<boolean>; onLink: (ref: string) => Promise<string | null>; onOpenThread: (threadId: string) => void; + /** Create thread (a card with no thread): compose to the card's agent (or a live session in its project); the Send links the thread. */ + onCreateThread: (opener: HTMLElement) => void; } /** - * The opened card (approval "inbox-board", step 2, as mocked): a panel beside - * the Board (a full screen with "‹ Board" on a phone). The column is its Move - * menu; then the agent, the project and whether the agent is working, with - * "Ask where this is"; the "You: …" band; notes; threads (a click opens the + * The opened card (approval "inbox-board", step 2, as mocked; amended + * 2026-10-27): a panel beside the Board (a full screen with "‹ Board" on a + * phone). Its top: the column as its Move menu, then **Open thread** (the + * thread where the person makes the call, `youThread`; primary while they owe + * something) or, with no thread linked, **Create thread** (owner: "create, + * create, create is the idea"). Then the agent, the project and whether the + * agent is working; the "You: …" band; notes; threads (a click opens the * thread in the Inbox, with a way back); links (PRs, issues), their live - * state read when the card opens and never acted on; history; and at the - * foot Ship · Icebox · Drop… (with a reason), Send to an agent for a Backlog - * card nobody was asked about, Restore in the Icebox or Dropped. + * state read when the card opens and never acted on; history, with a quiet + * "Ask for an update"; and at the foot Ship · Icebox · Drop… (with a reason), + * Send to an agent for a Backlog card nobody was asked about, Restore in the + * Icebox or Dropped. */ export function CardPanel(props: CardPanelProps) { const { card, phone } = props; @@ -78,7 +84,11 @@ export function CardPanel(props: CardPanelProps) { const notSent = card.column === 'backlog' && !agent && !card.sent; // What the person owes, and the thread where they make that call (the lane card's "You: …" goes there too). const you = cardYou(card); - const youAt = you ? youThread(card, props.rowOf) : null; + const threadAt = youThread(card, props.rowOf); + const live = card.column !== 'dropped' && card.column !== 'icebox' && card.column !== 'shipped'; + const askable = (agent !== null || props.canAsk) && live; + // Create thread: a card that links no thread, unless it is dropped (nothing is said about a dropped card). + const creatable = threadAt === null && card.column !== 'dropped' && (agent !== null || props.canAsk); return ( <aside className={`ib-crdp${phone ? ' ib-crdp-phone' : ''}`} @@ -99,6 +109,23 @@ export function CardPanel(props: CardPanelProps) { </button> )} <ColumnMenu card={card} sent={sent !== null} onColumn={props.onColumn} onRestore={props.onRestore} /> + {threadAt ? ( + <Tip content={props.rowOf(threadAt)?.subject ?? 'The card’s thread'}> + <button + type="button" + className={`ib-btn ib-sm ib-crdp-open${you ? ' ib-pri' : ''}`} + data-card-open-thread={threadAt} + // Named by its words, "Open thread" (WCAG 2.5.3); what the person owes describes it. + aria-describedby={you ? `${headingId}-you` : undefined} + onClick={() => props.onOpenThread(threadAt)} + > + Open thread + <Icon name="arrowUpRight" size={14} /> + </button> + </Tip> + ) : creatable ? ( + <CreateThreadButton primary={you !== null} disabled={!props.canAsk} onCreate={props.onCreateThread} /> + ) : null} {!phone && ( <Tip content="Close"> <button type="button" className="ib-crdp-x" aria-label="Close the card" onClick={props.onClose} data-card-close=""> @@ -140,34 +167,11 @@ export function CardPanel(props: CardPanelProps) { )} </> )} - {(agent || props.canAsk) && card.column !== 'dropped' && card.column !== 'icebox' && card.column !== 'shipped' && ( - <AskButton - disabled={!props.canAsk} - onAsk={async () => { - const failed = await props.onAsk(); - setSaid(failed ?? `Asked ${agent ? inboxAgentName(agent) : 'an agent'} where “${card.title}” is.`); - }} - /> - )} - {props.askNote && props.canAsk && card.column !== 'dropped' && card.column !== 'icebox' && card.column !== 'shipped' && ( - <span className="ib-crdp-asknote" data-card-ask-other=""> - {props.askNote} - </span> - )} </div> {you && ( - <div className="ib-crdp-you" data-card-you=""> - <p id={`${headingId}-you`}> - <b>You:</b> {you} - </p> - {youAt && ( - // Named by its words, "Open thread" (WCAG 2.5.3); what the person owes describes it. - <button type="button" className="ib-btn ib-pri ib-sm" data-card-open-thread={youAt} aria-describedby={`${headingId}-you`} onClick={() => props.onOpenThread(youAt)}> - Open thread - <Icon name="arrowUpRight" size={14} /> - </button> - )} - </div> + <p className="ib-crdp-you" data-card-you="" id={`${headingId}-you`}> + <b>You:</b> {you} + </p> )} {card.column === 'dropped' && card.dropped && ( <p className="ib-crdp-dropped"> @@ -202,7 +206,20 @@ export function CardPanel(props: CardPanelProps) { <LinkField onLink={props.onLink} /> </section> <section className="ib-crdp-sec" aria-label="History"> - <h3>History</h3> + <h3 className="ib-crdp-hhead"> + History + {askable && ( + <AskButton + disabled={!props.canAsk} + note={props.canAsk ? (props.askNote ?? null) : null} + agentName={agent ? inboxAgentName(agent) : 'an agent'} + onAsk={async () => { + const failed = await props.onAsk(); + setSaid(failed ?? `Asked ${agent ? inboxAgentName(agent) : 'an agent'} for an update on “${card.title}”.`); + }} + /> + )} + </h3> {props.history === null ? ( <p className="ib-lane-empty">Reading…</p> ) : ( @@ -397,24 +414,49 @@ function StartButton({ disabled, onStart }: { disabled: boolean; onStart: () => return disabled ? <Tip content="No agent is running in this project.">{button}</Tip> : button; } -function AskButton({ disabled, onAsk }: { disabled: boolean; onAsk: () => Promise<void> }) { +/** + * "Ask for an update" (approved 2026-10-27, mock opened-B; was "Ask where + * this is"): a quiet link by History, where the answer shows. Its tooltip + * says who it asks (another session when the card's own agent ended). + */ +function AskButton({ disabled, note, agentName, onAsk }: { disabled: boolean; note: string | null; agentName: string; onAsk: () => Promise<void> }) { const [busy, setBusy] = useState(false); - const button = ( - <button - type="button" - className="ib-crdp-ask" - disabled={disabled || busy} - data-card-ask="" - onClick={async () => { - setBusy(true); - await onAsk(); - setBusy(false); - }} - > - Ask where this is - </button> + return ( + <Tip content={disabled ? 'No agent is running in this project.' : (note ?? `Asks ${agentName} to move the card or say where it stands`)}> + <button + type="button" + className="ib-crdp-ask" + disabled={disabled || busy} + data-card-ask="" + data-card-ask-other={note ? '' : undefined} + onClick={async () => { + setBusy(true); + await onAsk(); + setBusy(false); + }} + > + Ask for an update + </button> + </Tip> + ); +} + +/** Create thread: a card that links no thread starts one, in compose, to its agent; the Send links it to the card. */ +function CreateThreadButton({ primary, disabled, onCreate }: { primary: boolean; disabled: boolean; onCreate: (opener: HTMLElement) => void }) { + return ( + <Tip content={disabled ? 'No agent is running in this project.' : 'A new thread to the card’s agent, linked to this card'}> + <button + type="button" + className={`ib-btn ib-sm ib-crdp-open${primary ? ' ib-pri' : ''}`} + disabled={disabled} + data-card-create-thread="" + onClick={(event) => onCreate(event.currentTarget)} + > + <Icon name="plus" size={14} /> + Create thread + </button> + </Tip> ); - return disabled ? <Tip content="No agent is running in this project.">{button}</Tip> : button; } /** The person's notes: shown, and edited in place (Save keeps them; empty clears them). */ diff --git a/packages/inbox/board/boardPrompt.ts b/packages/inbox/board/boardPrompt.ts index 802eb72b4..4bdb8861d 100644 --- a/packages/inbox/board/boardPrompt.ts +++ b/packages/inbox/board/boardPrompt.ts @@ -28,7 +28,7 @@ Columns (where the work stands now; if it waits on me, that wins): 1. List the cards first (op \`list\` with \`asked_by: "anyone"\`). Never make a second card for work that has one; update it with \`move\`, \`say\` or \`link\`. 2. Look at the work you know about: this session, this repository's open branches, worktrees and pull requests, and your own notes and plans. One card per real piece of work. Skip trivial things, and work that has shipped or stopped. -3. Give each card a short \`title\`, its \`column\`, and \`say\`: your last word on where it stands, in one line. In your_look and your_review, put what I owe in \`you\` ("Merge #1963", "Pick one of three dialogs"). Link its PR with \`pr\` and an issue with \`ref\` (owner/repo#123 or the URL); more with op \`link\`, an Inbox thread with \`thread_id\`. +3. Give each card a \`title\` of 2–5 words naming the work ("Pi update fix"), never a sentence; where it stands goes in \`say\`, your last word in one line. Give its \`column\`. In your_look and your_review, put what I owe in \`you\` ("Merge #1963", "Pick one of three dialogs"). Link its PR with \`pr\` and an issue with \`ref\` (owner/repo#123 or the URL); more with op \`link\`. When a card waits on me, link the Inbox thread where I make the call (\`thread_id\`), so "You: …" opens it. 4. For work in another repository, pass that folder as \`project_path\`. If a call answers that the project has no Board yet, send me one short Inbox message from that project, then try again. 5. Change nothing else: no commits, pushes or PR comments. diff --git a/packages/inbox/board/model.ts b/packages/inbox/board/model.ts index baf1f4cca..47563053a 100644 --- a/packages/inbox/board/model.ts +++ b/packages/inbox/board/model.ts @@ -258,7 +258,7 @@ export function messageKey(key: string, session: string, body: string): string { return `${key}:${session}:${hash.toString(36)}`; } -/** "Where is this?": the message "Ask where this is" sends to the card's agent. */ +/** "Where is this?": the message "Ask for an update" sends to the card's agent. */ export function askWhereBody(card: Pick<InboxCardWire, 'id' | 'title'>): string { return `Where is “${card.title}”? (Board card ${card.id}) Move the card, or say where it stands with the card action.`; } diff --git a/packages/inbox/boardPage.test.tsx b/packages/inbox/boardPage.test.tsx index eeb69f10a..1b009a597 100644 --- a/packages/inbox/boardPage.test.tsx +++ b/packages/inbox/boardPage.test.tsx @@ -18,6 +18,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { InboxAuthor, InboxCardWire, InboxListRow } from '@plannotator/core/inbox-types'; import type { BoardMove, BoardPageProps } from './board/BoardPage'; +import { sized, tooltipOf, unhover } from './testing/tooltip'; const hasDom = typeof document !== 'undefined'; const { BoardPage } = (hasDom ? await import('./board/BoardPage') : {}) as typeof import('./board/BoardPage'); @@ -74,6 +75,12 @@ const YOU_CARDS: InboxCardWire[] = [ card('crd_two', { title: 'Caret anywhere', column: 'your_review', you: 'Merge #15', threads: ['msg_new', 'msg_old'] }), card('crd_none', { title: 'Settings search', column: 'your_look', you: 'Choose fuzzy or prefix' }), ]; +// The lane card's second line (approval board-lane-card, r1): only "You: …" or a pending "Sent to …", never the agent's last word. +const LINE_CARDS: InboxCardWire[] = [ + card('crd_said', { title: 'Hero scene timers', column: 'building', said: 'Option 3 of 6 rendered' }), + card('crd_owe', { title: 'Pricing page', column: 'your_look', you: 'Read the copy, then say go', said: 'Copy drafted' }), + card('crd_wait', { title: 'Auto-update', column: 'backlog', agent: null, said: 'Not started', sent: { at: NEW, to: CODEX, thread_id: 'msg_wait' } }), +]; const YOU_LAST: Record<string, string> = { msg_new: '2026-10-21T09:00:00.000Z', msg_old: '2026-10-20T09:00:00.000Z', msg_sent: '2026-10-19T09:00:00.000Z' }; const youRow = (id: string) => (YOU_LAST[id] ? ({ thread_id: id, subject: id, project: { id: 'prj_1', name: 'web' }, last_at: YOU_LAST[id] } as unknown as InboxListRow) : null); @@ -111,7 +118,11 @@ function scriptFetch(sessions: unknown[] = []) { const refs = url.pathname.match(/^\/api\/inbox\/cards\/([^/]+)\/refs$/); if (refs) return json({ refs: [] }); const one = url.pathname.match(/^\/api\/inbox\/cards\/([^/]+)$/); - if (one && method === 'GET') return json({ card: [...CARDS, ...YOU_CARDS].find((c) => c.id === one[1]), history: [{ kind: 'created', at: OLD, by: CLAUDE }] }); + if (one && method === 'GET') { + const found = [...CARDS, ...YOU_CARDS, ...LINE_CARDS].find((c) => c.id === one[1]); + // The agent's last word is in the opened card's history. + return json({ card: found, history: [...(found?.said ? [{ kind: 'said', at: NEW, by: CLAUDE, text: found.said }] : []), { kind: 'created', at: OLD, by: CLAUDE }] }); + } if (one && method === 'POST') return json({ card: { ...[...CARDS, ...YOU_CARDS].find((c) => c.id === one[1])!, sent: body?.op === 'send' ? { at: NEW, to: CLAUDE, thread_id: body.thread_id } : null } }); if (url.pathname === '/api/inbox/cards') return json({ card: card('crd_new', { title: String(body?.title), agent: null, notes: (body?.notes as string) ?? null }) }); if (/^\/api\/inbox\/threads\/[^/]+\/message$/.test(url.pathname)) return json({ message: { id: 'msg_start' }, replayed: false }); @@ -163,6 +174,7 @@ function draw(over: Partial<BoardPageProps> = {}): Drawn { drawn.seen += 1; }, onOpenThread: () => {}, + onCreateThread: () => {}, ...over, }; const render = () => act(() => root!.render(<SidebarProvider><BoardPage {...props} /></SidebarProvider>)); @@ -184,7 +196,7 @@ const $ = (selector: string) => document.querySelector<HTMLElement>(selector); const $$ = (selector: string) => [...document.querySelectorAll<HTMLElement>(selector)]; const cardEl = (id: string) => $(`.ib-bcard[data-card-id="${id}"]`); -test.if(hasDom)('the lanes in order, a card’s two lines, bold while unread, no "on you" pill', async () => { +test.if(hasDom)('the lanes in order, the title row, bold while unread, no "on you" pill', async () => { scriptFetch(); draw(); await settle(); @@ -193,7 +205,9 @@ test.if(hasDom)('the lanes in order, a card’s two lines, bold while unread, no expect($('[data-board-link="shipped"]')!.textContent).toBe('Shipped 1'); expect($('[data-board-link="icebox"]')!.textContent).toBe('Icebox 1'); expect(cardEl('crd_price')!.querySelector('.ib-bc-line')!.textContent).toBe('You: Read the copy, then say go'); - expect(cardEl('crd_hero')!.querySelector('.ib-bc-line')!.textContent).toBe('Option 3 of 6 rendered'); + // The marks and the age sit in the title row, in this order; a card with nothing owed or pending is that row alone. + expect([...cardEl('crd_vite')!.querySelectorAll('.ib-bc-top .ib-bc-meta > *')].map((el) => el.className.split(' ')[0])).toEqual(['ib-bc-mark', 'ib-bc-who', 'ib-bc-age']); + expect(cardEl('crd_vite')!.querySelector('.ib-bc-bot')).toBeNull(); // Heard after the look: bold. Before it: not. expect(cardEl('crd_auto')!.hasAttribute('data-unread')).toBe(true); expect(cardEl('crd_hero')!.hasAttribute('data-unread')).toBe(false); @@ -206,6 +220,39 @@ test.if(hasDom)('the lanes in order, a card’s two lines, bold while unread, no expect(calls.some((c) => c.path.startsWith('/api/inbox/cards/'))).toBe(false); }); +test.if(hasDom)('a second line only when the person owes something or a send is pending; the agent’s last word only in the opened card', async () => { + scriptFetch(); + draw({ model: { serverSession: 's', cursor: 1, cards: LINE_CARDS, dropped_unseen: 0, seen_seq: LOOK }, openCard: 'crd_said' }); + await settle(); + const second = (id: string) => cardEl(id)!.querySelector('.ib-bc-bot .ib-bc-line')?.textContent ?? null; + expect(second('crd_owe')).toBe('You: Read the copy, then say go'); + expect(second('crd_wait')).toBe('Sent to Codex'); + expect(second('crd_said')).toBeNull(); + // Never the agent's word on a lane card, whether or not it has a second line. + for (const c of LINE_CARDS) expect(cardEl(c.id)!.textContent).not.toContain(c.said!); + // The opened card has it. + await settle(); + expect($('[data-card-panel="crd_said"]')!.textContent).toContain('Option 3 of 6 rendered'); +}); + +// Review of #2248: the title is inline, so its own box never reads as cut; the clamp is on the title button. +// What regresses: a title cut at three lines (or a long word cut at the edge) with no way to read it whole. +test.if(hasDom)('a lane card title cut by its clamp opens the full title in a tooltip; one that fits opens none', async () => { + scriptFetch(); + draw({ model: { serverSession: 's', cursor: 1, cards: LINE_CARDS, dropped_unseen: 0, seen_seq: LOOK } }); + await settle(); + const title = (id: string) => cardEl(id)!.querySelector<HTMLElement>('.ib-bc-t')!; + const main = (id: string) => cardEl(id)!.querySelector<HTMLElement>('.ib-bc-main')!; + // Clamped: the button's content is taller than the button. + Object.defineProperty(main('crd_said'), 'scrollHeight', { configurable: true, get: () => 66 }); + Object.defineProperty(main('crd_said'), 'clientHeight', { configurable: true, get: () => 59 }); + expect(await tooltipOf(title('crd_said'))).toBe('Hero scene timers'); + unhover(title('crd_said')); + await settle(); + sized(main('crd_owe'), 180, 180); + expect(await tooltipOf(title('crd_owe'))).toBeNull(); +}); + test.if(hasDom)('"You: …" opens the thread where the person makes the call, a target apart from opening the card', async () => { scriptFetch(); const threads: { card: string; thread: string }[] = []; @@ -240,27 +287,38 @@ test.if(hasDom)('"You: …" opens the thread where the person makes the call, a expect(threads.length).toBe(2); }); -test.if(hasDom)('the opened card puts Open thread at the top when the person owes something, to the same thread', async () => { - scriptFetch(); +test.if(hasDom)('the opened card: Open thread at the top to the same thread; with no thread, Create thread to the card’s live agent', async () => { + scriptFetch([{ project: { id: 'prj_1', name: 'web', root: '/Users/ada/src/web' }, sessions: [{ session: 'ses_claude', host: 'claude-code', started_at: OLD, last_seen_at: NEW, busy: false, idle_since: NEW, last_thread: null }] }]); const threads: { card: string; thread: string }[] = []; + const created: { card: string; session: string | null }[] = []; const drawn = draw({ model: { serverSession: 's', cursor: 1, cards: YOU_CARDS, dropped_unseen: 0, seen_seq: LOOK }, rowOf: youRow, openCard: 'crd_two', onOpenThread: (target, thread) => threads.push({ card: target.id, thread }), + onCreateThread: (target, session) => created.push({ card: target.id, session }), }); await settle(); - const open = $('[data-card-panel] [data-card-you] [data-card-open-thread]') as HTMLButtonElement; + const open = $('[data-card-panel] .ib-crdp-top [data-card-open-thread]') as HTMLButtonElement; // Named by its own words (no aria-label to drift from them), described by what the person owes. expect(open.hasAttribute('aria-label')).toBe(false); expect(document.getElementById(open.getAttribute('aria-describedby')!)!.textContent).toContain('Merge #15'); + expect($('[data-card-create-thread]')).toBeNull(); act(() => open.click()); expect(threads).toEqual([{ card: 'crd_two', thread: 'msg_new' }]); - // No thread to go to: no button. + // Ask for an update: by History, never beside the agent. + expect($('[data-card-panel] .ib-crdp-hhead [data-card-ask]')!.textContent).toBe('Ask for an update'); + expect($('[data-card-panel] .ib-crdp-who [data-card-ask]')).toBeNull(); + // No thread linked: no Open thread; Create thread goes to the card’s live agent. drawn.rerender({ openCard: 'crd_none' }); await settle(); - expect($('[data-card-panel] [data-card-you]')!.textContent).toContain('Choose fuzzy or prefix'); expect($('[data-card-open-thread]')).toBeNull(); + const create = $('[data-card-panel] .ib-crdp-top [data-card-create-thread]') as HTMLButtonElement; + expect(create.textContent).toBe('Create thread'); + expect(create.disabled).toBe(false); + act(() => create.click()); + expect(created).toEqual([{ card: 'crd_none', session: 'ses_claude' }]); + expect(threads.length).toBe(1); }); test.if(hasDom)('the agents’ drops show above the lanes with their reason; Got it says the person looked', async () => { diff --git a/packages/inbox/boardSend.test.tsx b/packages/inbox/boardSend.test.tsx index 60c515ec0..417b9f03f 100644 --- a/packages/inbox/boardSend.test.tsx +++ b/packages/inbox/boardSend.test.tsx @@ -184,6 +184,7 @@ async function drawBoard(openCard: string | null) { onChanged: () => {}, onSeeDrops: () => {}, onOpenThread: () => {}, + onCreateThread: () => {}, }; act(() => root!.render(<SidebarProvider><BoardPage {...props} /></SidebarProvider>)); } diff --git a/packages/inbox/compose.test.tsx b/packages/inbox/compose.test.tsx index 93a35622f..5fb6ed0d9 100644 --- a/packages/inbox/compose.test.tsx +++ b/packages/inbox/compose.test.tsx @@ -714,3 +714,81 @@ test.skipIf(!hasDom)('dictation or a paste into the chip that cannot be a name i expect(paste('this is a sentence the person said out loud').defaultPrevented).toBe(true); expect(paste('Short').defaultPrevented).toBe(false); }); + +// Create thread (owner, 2026-10-27: a card with no thread offers "Create thread": "create, create, create is +// the idea"). What regresses: the thread not linked to the card after the Send (Open thread never appears), +// compose addressed to nobody, or a card's title the person did not change counted as words to keep. +test.skipIf(!hasDom)('Create thread on a card with no thread: compose to a live session in its project, starting with the title; the Send links the thread to the card', async () => { + expect((await loopback('/api/inbox/settings', 'POST', JSON.stringify({ board: { on: true } }))).status).toBe(200); + // The first-visit dialog is claimed here, so it never covers the card. + await loopback('/api/inbox/board/intro', 'POST', '{}'); + const project = (JSON.parse((await loopback('/api/inbox/projects')).text) as { projects: { id: string; root: string }[] }).projects[0]!; + const made = JSON.parse((await loopback('/api/inbox/cards', 'POST', JSON.stringify({ project: project.id, title: 'Settings search' }))).text) as { card: { id: string; threads: string[] } }; + expect(made.card.threads).toEqual([]); + await poll('ses_create_thread', 'claude-code', project.root); + const el = await renderApp(`#board&card=${made.card.id}`); + await until(() => el.querySelector<HTMLButtonElement>('[data-card-create-thread]:not(:disabled)') !== null, 'Create thread'); + expect(el.querySelector('[data-card-open-thread]')).toBeNull(); + act(() => el.querySelector<HTMLButtonElement>('[data-card-create-thread]')!.click()); + await until(() => dialog()?.querySelector('[data-compose-to="ses_create_thread"]') != null, 'compose to the live session'); + expect(dialog()!.querySelector('[data-compose-card]')!.textContent).toContain('Settings search'); + expect((dialog()!.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Settings search\n\n'); + // Untouched, it is nothing to keep: Escape closes without asking. + key('Escape'); + await until(() => dialog() === null, 'closed without asking'); + act(() => el.querySelector<HTMLButtonElement>('[data-card-create-thread]')!.click()); + await until(() => dialog()?.querySelector('[data-compose-to="ses_create_thread"]') != null, 'compose again'); + type('Settings search\n\nFuzzy or prefix matching? Say which and why.'); + act(() => (dialog()!.querySelector('[data-compose-send]') as HTMLButtonElement).click()); + await until(() => dialog() === null, 'sent'); + const thread = await threadIdOf('Settings search'); + await until(async () => (JSON.parse((await loopback(`/api/inbox/cards/${made.card.id}`)).text) as { card: { threads: string[] } }).card.threads.includes(thread), 'the thread linked to the card'); + await until(() => el.querySelector(`[data-card-open-thread="${thread}"]`) !== null, 'Open thread on the card'); + expect(el.querySelector('[data-card-create-thread]')).toBeNull(); +}); + +// Owner's follow-up to Create thread (2026-10-27): a draft kept from it must still link the thread when it is +// sent later, from Drafts. What regresses: the draft dropping the card, so the later Send starts an unlinked +// thread and the card keeps offering Create thread. +test.skipIf(!hasDom)('Create thread kept as a draft: the draft keeps the card; sent later from Drafts, the thread is linked to the card', async () => { + expect((await loopback('/api/inbox/settings', 'POST', JSON.stringify({ board: { on: true } }))).status).toBe(200); + await loopback('/api/inbox/board/intro', 'POST', '{}'); + const project = (JSON.parse((await loopback('/api/inbox/projects')).text) as { projects: { id: string; root: string }[] }).projects[0]!; + const made = JSON.parse((await loopback('/api/inbox/cards', 'POST', JSON.stringify({ project: project.id, title: 'Draft card link' }))).text) as { card: { id: string } }; + await poll('ses_draft_card', 'claude-code', project.root); + const el = await renderApp(`#board&card=${made.card.id}`); + await until(() => el.querySelector<HTMLButtonElement>('[data-card-create-thread]:not(:disabled)') !== null, 'Create thread'); + act(() => el.querySelector<HTMLButtonElement>('[data-card-create-thread]')!.click()); + await until(() => dialog()?.querySelector('[data-compose-to="ses_draft_card"]') != null, 'compose to the live session'); + type('Draft card link\n\nWhich of the two search modes?'); + key('Escape'); + act(() => (dialog()!.querySelector('[data-compose-keep]') as HTMLButtonElement).click()); + await until(() => dialog() === null, 'kept as a draft'); + const kept = (await drafts()).find((d) => d.body.startsWith('Draft card link')) as unknown as { id: string; card?: { id: string; title: string } }; + expect(kept.card).toEqual({ id: made.card.id, title: 'Draft card link' }); + // Nothing sent yet: the card still has no thread. + expect((JSON.parse((await loopback(`/api/inbox/cards/${made.card.id}`)).text) as { card: { threads: string[] } }).card.threads).toEqual([]); + + // Deleted from Drafts and brought back with Undo: it still names the card (review of #2248). + act(() => (el.querySelector('[data-drafts-nav]') as HTMLButtonElement).click()); + await until(() => el.querySelector(`[data-draft-id="${kept.id}"]`) !== null, 'the draft row'); + act(() => (el.querySelector(`[data-draft-id="${kept.id}"] [data-draft-delete]`) as HTMLButtonElement).click()); + await until(() => el.querySelector(`[data-draft-id="${kept.id}"]`) === null, 'the row gone'); + act(() => (document.querySelector('[data-toast-undo]') as HTMLButtonElement).click()); + await until(() => el.querySelector(`[data-draft-id="${kept.id}"]`) !== null, 'the draft back'); + const restored = (await drafts()).find((d) => d.id === kept.id) as unknown as { card?: { id: string; title: string } }; + expect(restored.card).toEqual({ id: made.card.id, title: 'Draft card link' }); + + // Later, from Drafts: the draft reopens naming the card, and its Send links the thread. + act(() => (el.querySelector(`[data-draft-id="${kept.id}"] .ib-draft-open`) as HTMLButtonElement).click()); + await until(() => dialog()?.querySelector(`[data-compose-card="${made.card.id}"]`) != null, 'the draft names the card'); + await until(() => dialog()!.querySelector('[data-compose-to="ses_draft_card"]') !== null, 'To keeps the session'); + act(() => (dialog()!.querySelector('[data-compose-send]') as HTMLButtonElement).click()); + await until(() => dialog() === null, 'sent'); + const thread = await threadIdOf('Draft card link'); + await until(async () => (JSON.parse((await loopback(`/api/inbox/cards/${made.card.id}`)).text) as { card: { threads: string[] } }).card.threads.includes(thread), 'the thread linked to the card'); + // A draft naming something that is not a card is refused, not kept with a broken link. + const refused = await loopback(`/api/inbox/drafts/drf_${'0'.repeat(26)}`, 'POST', JSON.stringify({ body: 'x', base: null, card: { id: 'msg_x', title: 'x' } })); + expect(refused.status).toBe(422); + expect(JSON.parse(refused.text)).toMatchObject({ code: 'validation_error', field: 'card' }); +}); diff --git a/packages/inbox/compose/ComposeDialog.tsx b/packages/inbox/compose/ComposeDialog.tsx index 69aca7c9e..d0184c7bf 100644 --- a/packages/inbox/compose/ComposeDialog.tsx +++ b/packages/inbox/compose/ComposeDialog.tsx @@ -61,6 +61,14 @@ export interface ComposeRequest { body?: string; /** A session to put in To (the palette's "Message <host> in <project>"). */ session?: string | null; + /** + * A Board card with no thread (its opened card's "Create thread"): the + * message starts with the card's title, so the thread's subject is the + * title, and the window links the sent thread to the card (`ComposeSent.cardId`). + * Saved as a draft, the draft keeps the card (`InboxDraft.card`), so sending + * it later from Drafts still links the thread. + */ + card?: { id: string; title: string } | null; } export interface ComposeSent { @@ -70,6 +78,8 @@ export interface ComposeSent { projectName: string; /** The draft it was sent from (it is deleted with the Send), or null. */ draftId: string | null; + /** The Board card the thread was created for (`ComposeRequest.card`): link the thread to it. */ + cardId?: string | null; } /** @@ -122,7 +132,9 @@ export function ComposeDialog(props: ComposeDialogProps) { const statusId = `ib-cp-status-${uid}`; const [draft, setDraft] = useState<DraftView | null>(request.draft ?? null); - const [body, setBody] = useState(request.draft?.body ?? request.body ?? ''); + const [body, setBody] = useState(request.draft?.body ?? request.body ?? (request.card ? `${request.card.title}\n\n` : '')); + // The Board card this thread is for (Create thread), kept on its draft so a draft sent later still links it. + const card = request.card ?? request.draft?.card ?? null; // The thread's name (its chip): follows the message until the person types in it (ThreadNameChip). const [nameState, setNameState] = useState<ThreadNameState>(() => threadNameStateOf(request.draft)); const [chosen, setChosen] = useState<string | null>(opening.chosen); @@ -188,9 +200,12 @@ export function ComposeDialog(props: ComposeDialogProps) { const draftId = useRef(request.draft?.id ?? inboxId('drf')); // Nothing written: no words, no files, no typed thread name. An empty compose is never kept as a draft. - const empty = body.trim() === '' && files.files.length === 0 && threadName.trim() === ''; + // A Board card's Create thread starts with the card's title: left as it was, that is nothing written either. + const cardStart = request.card && !request.draft ? `${request.card.title}\n\n` : null; + const untouched = cardStart !== null && body === cardStart && files.files.length === 0 && threadName.trim() === ''; + const empty = untouched || (body.trim() === '' && files.files.length === 0 && threadName.trim() === ''); const saved = draftSnapshot(draft); - const dirty = isDirty({ body, session: chosen, files: uploadIds, threadName }, saved, draft !== null) || files.uploading; + const dirty = !untouched && (isDirty({ body, session: chosen, files: uploadIds, threadName }, saved, draft !== null) || files.uploading); const projectName = current?.name ?? null; // ── the dialog ── @@ -272,7 +287,7 @@ export function ComposeDialog(props: ComposeDialogProps) { ...named, ...(draft ? { draft_id: draft.id } : {}), }); - props.onSent({ threadId: result.thread_id, session: target.session, host: target.host, projectName: result.project?.name ?? target.project.name, draftId: draft?.id ?? null }); + props.onSent({ threadId: result.thread_id, session: target.session, host: target.host, projectName: result.project?.name ?? target.project.name, draftId: draft?.id ?? null, cardId: card?.id ?? null }); } catch (cause) { if (cause instanceof InboxApiError && cause.code === 'session_not_live') { void load(); @@ -302,7 +317,7 @@ export function ComposeDialog(props: ComposeDialogProps) { const project = target ? { id: target.project.id, root: target.project.root, name: target.project.name } : (draft?.project ?? (current?.root ? { id: current.id, root: current.root, name: current.name ?? current.root } : null)); - const result = await inboxApi.saveDraft(draftId.current, { body, to, project, files: uploadIds, base, thread_name: threadName }); + const result = await inboxApi.saveDraft(draftId.current, { body, to, project, files: uploadIds, base, thread_name: threadName, ...(card ? { card } : {}) }); setDraft(result.draft); setConflict(null); return result.draft; @@ -522,6 +537,13 @@ export function ComposeDialog(props: ComposeDialogProps) { </h2> )} {toRow} + {card && ( + <p className="ib-cp-card" data-compose-card={card.id}> + <span> + A thread for the Board card <b dir="auto">{card.title}</b>. Sending links it to the card. + </span> + </p> + )} {picker && ( <ToPicker groups={groups} diff --git a/packages/inbox/inbox.css b/packages/inbox/inbox.css index 00197d7ad..abf8d864d 100644 --- a/packages/inbox/inbox.css +++ b/packages/inbox/inbox.css @@ -3172,6 +3172,9 @@ dialog.ib-cp::backdrop { background: oklch(0 0 0 / 0.45); } dialog.ib-cp[open]::backdrop { animation: ib-sp-fade 150ms ease-out; } } .ib-cp-to { position: relative; display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 6px 14px 6px 16px; border-bottom: 1px solid var(--ib-line); flex: none; } +/* Create thread from a Board card: which card the new thread is for. */ +.ib-cp-card { display: flex; align-items: center; gap: 8px; margin: 0; padding: 8px 16px; border-bottom: 1px solid var(--ib-line); font-size: var(--ib-fs-13, 13px); color: var(--muted-foreground); flex: none; } +.ib-cp-card b { color: var(--foreground); font-weight: 600; } .ib-cp-tol { color: var(--muted-foreground); font-size: var(--ib-fs-13-5, 13.5px); flex: none; } .pn-inbox .ib-cp-tobtn { display: inline-flex; align-items: center; gap: 7px; min-width: 0; max-width: 100%; height: 34px; padding: 0 8px; border-radius: 7px; border: 0; background: transparent; color: var(--foreground); font-size: var(--ib-fs-14, 14px); text-align: left; } .pn-inbox .ib-cp-tobtn:hover:not(:disabled), .pn-inbox .ib-cp-tobtn[aria-expanded="true"] { background: color-mix(in oklch, var(--muted) 70%, transparent); } @@ -4866,15 +4869,36 @@ dialog.ib-dm::backdrop { background: oklch(0 0 0 / 0.45); } thread, not the card; the row takes the card's bottom padding and the gap above it for the height. */ .ib-bstack .ib-bc-bot:has(.ib-bc-youlink) { align-items: center; margin: -4px 0 -10px; } .ib-bstack .ib-bc-youlink { display: flex; align-items: center; width: 100%; min-height: 44px; } -.ib-bc-top { display: flex; align-items: center; gap: 8px; min-width: 0; padding-right: 22px; } +.ib-bc-top { display: flex; align-items: center; gap: 8px; min-width: 0; } .ib-bc-t { min-width: 0; flex: 0 1000000 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--ib-fs-14, 14px); font-weight: 500; color: color-mix(in oklab, var(--foreground) 72%, var(--card)); } .ib-bcard[data-unread] .ib-bc-t { font-weight: 650; color: var(--foreground); } -.ib-bc-agetop { flex: none; margin-left: auto; margin-right: -22px; font-size: var(--ib-fs-13, 13px); color: var(--muted-foreground); } -.ib-bc-you { color: var(--muted-foreground); } -.ib-bc-bot { display: flex; align-items: baseline; gap: 8px; min-width: 0; padding-left: calc(var(--ib-mark-16) + 8px); font-size: var(--ib-fs-13, 13px); color: var(--muted-foreground); } +.ib-bc-bot { display: flex; align-items: baseline; gap: 8px; min-width: 0; font-size: var(--ib-fs-13, 13px); color: var(--muted-foreground); } .ib-bc-line { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 2px; margin: -2px; } .ib-bc-youw { color: var(--ib-onyou-fg); font-weight: 600; } .ib-bc-age { flex: none; font-size: var(--ib-fs-12-5, 12.5px); font-variant-numeric: tabular-nums; white-space: nowrap; } +/* The bottom right (owner, 2026-10-27): the link mark, the agent as a small round mark, the age. The title and its line take the whole width. */ +.ib-bc-meta { flex: none; display: inline-flex; align-items: center; gap: 6px; align-self: center; margin-left: auto; } +.ib-bc-who { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: color-mix(in oklch, var(--muted) 90%, var(--foreground) 4%); } +.ib-bc-who .ib-mk, .ib-bc-who .ib-mk > svg, .ib-bc-who .ib-mk > img { width: var(--ib-mark-16-g); height: var(--ib-mark-16-g); } +.ib-bc-who[data-host="you"] { background: none; } +/* "Title row" (approval board-lane-card, r1): the marks and the age float at the right of the title's first + line and the title wraps under them; the line under it ("You: …", "Sent to …") gets the card's whole width; + the You link's arrow only on hover or focus. */ +.ib-bpage .ib-bcard { gap: 3px; padding: 9px 12px; } +/* At most three lines: a sentence-long title is cut; its tooltip has it all (BoardPage measures this button, `titleCut`). */ +.ib-bpage .ib-bcard .ib-bc-main { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; flex: 1 1 auto; text-align: left; line-height: 1.4; overflow: hidden; } +.ib-bpage .ib-bcard .ib-bc-t { display: inline; white-space: normal; overflow: visible; overflow-wrap: anywhere; line-height: 1.4; } +/* WebKit lays a -webkit-box out as a box, so the float would sit on top: a plain block cut at three lines there. */ +@supports (font: -apple-system-body) { + .ib-bpage .ib-bcard .ib-bc-main { display: block; max-height: calc(1.4em * 3); } +} +.ib-bpage .ib-bcard .ib-bc-meta { float: right; display: inline-flex; height: calc(var(--ib-fs-14, 14px) * 1.4); margin: 0 0 0 8px; gap: 5px; } +.ib-bpage .ib-bcard .ib-bc-age { font-size: var(--ib-fs-12, 12px); color: var(--muted-foreground); } +@media (hover: hover) and (pointer: fine) { + .ib-bpage .ib-bcard .ib-bc-youlink > svg { display: none; } + .ib-bpage .ib-bcard .ib-bc-youlink:is(:hover, :focus-visible) > svg { display: inline; } +} +.ib-bc-who > .ib-bc-yourdot { width: 13px; height: 13px; } /* A linked PR's or issue's state (inbox-card-marks): GitHub's Octicon shapes and colours, the dark values first and the light ones below; unknown is the same shape, quiet. */ .pn-inbox { @@ -4948,8 +4972,6 @@ dialog.ib-dm::backdrop { background: oklch(0 0 0 / 0.45); } /* A bare "+" (the mock), its hit area still 44 px. */ .pn-inbox .ib-bnew { width: 44px; height: 44px; padding: 0; justify-content: center; border-color: transparent; background: transparent; box-shadow: none; color: var(--foreground); } .ib-bcard { padding: 12px; } - .ib-bc-top { padding-right: 0; } - .ib-bc-agetop { margin-right: 0; } .ib-bc-t { white-space: normal; overflow-wrap: anywhere; font-size: var(--ib-fs-15, 15px); } .ib-bdrops { margin: 10px 12px; } .ib-bdrops li { flex-wrap: wrap; } @@ -4998,14 +5020,17 @@ dialog.ib-dm::backdrop { background: oklch(0 0 0 / 0.45); } .ib-crdp-sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted-foreground); } .ib-crdp-state[data-agent-state="working"] .ib-crdp-sdot { background: var(--success, oklch(0.6 0.15 150)); } .ib-crdp-state[data-agent-state="ended"] .ib-crdp-sdot { background: none; box-shadow: inset 0 0 0 1.5px var(--muted-foreground); } -.ib-crdp-ask { margin-left: auto; border: 0; background: none; color: var(--muted-foreground); font: inherit; font-size: var(--ib-fs-14, 14px); padding: 4px 6px; border-radius: 6px; cursor: pointer; } -.ib-crdp-ask:hover:not(:disabled) { color: var(--foreground); background: var(--ib-hover); } -.ib-crdp-ask:disabled { opacity: 0.55; cursor: default; } -.ib-crdp-asknote { flex-basis: 100%; text-align: right; color: var(--muted-foreground); font-size: var(--ib-fs-12, 12px); } -.ib-crdp-you { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; margin: 0; padding: 12px 12px 12px 16px; border-radius: var(--ib-lr-md, 9px); background: color-mix(in oklab, var(--warning, oklch(0.75 0.15 85)) 15%, var(--card)); font-size: var(--ib-fs-15, 15px); overflow-wrap: anywhere; } +.ib-crdp-you { margin: 0; padding: 12px 16px; border-radius: var(--ib-lr-md, 9px); background: color-mix(in oklab, var(--warning, oklch(0.75 0.15 85)) 15%, var(--card)); font-size: var(--ib-fs-15, 15px); overflow-wrap: anywhere; } .ib-crdp-you b { color: var(--ib-onyou-fg); } -.ib-crdp-you > p { flex: 1 1 180px; min-width: 0; margin: 0; } -.ib-crdp-you > .ib-btn { flex: none; gap: 5px; } +/* The top: Open thread, or Create thread with no thread (approved 2026-10-27), right of the Move menu. */ +.pn-inbox .ib-crdp-open { gap: 5px; } +.ib-crdp-top > :is(.ib-crdp-open, .ib-tipwrap:has(> .ib-crdp-open)) { margin-left: auto; } +.ib-crdp-top > :is(.ib-crdp-open, .ib-tipwrap:has(> .ib-crdp-open)) ~ .ib-crdp-x { margin-left: 0; } +/* "Ask for an update": a quiet link by History. */ +.ib-crdp-sec h3.ib-crdp-hhead { display: flex; align-items: baseline; gap: 8px; } +.ib-crdp-ask { margin-left: auto; padding: 0; border: 0; background: none; color: var(--muted-foreground); font: inherit; font-weight: 500; cursor: pointer; } +.ib-crdp-ask:is(:hover, :focus-visible):not(:disabled) { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; } +.ib-crdp-ask:disabled { opacity: 0.55; cursor: default; } .ib-crdp-dropped { margin: 0; color: var(--muted-foreground); font-size: var(--ib-fs-14, 14px); } .ib-crdp-notes p { margin: 0 0 8px; font-size: var(--ib-fs-15, 15px); line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; } .ib-crdp-notes .ib-crdp-nonotes { color: var(--muted-foreground); font-size: var(--ib-fs-14, 14px); } diff --git a/packages/server/inbox-board-prompt.test.ts b/packages/server/inbox-board-prompt.test.ts index 4c121fae9..04c22b88d 100644 --- a/packages/server/inbox-board-prompt.test.ts +++ b/packages/server/inbox-board-prompt.test.ts @@ -39,6 +39,14 @@ test('the columns it lists are the store’s, Backlog takes work not started, an expect(prompt).toMatch(/Icebox[^\n]*is mine\. Never put a card there/); }); +// Approved 2026-10-27: agents wrote sentence titles and linked no thread to decide in. +test('it asks for 2–5 word titles with status in `say`, and the thread where the person decides linked with `thread_id`', () => { + const prompt = boardPrompt(BIN); + expect(prompt).toContain('2–5 words naming the work'); + expect(prompt).toContain('never a sentence; where it stands goes in `say`'); + expect(prompt).toMatch(/link the Inbox thread where I make the call \(`thread_id`\)/); +}); + test('it names this computer’s plannotator for the CLI, and a plain one before the settings load', () => { expect(boardPrompt(BIN)).toContain('`/Users/ada/.local/bin/plannotator card --help`'); expect(boardPrompt(null)).toContain('`plannotator card --help`'); diff --git a/packages/server/inbox-cards.test.ts b/packages/server/inbox-cards.test.ts index e1dfb5e3d..7f4583cba 100644 --- a/packages/server/inbox-cards.test.ts +++ b/packages/server/inbox-cards.test.ts @@ -189,6 +189,29 @@ describe.if(INBOX_CARD_ACTION_ON)("a card call writes no project, and tolerates expect(created.structuredContent.card.column).toBe("backlog"); }); + // Approved 2026-10-27: titles are 2–5 words, and a sentence-long one gets a hint, never a refusal. + test("a title over 40 characters is kept but answered with a hint to shorten it; a short one, and a call that sets no title, get none", async () => { + const { card } = await world(); + const long = "Fix the Pi extension so updates stop loading the stale copy"; // 59 characters + const made = await card({ op: "create", title: long, column: "building" }); + expect(made.isError).toBeFalsy(); + expect(made.structuredContent.card.title).toBe(long); + expect(made.structuredContent.title_hint).toContain("2–5 words"); + expect(made.content[0]!.text).toContain("The title is 59 characters."); + const id = made.structuredContent.card.id as string; + // A say that leaves the title alone is not nagged about it. + const said = await card({ op: "say", card_id: id, say: "tests green" }); + expect(said.structuredContent.title_hint).toBeUndefined(); + expect(said.content[0]!.text).not.toContain("The title is"); + // Renamed short: no hint. + const renamed = await card({ op: "say", card_id: id, title: "Pi update fix" }); + expect(renamed.structuredContent.card.title).toBe("Pi update fix"); + expect(renamed.structuredContent.title_hint).toBeUndefined(); + const short = await card({ op: "create", title: "Settings search fuzzy" }); + expect(short.structuredContent.title_hint).toBeUndefined(); + expect(short.content[0]!.text).not.toContain("The title is"); + }); + test("a field an op does not take is refused, but its `false` default is not", async () => { const { card } = await world(); const id = (await card({ op: "create", title: "Defaults" })).structuredContent.card.id as string; diff --git a/packages/server/inbox-mcp-cards.ts b/packages/server/inbox-mcp-cards.ts index ea55ca7c4..bfb1a4233 100644 --- a/packages/server/inbox-mcp-cards.ts +++ b/packages/server/inbox-mcp-cards.ts @@ -20,6 +20,7 @@ import { INBOX_CARD_LIVE_COLUMNS, inboxAgentName, inboxCardRefsOf, + inboxCardTitleHint, isInboxCardDropUnseen, type InboxAuthor, type InboxCardColumn, @@ -37,7 +38,7 @@ export type InboxCardOp = (typeof INBOX_CARD_OPS)[number]; /** For raw MCP clients; the agent tool words its action line itself (INBOX_AGENT_ACTION_LEADS.card). */ export const CARD_DESCRIPTION = - "Keep a card on the person's Board for work that outlasts this reply (a lane started, a PR opened, \"I'll send you X\"), and keep it current with one short call as the work moves. op: create (a title, or thread_id to take its subject), move (column), say (your last word, or what the person owes in `you`), link (thread_id, pr), ship, drop (with a reason), list (your cards). A merged PR never moves a card by itself: ship it when it is merged or live."; + "Keep a card on the person's Board for work that outlasts this reply (a lane started, a PR opened, \"I'll send you X\"), and keep it current with one short call as the work moves. op: create (a title: 2–5 words naming the work (\"Pi update fix\"), never a sentence; status goes in say), move (column), say (your last word, or what the person owes in `you`), link (thread_id, pr), ship, drop (with a reason), list (your cards). When a card waits on the person, link the Inbox thread where they make the call (thread_id), so \"You: …\" opens it. A merged PR never moves a card by itself: ship it when it is merged or live."; /** Short on purpose: Pi and OpenCode send it with every request (INBOX_TOOL_COST). */ export const CARD_COLUMN_DESCRIPTION = @@ -150,10 +151,14 @@ export async function runCardCall(context: InboxMcpContext, input: CardCallInput } const session = input.agent_session?.trim() || null; const by: InboxAuthor = { kind: "agent", host: input.agent_host?.trim() || null, session, name: input.agent_name?.trim() || null }; - const answer = (verb: string, wire: InboxCardWire, extra: Record<string, unknown> = {}) => ({ - text: `${verb}:\n${cardLineText(wire)}`, - structured: { card: wire, ...extra }, - }); + // A title that reads like a sentence gets a hint after the card line (approved 2026-10-27; never refused). + const answer = (verb: string, wire: InboxCardWire, extra: Record<string, unknown> = {}, titled: string | null = null) => { + const hint = titled === null ? null : inboxCardTitleHint(titled); + return { + text: `${verb}:\n${cardLineText(wire)}${hint ? `\n${hint}` : ""}`, + structured: { card: wire, ...extra, ...(hint ? { title_hint: hint } : {}) }, + }; + }; if (input.op === "list") { if (!project) { @@ -198,7 +203,7 @@ export async function runCardCall(context: InboxMcpContext, input: CardCallInput refs: input.ref ? [input.ref] : [], idempotency_key: input.idempotency_key, }); - return answer(replayed ? "Already made (the same idempotency_key)" : "Made a card on the person's Board; keep it current with move or say as the work moves", store.cardWire(card), { replayed }); + return answer(replayed ? "Already made (the same idempotency_key)" : "Made a card on the person's Board; keep it current with move or say as the work moves", store.cardWire(card), { replayed }, card.title); } const card = cardInScope(store, input.card_id, projectIds); @@ -218,7 +223,7 @@ export async function runCardCall(context: InboxMcpContext, input: CardCallInput reason: input.reason, }); const verb = next.column !== card.column ? `Moved to ${INBOX_CARD_COLUMN_LABELS[next.column]}` : "Updated"; - return answer(verb, store.cardWire(next)); + return answer(verb, store.cardWire(next), {}, input.title ? next.title : null); } export function registerInboxCardTool(server: McpServer, context: InboxMcpContext): void { @@ -231,14 +236,14 @@ export function registerInboxCardTool(server: McpServer, context: InboxMcpContex .object({ op: z.enum(INBOX_CARD_OPS), card_id: z.string().optional().describe("The card (crd_…); not for create or list."), - title: z.string().optional().describe("One line."), + title: z.string().optional().describe("2–5 words naming the work (\"Pi update fix\"), never a sentence; status goes in say. Create with thread_id: pass one, as subjects run long."), column: z.enum([...INBOX_CARD_COLUMNS, "asked"]).optional().describe(CARD_COLUMN_DESCRIPTION), say: z.string().optional().describe("Your last word: where the work is, one line."), you: z.string().optional().describe("What the person owes, one line."), pr: z.string().optional().describe("owner/repo#123 or a PR URL."), ref: z.string().optional().describe("An issue or PR: owner/repo#123 or its URL."), reason: z.string().optional().describe("Why it is dropped."), - thread_id: z.string().optional().describe("A thread or message id to link; create takes its subject."), + thread_id: z.string().optional().describe("A thread or message id to link; create takes its subject when no title. Link the thread where the person makes the call."), idempotency_key: z.string().optional().describe("Create: the same key answers the first card."), asked_by: z.enum(["me", "anyone"]).optional().describe("List: \"me\" (default) or \"anyone\"."), include_resolved: z.boolean().optional().describe("List: shipped and dropped too."), diff --git a/packages/shared/inbox/drafts.ts b/packages/shared/inbox/drafts.ts index fa2a53bfc..f72900d66 100644 --- a/packages/shared/inbox/drafts.ts +++ b/packages/shared/inbox/drafts.ts @@ -17,7 +17,7 @@ import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { isInboxId, type InboxDraft, type InboxDraftTo, type InboxUpload } from "@plannotator/core/inbox-types"; +import { INBOX_CARD_TITLE_MAX, isInboxId, type InboxDraft, type InboxDraftTo, type InboxUpload } from "@plannotator/core/inbox-types"; import { InboxError } from "./schema"; import { readInboxUpload } from "./uploads"; @@ -128,6 +128,16 @@ function checkProject(value: unknown): InboxDraft["project"] { return { id, root: short(project.root, "project.root", false)!, name: short(project.name, "project.name", false)! }; } +/** The Board card a draft was started for (Create thread): `{ id: crd_…, title }`, or null. */ +function checkCard(value: unknown): InboxDraft["card"] | null { + if (value === null || value === undefined) return null; + const card = value as Record<string, unknown>; + if (typeof value !== "object" || Array.isArray(value) || !isInboxId("crd", card.id) || typeof card.title !== "string" || [...card.title].length > INBOX_CARD_TITLE_MAX) { + throw new InboxError("validation_error", "card: { id: crd_…, title } or null.", { field: "card" }); + } + return { id: card.id, title: card.title }; +} + /** * Create or replace a draft. `input.files` are upload ids (or `{ upload_id }`), * each an upload still pending; the draft keeps the upload's facts. @@ -136,7 +146,7 @@ function checkProject(value: unknown): InboxDraft["project"] { export function saveInboxDraft( inboxDirPath: string, id: unknown, - input: { body?: unknown; to?: unknown; project?: unknown; files?: unknown; base?: unknown; thread_name?: unknown }, + input: { body?: unknown; to?: unknown; project?: unknown; files?: unknown; base?: unknown; thread_name?: unknown; card?: unknown }, at: string, ): InboxDraft { if (!isInboxId("drf", id)) throw new InboxError("validation_error", "id: a draft id (drf_…).", { field: "id" }); @@ -160,6 +170,7 @@ export function saveInboxDraft( }); const to = checkTo(input.to); const project = checkProject(input.project); + const card = checkCard(input.card); const base = input.base === undefined ? null : input.base; if (base !== null && typeof base !== "string") throw new InboxError("validation_error", "base: the updated_at you read, or null to create.", { field: "base" }); const existing = readInboxDraft(inboxDirPath, id); @@ -183,6 +194,7 @@ export function saveInboxDraft( project, files, ...(threadName.trim() ? { thread_name: threadName } : {}), + ...(card ? { card } : {}), }; const dir = draftsDir(inboxDirPath); mkdirSync(dir, { recursive: true, mode: 0o700 }); diff --git a/tests/e2e/inbox-board.spec.ts b/tests/e2e/inbox-board.spec.ts index cfa91a580..55ed1b75f 100644 --- a/tests/e2e/inbox-board.spec.ts +++ b/tests/e2e/inbox-board.spec.ts @@ -275,7 +275,9 @@ test("an agent's cards appear live as it makes and moves them, bold until opened // The agent moves Delivery ticks to Your review. await claude.card({ op: 'move', card_id: ids.ticks, column: 'your_review', you: 'Look at the screenshots, then merge #14', project_path: projects.plannotator }); await expect(lane(page, 'your_review').locator(`[data-card-id="${ids.ticks}"]`)).toBeVisible(); - await expect(card(page, ids.herdr).locator('.ib-bc-line')).toHaveText('Triage the open PRs first'); + // The agent's last word is not on the lane card (approval board-lane-card, r1): its title row alone. + await expect(card(page, ids.herdr).locator('.ib-bc-line')).toHaveCount(0); + await expect(card(page, ids.herdr)).not.toContainText('Triage the open PRs first'); await expect(page.locator('[data-board-summary]')).toHaveText('12 in flight · 5 on you'); await expect(page.locator('[data-board-link="shipped"]')).toHaveText('Shipped 1'); // No "on you" pill on a lane, no counts on a card.