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
: `), 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 " 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 " 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 "); 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 "); 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>
/** 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> = {
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> = {
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 " 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
+ * " 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 = (
-
- {age.short.replace(' ago', '')}
-
+ // "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 = (
+
+ {mark && }
+
+
+ {card.agent ? : }
+
+
+
+ {age.short.replace(' ago', '')}
+
+
);
return (
- {card.agent ? (
-
- ) : (
-
- )}
- {oneLine && {ageTip}}
{!oneLine && (
@@ -718,24 +732,18 @@ function BoardCard({
{delivery ? : }
Sent to {sent}
- ) : line ? (
- line.you && thread ? (
-
- ) : line.you ? (
- // No thread to go to: plain words (a click on them opens the card, as anywhere on it), no second tab stop.
- <>
- You: {line.text}
- >
- ) : (
- line.text
- )
- ) : null}
+ ) : line?.you && thread ? (
+
+ ) : (
+ // No thread to go to: plain words (a click on them opens the card, as anywhere on it), no second tab stop.
+ <>
+ You: {line?.text}
+ >
+ )}
- {mark && }
- {ageTip}
)}
@@ -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(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;
onLink: (ref: string) => Promise;
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 (