From 57fc27141a6b690b114b33ae315f598dc6b72e7e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 27 Oct 2026 09:26:59 -0400 Subject: [PATCH 01/10] Board: the You line opens the thread where the person makes the call Owner, 2026-10-27: "When there's a You action, it should take me to the thread where I need to make the call." - Lane card: "You: ..." in Your look and Your review is its own button (a link: arrow, link colour on hover and focus), named "Open the thread to ...". It opens the card's sent thread, else the linked thread with the latest message (youThread), with the same way back to the Board; with no thread linked it opens the card. The title stays the button that opens the card; a click anywhere else on the card opens it too. - Opened card: an Open thread button in the You band, to the same thread. - Tests: youThread's choice (model.test.ts) and the separate targets and the panel's button (boardPage.test.tsx). --- .../inbox-notifications-and-agents.md | 2 +- packages/inbox/board/BoardPage.tsx | 90 ++++++++++++------- packages/inbox/board/CardPanel.tsx | 22 +++-- packages/inbox/board/model.test.ts | 24 ++++- packages/inbox/board/model.ts | 34 +++++++ packages/inbox/boardPage.test.tsx | 67 +++++++++++++- packages/inbox/icons.tsx | 2 + packages/inbox/inbox.css | 31 ++++--- 8 files changed, 217 insertions(+), 55 deletions(-) diff --git a/docs/agent-guides/inbox-notifications-and-agents.md b/docs/agent-guides/inbox-notifications-and-agents.md index a951de1ad..de0c17c4b 100644 --- a/docs/agent-guides/inbox-notifications-and-agents.md +++ b/docs/agent-guides/inbox-notifications-and-agents.md @@ -75,7 +75,7 @@ | 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 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 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, and the card when the card links no thread ("Open the thread to merge #14", else "Open the card to …"). The opened card has the same thread as **Open thread** in its You band. 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. diff --git a/packages/inbox/board/BoardPage.tsx b/packages/inbox/board/BoardPage.tsx index 42ea9ef97..4aa56b850 100644 --- a/packages/inbox/board/BoardPage.tsx +++ b/packages/inbox/board/BoardPage.tsx @@ -33,6 +33,8 @@ import { moveSaid, sentTo, startBody, + youLinkLabel, + youThread, unseenDrops, type BoardView, } from './model'; @@ -319,6 +321,7 @@ export function BoardPage(props: BoardPageProps) { rowOf: props.rowOf, markOf, onOpen: (card) => props.onOpenCard(card.id), + onOpenThread: props.onOpenThread, }; let body: ReactNode; @@ -498,6 +501,8 @@ interface CardCommon { /** The card's lane mark (its linked PR or issue), null when it links neither. */ markOf: (card: InboxCardWire) => InboxCardMark | null; onOpen: (card: InboxCardWire) => void; + /** The "You: …" line's way in: the thread where the person makes the call (`youThread`). */ + onOpenThread: (card: InboxCardWire, threadId: string) => void; } function NewCardButton({ disabled, phone, onOpen }: { disabled: boolean; phone: boolean; onOpen: () => void }) { @@ -628,7 +633,10 @@ const LANE_EMPTY: Partial> = { * 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. A button: it opens the card. + * 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`), or + * the card when it links none. */ function BoardCard({ card, @@ -640,6 +648,7 @@ function BoardCard({ rowOf, markOf, onOpen, + onOpenThread, draggable = false, dragging = false, onDragStart, @@ -652,6 +661,7 @@ function BoardCard({ const age = cardAge(card.heard_at, now); 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; @@ -670,6 +680,11 @@ function BoardCard({ data-open={openId === card.id ? '' : undefined} data-sent={sent ? '' : undefined} draggable={draggable && !phone ? true : undefined} + onClick={(event) => { + // The card's buttons do their own thing; a click anywhere else on it opens the card, as its title does. + if ((event.target as Element).closest('button, a')) return; + onOpen(card); + }} onDragStart={(event) => { if (!draggable || phone) return; event.dataTransfer.effectAllowed = 'move'; @@ -678,45 +693,52 @@ function BoardCard({ }} onDragEnd={onDragEnd} > - + {oneLine && {ageTip}} + + {!oneLine && ( + + + {sent ? ( + + {delivery ? : } + Sent to {sent} + + ) : line ? ( + line.you ? ( + + ) : ( + line.text + ) + ) : null} - )} - + {mark && } + {ageTip} + + )} ); } diff --git a/packages/inbox/board/CardPanel.tsx b/packages/inbox/board/CardPanel.tsx index 3607382ee..001c6a167 100644 --- a/packages/inbox/board/CardPanel.tsx +++ b/packages/inbox/board/CardPanel.tsx @@ -7,7 +7,6 @@ import { INBOX_SECTIONS, inboxAgentName, inboxCardRefsOf, - isInboxCardPersonColumn, type InboxCardColumn, type InboxCardHistoryEntry, type InboxCardRef, @@ -21,7 +20,7 @@ import { AuthorMark, Icon } from '../icons'; import { Tip } from '../components/Tip'; import { OnceButton } from './OnceButton'; import { RefMarkIcon } from './RefMark'; -import { cardAge, cardFoot, columnChoices, historyWords, refState, sentTo } from './model'; +import { cardAge, cardFoot, cardYou, columnChoices, historyWords, refState, sentTo, youLinkLabel, youThread } from './model'; export interface CardPanelProps { card: InboxCardWire; @@ -77,6 +76,9 @@ export function CardPanel(props: CardPanelProps) { const refs = inboxCardRefsOf(card); const agent = card.agent?.kind === 'agent' ? card.agent : null; 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; return (