From aa73c1b2047df9dbe28f4d6b1e78fc8cd7220160 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 13:06:50 -0500 Subject: [PATCH 001/149] board: four gate stories from real gate shapes replace the 86-story gallery Co-Authored-By: Claude Opus 5.5 --- apps/board/AGENTS.md | 10 +- .../board/DecisionQueueModal.stories.tsx | 646 ----------- .../src/client/board/GateGallery.stories.tsx | 396 ------- apps/board/src/client/board/Gates.stories.tsx | 166 +++ .../client/board/ReviewGateSheet.stories.tsx | 1006 ----------------- .../src/client/board/gate-stories.fixtures.ts | 801 +++++++++++++ 6 files changed, 973 insertions(+), 2052 deletions(-) delete mode 100644 apps/board/src/client/board/DecisionQueueModal.stories.tsx delete mode 100644 apps/board/src/client/board/GateGallery.stories.tsx create mode 100644 apps/board/src/client/board/Gates.stories.tsx delete mode 100644 apps/board/src/client/board/ReviewGateSheet.stories.tsx create mode 100644 apps/board/src/client/board/gate-stories.fixtures.ts diff --git a/apps/board/AGENTS.md b/apps/board/AGENTS.md index 470026e7dd..3238a1e31d 100644 --- a/apps/board/AGENTS.md +++ b/apps/board/AGENTS.md @@ -43,10 +43,12 @@ resolves run to MR in the background; the request path never waits on it). ## Seeing every face -`bun run storybook` from the repo root, then `Gates/Board/Gallery`: one -story per gate kind and variant, built from real gate shapes with invented -data (`gate-gallery.fixtures.ts`). Add a story when you add a gate kind or -variant, with invented names only (the repo is public). +`bun run storybook` from the repo root, then `Gates/Board`: four stories +(review first round, review re-review, respond plan, respond post), each +rebuilt from a real gate's shape with invented data +(`gate-stories.fixtures.ts`). Keep it to gates a real run asks; a variant +belongs in a DOM test, not a new story. Invented names only (the repo is +public). ## Respond rounds diff --git a/apps/board/src/client/board/DecisionQueueModal.stories.tsx b/apps/board/src/client/board/DecisionQueueModal.stories.tsx deleted file mode 100644 index ba137358c9..0000000000 --- a/apps/board/src/client/board/DecisionQueueModal.stories.tsx +++ /dev/null @@ -1,646 +0,0 @@ -import type { ReactNode } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import type { GateSelections } from '@mattstack/gate-kit'; -import { gateDraftKey } from '@mattstack/gate-kit/react'; -import { registerTheme, SoribashiProvider } from '@mattstack/tui-kit/provider'; -import { tuiTheme } from '@mattstack/tui-kit/theme'; - -import '@mattstack/tui-kit/theme.css'; -import '@mattstack/tui-kit/canvas.css'; -import '../../style.css'; - -import type { GateQuestion, GateRow } from '../../gates/store.ts'; -import type { BoardMRWithReview } from '../types.ts'; -import { - DecisionQueueComplete, - DecisionQueueModal, -} from './DecisionQueueModal.tsx'; - -/** - * Sign-off catalog for the decision queue's stage sheet and pane notice: - * every question of a gate stacked in the main column, the MR card and the - * decision context in the rail, and the answer docked under them; the head - * is one row (title, gate-level actions, queue nav, tag, close). The sheet is - * fixed-position, so each story renders inside a tall stage it covers; - * drafts seed via the same `gateDraftKey()` localStorage write - * `useGateDraft` reads. - */ -function BoardStage({ - scheme, - children, -}: { - scheme: 'light' | 'dark'; - children: ReactNode; -}) { - return ( -
- {children} -
- ); -} - -const boardStage = ( - Story: () => ReactNode, - context: { globals: { scheme?: string } } -) => ( - - - -); - -const meta = { - title: 'Gates/Board/DecisionQueueModal', - decorators: [boardStage], - parameters: { layout: 'fullscreen' }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -registerTheme(tuiTheme); - -function question( - id: string, - label: string, - multi: boolean, - options: GateQuestion['options'] -): GateQuestion { - return { id, label, multi, options }; -} - -const boardMr = { - iid: 31, - title: 'themed gate controls', - author: { id: 'gitlab:7', username: 'paul', name: 'Paul', avatarUrl: null }, - sourceBranch: 'board-28-themed-gate-controls', - targetBranch: 'main', - createdAt: '2026-09-22T12:00:00Z', -} as unknown as BoardMRWithReview; - -function seedDraft( - gateId: string, - draft: { - selections: GateSelections; - notes: Record; - item: string | null; - } -) { - localStorage.setItem(gateDraftKey(gateId), JSON.stringify(draft)); -} - -const noop = () => {}; - -const respondPlanQuestions = [ - question('thread-1', 'Thread 1: naming', false, [ - { value: 'reply:aaaaaaaaaaaa', label: 'reply' }, - { value: 'fix:bbbbbbbbbbbb', label: 'fix (recommended)' }, - { value: 'skip:cccccccccccc', label: 'skip' }, - ]), - question('thread-2', 'Thread 2: test coverage', false, [ - { value: 'reply:dddddddddddd', label: 'reply' }, - { value: 'fix:eeeeeeeeeeee', label: 'fix (recommended)' }, - { value: 'skip:ffffffffffff', label: 'skip' }, - ]), - question('code-changes', 'What changed?', false, [ - 'skip', - 'inline-diff', - 'separate-commit', - ]), -]; - -// --- FirstGateIdle ---------------------------------------------------------- - -const firstGate: GateRow = { - gateId: 'triage-first', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/31', - kind: 'respond-plan', - label: 'respond-plan', - status: 'open', - openedAt: 1788962100000, - questions: respondPlanQuestions, - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -export const FirstGateIdle: Story = { - render: () => ( - - ), -}; - -// --- MidGateSelected -------------------------------------------------------- - -const midGate: GateRow = { ...firstGate, gateId: 'triage-mid' }; - -export const MidGateSelected: Story = { - render: () => { - seedDraft(midGate.gateId, { - selections: { - 'thread-1': 'fix:bbbbbbbbbbbb', - 'thread-2': 'fix:eeeeeeeeeeee', - }, - notes: {}, - item: 'thread-2', - }); - return ( - - ); - }, -}; - -// --- LastGateSubmit --------------------------------------------------------- - -const lastGate: GateRow = { - gateId: 'triage-last', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/46', - kind: 'self-review', - label: 'self-review', - status: 'open', - openedAt: 1788964320000, - questions: [ - question('verdict', 'Ready to merge?', false, [ - { value: 'approve', label: 'Approve (recommended)' }, - { value: 'changes', label: 'Request changes' }, - { value: 'discuss', label: 'Discuss further' }, - ]), - ], - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -export const LastGateSubmit: Story = { - render: () => { - seedDraft(lastGate.gateId, { - selections: { verdict: 'approve' }, - notes: {}, - item: null, - }); - return ( - - ); - }, -}; - -// --- ShipGate --------------------------------------------------------------- - -/** A Runs stage gate on no MR: the rail opens on the decision context, and - an option that carries its "(Recommended)" marker mid-label reads as the - label, the recommended chip, and the rest as its subtitle. */ -const shipGate: GateRow = { - gateId: 'triage-ship', - subject: 'run:20260923-120115-widgets', - kind: 'ship', - label: 'ship', - status: 'open', - openedAt: 1788964320000, - context: - 'Three commits are ready on a clean tree. The after screenshots are still outstanding, so I recommend a draft.\n\n- Branch: `themed-gate-controls`\n- Target: `main`', - questions: [ - question('handoff', 'How do we hand off?', false, [ - { - value: 'hand-back', - label: - 'Hand back (Recommended). I give you the branch, the target and the description.', - }, - { value: 'hold', label: 'Hold the run here.' }, - ]), - question('preview', 'Which preview environments?', true, [ - { value: 'preview-a', label: 'preview-a (Recommended)' }, - { value: 'preview-b', label: 'preview-b' }, - ]), - ], - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -export const ShipGate: Story = { - render: () => ( - - ), -}; - -// --- ParkedGate ------------------------------------------------------------- - -const parkedGate: GateRow = { - ...firstGate, - gateId: 'triage-parked', - status: 'parked', - domain: 'review', - origin: undefined, -}; - -export const ParkedGate: Story = { - render: () => ( - - ), -}; - -// --- WithContext ------------------------------------------------------------ - -/** The gate's context renders open in the rail's decision context card, - beside every question. */ -const contextBody = `The reviewer left two threads on this MR. - -**Thread 1 · \`lib/retry.ts:41\` · naming** - -> \`withBackoff\` reads like it adds backoff to an existing retry, but it IS -> the retry loop. Suggest \`retryWithBackoff\` so call sites read correctly. - -**Thread 2 · \`lib/retry.ts:58\` · test coverage** - -> The abort path has no test. If the signal fires between attempts, the loop -> should stop without scheduling another timer: - -\`\`\`ts -if (signal?.aborted) return { ok: false, reason: 'aborted' }; -await delay(backoff(attempt), { signal }); -\`\`\` - -Recommended plan: fix both in this MR as a separate commit.`; - -const contextGate: GateRow = { - ...firstGate, - gateId: 'triage-context', - context: contextBody, -}; - -export const WithContext: Story = { - render: () => { - seedDraft(contextGate.gateId, { - selections: { 'thread-1': 'fix:bbbbbbbbbbbb' }, - notes: {}, - item: 'thread-2', - }); - return ( - - ); - }, -}; - -// --- LongContextScroll ------------------------------------------------------ - -/** Context taller than the rail: the rail scrolls above the docked - answer, which never moves. */ -const longContextGate: GateRow = { - ...firstGate, - gateId: 'triage-context-long', - context: [ - contextBody, - ...Array.from( - { length: 6 }, - (_, i) => `**Follow-up note ${i + 1}** - -> Additional reviewer discussion quoted here so the pane overflows: the -> retry loop's jitter window, the timer cleanup on unmount, and how the -> abort reason propagates to the caller were each debated at length.` - ), - ].join('\n\n'), -}; - -export const LongContextScroll: Story = { - render: () => ( - - ), -}; - -// --- WriteInAnswer ---------------------------------------------------------- - -/** Visual spec for RT-116's `allowOther`: an "Other (write in)" choice whose - written answer travels in the text field below it ({value: 'other', - note}). Until rt ships the schema field and the kit appends the choice - itself, this fixture carries the option explicitly; the rendering is - already exactly the ratified UX. */ -const writeInGate: GateRow = { - ...lastGate, - gateId: 'triage-write-in', - questions: [ - question('verdict', 'Ready to merge?', false, [ - { value: 'approve', label: 'Approve (recommended)' }, - { value: 'changes', label: 'Request changes' }, - { value: 'other', label: 'Other (write in)' }, - ]), - ], -}; - -export const WriteInAnswer: Story = { - render: () => { - seedDraft(writeInGate.gateId, { - selections: { verdict: 'other' }, - notes: { - verdict: 'Merge after the deploy freeze lifts on Thursday.', - }, - item: null, - }); - return ( - - ); - }, -}; - -// --- PaneBlocked / PaneGone ------------------------------------------------ - -const paneScreen = [ - 'The fix is in and the suite is green, so I will resume the review.', - '', - 'Entering worktree(~/worktrees/widgets/harbor)', - '\u2500'.repeat(80), - ' Tool use', - '', - ' Entering worktree(~/worktrees/widgets/harbor)', - '', - ' Do you want to proceed?', - ' \u276f 1. Yes', - ' 2. No', -].join('\n'); - -const paneGate = (reason: 'blocked' | 'gone'): GateRow => ({ - gateId: `triage-pane-${reason}`, - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/31', - kind: 'pane-attention', - label: 'pane-attention', - status: 'open', - openedAt: 1788964320000, - context: paneScreen, - meta: { agentId: 'agent-7', paneRef: 'w4:p2', reason }, - questions: [ - question('action', 'Pane needs attention', false, [ - 'focus-pane', - 'resume', - 'clear', - 'dismiss', - ]), - ], -}); - -/** A blocked pane: its prompt as terminal text, earlier output folded away, - focus pane as the one big action and the rest as text actions. */ -export const PaneBlocked: Story = { - render: () => ( - - ), -}; - -/** A gone pane: its last screen, with resume as the big action. */ -export const PaneGone: Story = { - render: () => ( - - ), -}; - -// --- AnsweredGate / AnswerStuck / AgentNotRunning -------------------------- - -const answeredShip: GateRow = { - ...shipGate, - gateId: 'answered-ship', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/31', - status: 'answered', - answers: { - handoff: { value: 'hand-back', note: 'Push after the standup.' }, - preview: ['preview-a'], - }, - answeredBy: 'paul', - answeredAt: 1788964500000, -}; - -const answeredStory = (gate: GateRow): Story => ({ - render: () => ( - - ), -}); - -/** A recorded answer, read-only: every pick checked, nothing to press. */ -export const AnsweredGate = answeredStory(answeredShip); - -/** The pane never picked the answer up: focus pane is the one action. */ -export const AnswerStuck = answeredStory({ - ...answeredShip, - gateId: 'answered-stuck', - delivery: { outcome: 'stuck', at: 1788964600000 }, -}); - -/** No agent was left to run the answer: retry posts it again. */ -export const AgentNotRunning = answeredStory({ - ...answeredShip, - gateId: 'answered-unassigned', - execution: 'unassigned', -}); - -// --- QueueComplete ---------------------------------------------------------- - -const decidedMr = (iid: number, title: string) => - ({ ...boardMr, iid, title }) as BoardMRWithReview; - -const postThread = (n: number, t: string) => - question(`thread-${n}`, `widgets/${t}.ts:${n * 12}`, true, [ - { value: `post:${t}`, label: 'Post' }, - { value: `resolve:${t}`, label: 'Resolve' }, - ]); - -const decided: Array<{ gate: GateRow; mr?: BoardMRWithReview }> = [ - { - gate: { - ...firstGate, - gateId: 'done-post', - kind: 'respond-post', - status: 'answered', - questions: [ - postThread(1, 'queue'), - postThread(2, 'retry'), - postThread(3, 'drain'), - ], - answers: { - 'thread-1': { - value: ['post:queue', 'resolve:queue'], - text: 'Reworded reply', - }, - 'thread-2': ['post:retry'], - 'thread-3': [], - }, - answeredBy: 'board', - }, - mr: decidedMr(31, 'themed gate controls'), - }, - { - gate: { - ...firstGate, - gateId: 'done-plan', - status: 'answered', - answers: { - 'thread-1': 'fix:bbbbbbbbbbbb', - 'thread-2': 'reply:dddddddddddd', - 'code-changes': 'separate-commit', - }, - answeredBy: 'board', - }, - mr: decidedMr(44, 'retry loop backs off on a 429 from the export queue'), - }, - { - gate: { - ...lastGate, - gateId: 'done-review', - kind: 'review-post', - status: 'answered', - answers: { verdict: 'approve' }, - answeredBy: 'dana', - }, - mr: decidedMr(46, 'drain the widget queue before shutdown'), - }, - { - gate: { ...lastGate, gateId: 'done-lagging', kind: 'review-post' }, - mr: decidedMr(52, 'split the settings form into sections'), - }, - { - gate: { - ...lastGate, - gateId: 'done-attention', - subject: 'agent:pane-4', - kind: 'pane-attention', - status: 'answered', - questions: [question('next', 'What next?', false, ['resume', 'stop'])], - answers: { next: 'resume' }, - answeredBy: 'board', - }, - }, -]; - -export const QueueComplete: Story = { - render: () => , -}; - -export const QueueCompleteEmpty: Story = { - render: () => , -}; diff --git a/apps/board/src/client/board/GateGallery.stories.tsx b/apps/board/src/client/board/GateGallery.stories.tsx deleted file mode 100644 index 75b28418b9..0000000000 --- a/apps/board/src/client/board/GateGallery.stories.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import type { ReactNode } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { userEvent, within } from 'storybook/test'; - -import type { GateSelections } from '@mattstack/gate-kit'; -import { gateDraftKey } from '@mattstack/gate-kit/react'; -import { registerTheme, SoribashiProvider } from '@mattstack/tui-kit/provider'; -import { tuiTheme } from '@mattstack/tui-kit/theme'; - -import '@mattstack/tui-kit/theme.css'; -import '@mattstack/tui-kit/canvas.css'; -import '../../style.css'; - -import { - DecisionQueueComplete, - DecisionQueueModal, - type TriageGateState, -} from './DecisionQueueModal.tsx'; -import { DiffSourceContext, noDiffSource } from './diff-source.ts'; -import * as G from './gate-gallery.fixtures.ts'; -import { boardPeople, PeopleProvider } from './people.ts'; - -/** - * Every decision-queue face the daemon's real gate kinds produce, one story - * per kind and variant, from `gate-gallery.fixtures.ts`. Open gates show the - * answerable face; the answered group covers the read-only, stuck, - * no-agent and lost-the-race faces. - */ -function Stage({ - scheme, - children, -}: { - scheme: 'light' | 'dark'; - children: ReactNode; -}) { - return ( -
- {children} -
- ); -} - -const stage = ( - Story: () => ReactNode, - context: { globals: { scheme?: string } } -) => ( - - - -); - -const meta = { - title: 'Gates/Board/Gallery', - decorators: [stage], - parameters: { layout: 'fullscreen' }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -registerTheme(tuiTheme); - -const noop = () => {}; - -function pips(position: number, total: number): TriageGateState[] { - return Array.from({ length: total }, (_, i) => - i + 1 < position ? 'done' : i + 1 === position ? 'active' : 'todo' - ); -} - -function Face({ - entry, - position = 2, - total = 4, -}: { - entry: G.GalleryGate; - position?: number; - total?: number; -}) { - const people = boardPeople({ - allMembers: [...G.PEOPLE].map(([username, name]) => ({ username, name })), - mrs: entry.mr ? [entry.mr] : [], - }); - return ( - - - - - - ); -} - -const face = (name: string, entry: G.GalleryGate): Story => ({ - name, - render: () => , -}); - -// --- Respond ---------------------------------------------------------------- - -export const RespondPlan = face( - 'Respond: plan, structured (plan@1, thread@1)', - G.respondPlan -); -export const RespondPlanSecondRound = face( - 'Respond: plan, second response with a revise', - G.respondPlanSecondRound -); -export const RespondPlanCommentsOnly = face( - 'Respond: plan, a person who only left Overview comments', - G.respondPlanCommentsOnly -); -export const RespondPlanTwoThreads = face( - 'Respond: plan, structured, two threads', - G.respondPlanTwoThreads -); -export const RespondPlanProse = face( - 'Respond: plan, prose contexts', - G.respondPlanProse -); -export const RespondPost = face( - 'Respond: post, structured (post@1, reply@1)', - G.respondPost -); -export const RespondPostRichPreview: Story = { - ...face('Respond: post, a rich reply in Preview', G.respondPostRich), - play: async ({ canvasElement }) => { - const tabs = await within(canvasElement).findAllByRole('button', { - name: 'Preview', - }); - await userEvent.click(tabs[2]!); - }, -}; -export const RespondPostReReview = face( - 'respond-post with a re-review latch', - G.respondPostReReview -); -export const RespondPostLongReplies = face( - 'Respond: post, long replies, no fixes', - G.respondPostLongReplies -); -export const RespondPostProse = face( - 'Respond: post, prose, replies and disposition', - G.respondPostProse -); -export const RespondPostNoContext = face( - 'Respond: post, no context', - G.respondPostNoContext -); - -// --- Review ----------------------------------------------------------------- - -export const ReviewPost = face( - 'Review: post, structured (review@1, findings@1)', - G.reviewPost -); -export const ReviewPostDrafted = face( - 'Review: post, drafted comments (editable)', - G.reviewPostDrafted -); -export const ReviewPostDraftedFullDiff: Story = { - ...face('Review: post, drafted, full file diff open', G.reviewPostDrafted), - play: async ({ canvasElement }) => { - const [open] = await within(canvasElement).findAllByRole('button', { - name: /view full diff/, - }); - await userEvent.click(open!); - }, -}; -export const ReviewPostDraftedPreview: Story = { - ...face('Review: post, drafted, a comment in Preview', G.reviewPostDrafted), - play: async ({ canvasElement }) => { - const [preview] = await within(canvasElement).findAllByRole('button', { - name: 'Preview', - }); - await userEvent.click(preview!); - }, -}; -export const ReviewPostDraftedRichPreview: Story = { - ...face( - 'Review: post, drafted, a rich comment in Preview', - G.reviewPostDraftedRich - ), - play: async ({ canvasElement }) => { - const [preview] = await within(canvasElement).findAllByRole('button', { - name: 'Preview', - }); - await userEvent.click(preview!); - }, -}; -export const ReviewPostDraftedHeldBack = face( - 'Review: post, drafted, a diff too large to show and one that failed', - G.reviewPostDraftedHeldBack -); -export const ReviewPostDraftedGitHub = face( - 'Review: post, drafted, GitHub with three verdicts', - G.reviewPostDraftedGitHub -); -export const ReviewPostVerdictOnly = face( - 'Review: post, prose, verdict only', - G.reviewPostVerdictOnly -); -export const ReviewPostBySeverity = face( - 'Review: post, prose, chunked by severity', - G.reviewPostProseBySeverity -); -export const ReviewPostByCount = face( - 'Review: post, prose, chunked by count', - G.reviewPostProseByCount -); - -// --- Stage ------------------------------------------------------------------ - -export const StagePlan = face('Stage: plan (escalated)', G.plan); -export const StageClarify = face('Stage: clarify', G.clarify); -export const StageEvidence = face('Stage: evidence', G.evidence); -export const StageEvidenceFinding = face( - 'Stage: evidence finding', - G.evidenceFinding -); -export const StageEvidenceAttach = face( - 'Stage: evidence attach', - G.evidenceAttach -); -export const StageEvidenceBoards = face( - 'Stage: evidence boards (answered in the console)', - G.evidenceBoards -); -export const StageImplementProgress = face( - 'Stage: implement progress', - G.implementProgress -); -export const StageSelfReviewProgress = face( - 'Stage: self-review progress', - G.selfReviewProgress -); -export const StageSelfReview = face('Stage: self-review', G.selfReview); -export const StageShip = face('Stage: ship', G.ship); -export const StagePlanWriteIn = face( - 'Stage: plan, write-in question', - G.planWriteIn -); -export const StageWatchCi = face('Stage: watch-ci', G.watchCi); -export const StageCiWatchWaiting = face( - 'Stage: ci-watch, waiting', - G.ciWatchWaiting -); -export const StageCiResult = face('Stage: ci-result', G.ciResult); -export const StageCiWatch = face('Stage: ci:watch-ci:1', G.ciWatchStage); -export const StageCiFix = face('Stage: ci-fix', G.ciFix); -export const StageMarkReady = face('Stage: mark-ready', G.markReady); -export const StageClose = face('Stage: close', G.close); -export const StageLogin = face('Stage: login (wait, meta context)', G.login); -export const StageNext = face('Stage: next (wait, meta context)', G.next); -export const StageConfirm = face('Stage: confirm, with a multi', G.confirm); -export const StageValidation = face( - 'Stage: validation, string options', - G.validation -); -export const StageApproval = face('Stage: approval', G.approval); - -// --- Herd and milestone ----------------------------------------------------- - -export const HerdQuestion = face('Herd: question', G.herdQuestion); -export const HerdMilestone = face('Herd: milestone', G.milestone); - -// --- Escalations ------------------------------------------------------------ - -export const EscalationCiWatch = face( - 'Escalation: ci:watch-ci:2 (escalated)', - G.ciWatchEscalated -); -export const EscalationDoctor = face( - 'Escalation: doctor-escalation', - G.doctorEscalation -); -export const EscalationRespond = face( - 'Escalation: respond-escalation', - G.respondEscalation -); -export const EscalationReview = face( - 'Escalation: review-escalation', - G.reviewEscalation -); -export const EscalationWrapUp = face('Escalation: wrap-up', G.wrapUp); - -// --- Pane ------------------------------------------------------------------- - -export const PaneTrustPrompt = face( - 'Pane: blocked on a trust prompt', - G.paneBlocked -); -export const PaneTrustPromptGone = face('Pane: gone, no MR', G.paneGone); - -// --- Answered states -------------------------------------------------------- - -export const AnsweredReview = face( - 'Answered: review post on the board', - G.answeredReview -); -export const AnsweredRespondPost = face( - 'Answered: respond post on the board', - G.answeredRespondPost -); -export const AnsweredMilestoneNote = face( - 'Answered: milestone with a note', - G.answeredMilestoneNote -); -export const AnsweredPlanWriteIn = face( - 'Answered: stage plan, wrote in', - G.answeredPlanWriteIn -); -export const AnsweredInPane = face('Answered: in the pane', G.answeredInPane); -export const AnswerStuck = face('Answered: stuck', G.answerStuck); -export const AgentNotRunning = face( - 'Answered: agent not running', - G.agentNotRunning -); - -function seedDraft(gateId: string, selections: GateSelections) { - localStorage.setItem( - gateDraftKey(gateId), - JSON.stringify({ selections, notes: {}, item: null }) - ); -} - -/** Submits a different answer than the one the stubbed daemon says won. */ -export const AnsweredElsewhere: Story = { - name: 'Answered: elsewhere (lost the race)', - beforeEach: () => { - const real = window.fetch; - window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = - input instanceof Request ? input.url : new URL(input, location.href); - if (String(url).endsWith('/gate/answer')) - return new Response( - JSON.stringify({ - ok: false, - conflict: true, - row: { - answer: { answers: G.shipWinningAnswer, by: 'shepherd' }, - }, - }), - { status: 409, headers: { 'content-type': 'application/json' } } - ); - return real(input, init); - }) as typeof fetch; - return () => { - window.fetch = real; - }; - }, - render: () => { - seedDraft(G.answeredElsewhere.gate.gateId, { - mr_open: 'update-description', - open_as: 'ready', - next: 'hold', - }); - return ; - }, - play: async ({ canvasElement }) => { - const submit = await within(canvasElement).findByRole('button', { - name: 'submit', - }); - await userEvent.click(submit); - }, -}; - -// --- Queue recap ------------------------------------------------------------ - -export const QueueRecap: Story = { - name: 'Queue recap: decided this session', - render: () => , -}; diff --git a/apps/board/src/client/board/Gates.stories.tsx b/apps/board/src/client/board/Gates.stories.tsx new file mode 100644 index 0000000000..f607ae43fc --- /dev/null +++ b/apps/board/src/client/board/Gates.stories.tsx @@ -0,0 +1,166 @@ +import { useEffect, type ReactNode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { registerTheme, SoribashiProvider } from '@mattstack/tui-kit/provider'; +import { tuiTheme } from '@mattstack/tui-kit/theme'; + +import '@mattstack/tui-kit/theme.css'; +import '@mattstack/tui-kit/canvas.css'; +import '../../style.css'; + +import { DecisionQueueModal } from './DecisionQueueModal.tsx'; +import { DiffSourceContext, noDiffSource } from './diff-source.ts'; +import * as G from './gate-stories.fixtures.ts'; +import { boardPeople, PeopleProvider } from './people.ts'; + +/** + * The review and respond gates as the board opens them, one story per gate + * a real run asks, from `gate-stories.fixtures.ts`. + */ +function Stage({ + scheme, + children, +}: { + scheme: 'light' | 'dark'; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +const stage = ( + Story: () => ReactNode, + context: { globals: { scheme?: string } } +) => ( + + + +); + +const meta = { + title: 'Gates/Board', + decorators: [stage], + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +registerTheme(tuiTheme); + +const REPORT = JSON.stringify({ + findings: [], + depth: + 'verify. vitest 212/212 green; the two typecheck warnings are generated code, not defects.', + strengths: [ + { + lead: 'The TTL and eviction read clearly', + detail: 'tileCache.ts:29-71, one place for each rule', + }, + { + lead: 'Tests are red on main and green here', + detail: 'tileCache.test.ts:101-117', + }, + ], + checks: [ + { tag: 'PASS', text: 'vitest suite (212/212)' }, + { tag: 'PASS', text: 'typecheck' }, + { tag: 'N/A', text: 'visual regression (no UI touched)' }, + ], + notes: ['The radar layer could move onto this cache in a follow-up.'], +}); + +// Captured at module scope, before any story stubs it. +const REAL_FETCH = globalThis.fetch; + +/** Answers the review sheet's `/review/report.json` for the story's mount + only, so it never reaches the network from Storybook's iframe. */ +function ReviewReport(Story: () => ReactNode) { + // Installed during render: the sheet fetches in its own mount effect, + // which runs before an effect here would. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.startsWith('/review/report.json')) + return new Response(REPORT, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + return REAL_FETCH(input as RequestInfo, init); + }) as typeof fetch; + useEffect( + () => () => { + globalThis.fetch = REAL_FETCH; + }, + [] + ); + return ; +} + +const noop = () => {}; + +function Face({ entry }: { entry: G.StoryGate }) { + const people = boardPeople({ + allMembers: [...G.PEOPLE].map(([username, name]) => ({ username, name })), + mrs: [entry.mr], + }); + return ( + + + + + + ); +} + +/** Seven drafted findings over two files, three of them with no diff line, + and the verdict leaning to Comment until the blocking one is fixed. */ +export const ReviewFirstRound: Story = { + name: 'Review: first round', + decorators: [ReviewReport], + render: () => , +}; + +/** Round 2 of the same review: three earlier threads (fixed, a fair + pushback, not done) and one new finding. */ +export const ReviewReReview: Story = { + name: 'Review: re-review', + decorators: [ReviewReport], + render: () => , +}; + +/** Five reviewer threads, four to fix and one to answer, with the code + changes to approve. */ +export const RespondPlan: Story = { + name: 'Respond: plan', + render: () => , +}; + +/** The replies after the fixes landed, the last on an Overview thread + GitLab cannot resolve. */ +export const RespondPost: Story = { + name: 'Respond: post', + render: () => , +}; diff --git a/apps/board/src/client/board/ReviewGateSheet.stories.tsx b/apps/board/src/client/board/ReviewGateSheet.stories.tsx deleted file mode 100644 index fba0b3cd93..0000000000 --- a/apps/board/src/client/board/ReviewGateSheet.stories.tsx +++ /dev/null @@ -1,1006 +0,0 @@ -import { useEffect, type ReactNode } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { gateDraftKey } from '@mattstack/gate-kit/react'; -import { registerTheme, SoribashiProvider } from '@mattstack/tui-kit/provider'; -import { tuiTheme } from '@mattstack/tui-kit/theme'; - -import '@mattstack/tui-kit/theme.css'; -import '@mattstack/tui-kit/canvas.css'; -import '../../style.css'; - -import type { GateQuestion, GateRow } from '../../gates/store.ts'; -import type { BoardMRWithReview } from '../types.ts'; -import { DiffSourceContext, type DiffSource } from './diff-source.ts'; -import { useGateForm } from './GateForm.tsx'; -import { ReviewGateSheet } from './ReviewGateSheet.tsx'; - -/** - * Storybook coverage for the full-screen review gate sheet (design doc - * `docs/superpowers/specs/2026-09-18-review-gate-redesign-design.md` §4): - * six invented findings across two chunked questions plus a clean-review - * (outcome-only) variant. The fixtures carry review@1 (gate-level) and - * findings@1 (per-chunk) contexts, the shapes `readReviewGate` joins. The - * sheet fills the viewport itself, so the stage only needs the theme - * provider, not DecisionQueueModal.stories.tsx's tall padded stage. - */ -function Stage({ - scheme, - children, -}: { - scheme: 'light' | 'dark'; - children: ReactNode; -}) { - return ( -
- {children} -
- ); -} - -const stage = ( - Story: () => ReactNode, - context: { globals: { scheme?: string } } -) => ( - - - -); - -const meta = { - title: 'Gates/Board/ReviewGateSheet', - decorators: [stage], - parameters: { layout: 'fullscreen' }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -registerTheme(tuiTheme); - -function question( - id: string, - label: string, - multi: boolean, - options: GateQuestion['options'], - context?: string -): GateQuestion { - return { id, label, multi, options, ...(context ? { context } : {}) }; -} - -const boardMr = { - iid: 31, - title: 'themed gate controls', - webUrl: 'https://gitlab.example.com/acme/widgets/-/merge_requests/31', - author: { id: 'gitlab:7', username: 'paul', name: 'Paul', avatarUrl: null }, - sourceBranch: 'board-28-themed-gate-controls', - targetBranch: 'main', - createdAt: '2026-09-17T09:00:00.000Z', - diff: { additions: 84, deletions: 21, filesChanged: 6 }, -} as unknown as BoardMRWithReview; - -const ctx = (v: unknown) => JSON.stringify(v); -const findingsCtx = (...findings: object[]) => - ctx({ 'gate-ctx': 'findings@1', findings }); -const F = { - f1: { - id: 'f1', - severity: 'critical', - title: 'SQL built from unsanitized input', - file: 'lib/db/query.ts:42', - fix: 'parameterize the query', - body: 'The search handler interpolates the raw `q` parameter into the WHERE clause, so a crafted query string reaches the database as SQL.', - }, - f2: { - id: 'f2', - severity: 'important', - title: 'Missing null check on response', - file: 'lib/api/client.ts:88', - fix: 'guard before dereferencing', - body: 'A 204 from the upstream returns no body, and `data.items` is read before anything checks that `data` exists.', - }, - f3: { - id: 'f3', - severity: 'important', - title: 'Inconsistent error wording', - file: 'lib/errors.ts:15', - fix: 'align with the style guide', - body: 'Two of the new messages end in a period and one does not; the style guide asks for none.', - }, - f4: { - id: 'f4', - severity: 'minor', - title: 'Unused import', - file: 'lib/utils.ts:3', - fix: 'drop the dead import', - body: '`debounce` is imported and never called.', - }, - f5: { - id: 'f5', - severity: 'important', - title: 'Retry loop lacks backoff', - file: 'lib/retry.ts:41', - fix: 'add exponential backoff', - body: 'The loop retries immediately up to five times, which turns one upstream blip into a burst of six requests.', - }, - f6: { - id: 'f6', - severity: 'minor', - title: 'Inconsistent spacing', - file: 'lib/format.ts:9', - fix: 'run prettier', - body: 'The new block mixes two- and four-space indents.', - }, -}; - -const findingsQuestions = [ - question( - 'findings-1', - 'Post which findings to !31?', - true, - [ - { - value: 'f1', - label: '[Critical] SQL built from unsanitized input', - description: 'lib/db/query.ts:42 · parameterize the query', - }, - { - value: 'f2', - label: '[Important] Missing null check on response', - description: - 'lib/api/client.ts:88 · guard before dereferencing · kind:bug', - }, - { - value: 'f3', - label: '[Important] Inconsistent error wording', - description: 'lib/errors.ts:15 · align with the style guide', - }, - { - value: 'f4', - label: '[Minor] Unused import', - description: 'lib/utils.ts:3 · drop the dead import', - }, - ], - findingsCtx(F.f1, F.f2, F.f3, F.f4) - ), - question( - 'findings-2', - 'Post which findings to !31?', - true, - [ - { - value: 'f5', - label: '[Important] Retry loop lacks backoff', - description: - 'lib/retry.ts:41 · add exponential backoff · kind:suggestion', - }, - { - value: 'f6', - label: '[Minor] Inconsistent spacing', - description: 'lib/format.ts:9 · run prettier', - }, - ], - findingsCtx(F.f5, F.f6) - ), - question('outcome', 'Verdict on !31', false, [ - { - value: 'approve', - label: 'approve (recommended)', - description: - 'Approve !31 and post the selected findings as inline threads.', - }, - { - value: 'comment', - label: 'comment', - description: - 'Post the selected findings without a verdict; the important ones stay open.', - }, - ]), -]; - -const sixFindingGate: GateRow = { - gateId: 'sheet-six-findings', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/31', - kind: 'review-post', - label: 'review', - status: 'open', - openedAt: 1788962100000, - context: ctx({ - 'gate-ctx': 'review@1', - reviewer: 'renee', - readiness: 'with-fixes', - summary: 'One critical injection risk; everything else is polish.', - findings: { critical: 1, important: 3, minor: 2 }, - }), - questions: findingsQuestions, - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -const reportJson = JSON.stringify({ - findings: [], - depth: - 'verify. jest 120/120 green; typecheck noise was codegen, not defects.', - strengths: [ - { - lead: 'Retry loop now covers the abort path', - detail: 'lib/retry.ts:58, matches the earlier thread', - }, - { lead: 'Config validation reads clean', detail: 'lib/config.ts:12-40' }, - ], - checks: [ - { tag: 'PASS', text: 'jest suite (120/120)' }, - { tag: 'PASS', text: 'typecheck' }, - { tag: 'N/A', text: 'visual regression (no UI touched)' }, - ], - notes: ['Consider a follow-up MR to backfill retry tests for the old path.'], -}); - -// Captured once at module scope, before any story stubs it: the restore -// target every fetchStub decorator falls back to and resets to on cleanup. -const REAL_FETCH = globalThis.fetch; - -/** Installs a fetch stub for the story's mount only and restores the real - fetch on unmount, rather than clobbering globalThis.fetch permanently. - `handler` answers `/review/*` urls; anything it returns undefined for - falls through to the real fetch. */ -function fetchStub( - handler: (url: string) => Response | Promise | undefined -) { - return function FetchStubDecorator(Story: () => ReactNode) { - // Installed during render, not in an effect: the sheet's own mount - // effect fetches report.json, and child effects run before this - // decorator's would, so an effect-installed stub arrives too late. - globalThis.fetch = (async ( - input: RequestInfo | URL, - init?: RequestInit - ) => { - const url = typeof input === 'string' ? input : input.toString(); - const stubbed = await handler(url); - if (stubbed !== undefined) return stubbed; - return REAL_FETCH(input as RequestInfo, init); - }) as typeof fetch; - useEffect( - () => () => { - globalThis.fetch = REAL_FETCH; - }, - [] - ); - return ; - }; -} - -const noop = () => {}; - -function seedDraft( - gateId: string, - draft: { - selections: Record; - notes: Record; - item: string | null; - } -) { - localStorage.setItem(gateDraftKey(gateId), JSON.stringify(draft)); -} - -function Host({ gate, mr }: { gate: GateRow; mr: BoardMRWithReview }) { - const form = useGateForm(gate, noop); - return ( - - ); -} - -// --- SixFindings ------------------------------------------------------- - -/** The full record cluster: report.json resolves with every optional array - populated, so `/review/report.json` is mocked in rather than left to hit - the network from Storybook's iframe. */ -export const SixFindings: Story = { - decorators: [ - fetchStub(url => - url.startsWith('/review/report.json') - ? new Response(reportJson, { - status: 200, - headers: { 'content-type': 'application/json' }, - }) - : undefined - ), - ], - render: () => , -}; - -// --- UnselectedSome ------------------------------------------------------ - -/** Mid-triage: three findings unchecked, so the tally and submit label both - read a partial count -- the draft seed drives the same localStorage key - useGateForm reads on mount. */ -const partialGate: GateRow = { ...sixFindingGate, gateId: 'sheet-partial' }; - -export const UnselectedSome: Story = { - decorators: [ - fetchStub(url => - url.includes('/review/') - ? new Response('no structured review yet', { status: 404 }) - : undefined - ), - ], - render: () => { - seedDraft(partialGate.gateId, { - selections: { findings: ['f1', 'f3', 'f5'], outcome: 'comment' }, - notes: {}, - item: null, - }); - return ; - }, -}; - -// --- CleanReview ----------------------------------------------------------- - -/** No findings at all: the outcome question rides alone, so the sheet skips - the findings head/list entirely and shows only the MR card, the (absent) - record cluster's full-report fallback, and the verdict. */ -const cleanGate: GateRow = { - gateId: 'sheet-clean', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/46', - kind: 'review-post', - label: 'review', - status: 'open', - openedAt: 1788964320000, - context: ctx({ - 'gate-ctx': 'review@1', - readiness: 'yes', - summary: 'Nothing flagged worth a thread.', - findings: {}, - }), - questions: [ - question('outcome', 'Verdict on !46', false, [ - { - value: 'approve', - label: 'approve (recommended)', - description: 'Approve !46; nothing was flagged worth a thread.', - }, - { - value: 'comment', - label: 'comment', - description: 'Leave a comment without approving.', - }, - ]), - ], - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -const cleanMr = { ...boardMr, iid: 46 } as BoardMRWithReview; - -export const CleanReview: Story = { - decorators: [ - fetchStub(url => - url.includes('/review/') - ? new Response('no structured review yet', { status: 404 }) - : undefined - ), - ], - render: () => , -}; - -// --- ReReview -------------------------------------------------------------- - -/** Round 2: the decision card's meta line carries the reviewer, the round, - and the addressed/still-open tally, and three carried-over findings each - show their own disposition pill. */ -const reReviewGate: GateRow = { - gateId: 'sheet-re-review', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/31', - kind: 'review-post', - label: 'review', - status: 'open', - openedAt: 1788965520000, - context: ctx({ - 'gate-ctx': 'review@1', - reviewer: 'renee', - readiness: 'with-fixes', - summary: 'Two important findings carried over; the rest is polish.', - findings: { important: 2, minor: 1 }, - round: 2, - re_review: true, - prior: { addressed: 3, still_open: 1 }, - }), - questions: [ - question( - 'findings-1', - 'Post which findings to !31?', - true, - [ - findingsQuestions[0]!.options[1]!, - findingsQuestions[1]!.options[0]!, - findingsQuestions[1]!.options[1]!, - ], - findingsCtx( - { ...F.f2, disposition: 'still-open' }, - { ...F.f5, disposition: 'new' }, - { ...F.f6, disposition: 'addressed-check' } - ) - ), - findingsQuestions[2]!, - ], - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -export const ReReview: Story = { - decorators: [ - fetchStub(url => - url.includes('/review/') - ? new Response('no structured review yet', { status: 404 }) - : undefined - ), - ], - render: () => , -}; - -// --- Later rounds: earlier threads, new findings, skipped findings -------- - -const no404 = fetchStub(url => - url.includes('/review/') - ? new Response('no structured review yet', { status: 404 }) - : undefined -); - -function carryQuestion( - n: number, - thread: string, - file: string, - defaults: { post: boolean; resolve: boolean }, - carry: { - round: number; - call: 'fixed' | 'not-fixed' | 'pushback-accepted' | 'pushback-rejected'; - original: string; - authorReply?: string; - note?: string; - reply: string; - } -): GateQuestion { - return question( - `thread-${n}`, - file, - true, - [ - { - value: `post:${thread}`, - label: defaults.post ? 'Post reply (Recommended)' : 'Post reply', - description: 'post this reply to the thread', - }, - { - value: `resolve:${thread}`, - label: defaults.resolve ? 'Resolve (Recommended)' : 'Resolve', - description: 'resolve the thread when the review is submitted', - }, - ], - ctx({ 'gate-ctx': 'carryover@1', thread, file, ...carry }) - ); -} - -const threadFixed = carryQuestion( - 1, - 'd-query', - 'lib/db/query.ts:42', - { post: true, resolve: true }, - { - round: 1, - call: 'fixed', - original: - '**issue:** the search handler interpolates the raw `q` parameter into the WHERE clause, so a crafted query string reaches the database as SQL.', - authorReply: - 'Switched to a parameterized query in 4c1e9a2 and added a test with a quote in the search term.', - note: 'verified: query.ts:42 now binds $1, and the new test covers a quoted term', - reply: "Thanks, that's exactly the fix. Resolving.", - } -); - -const threadNotFixed = carryQuestion( - 2, - 'd-retry', - 'lib/retry.ts:41', - { post: true, resolve: false }, - { - round: 2, - call: 'not-fixed', - original: - '**issue:** the loop retries immediately up to five times, which turns one upstream blip into a burst of six requests.', - authorReply: 'Added exponential backoff in 7d2f310.', - note: 'the backoff landed, but an aborted request still goes round the loop: retry.ts:58 never checks signal.aborted', - reply: - 'The backoff looks good. One gap left: an aborted request still goes round the loop, since nothing checks `signal.aborted` before the next try (`retry.ts:58`).', - } -); - -const threadPushbackAccepted = carryQuestion( - 3, - 'd-errors', - 'lib/errors.ts:15', - { post: true, resolve: true }, - { - round: 2, - call: 'pushback-accepted', - original: - '**suggestion:** two of the new messages end in a period and one does not; the style guide asks for none.', - authorReply: - "These mirror the upstream API's error strings word for word, so support can search for them. I'd rather keep them identical.", - note: 'fair: the strings are copied verbatim from the upstream API, and matching them is the better rule here', - reply: - 'Makes sense, matching the API exactly is the better call. Resolving.', - } -); - -const threadPushbackRejected = carryQuestion( - 4, - 'd-client', - 'lib/api/client.ts:88', - { post: true, resolve: false }, - { - round: 1, - call: 'pushback-rejected', - original: - '**issue:** a 204 from the upstream returns no body, and `data.items` is read before anything checks that `data` exists.', - authorReply: 'The upstream never sends a 204 for this endpoint.', - note: 'the upstream docs list a 204 for an empty page, and client.test.ts has no case for it', - reply: - "The upstream docs list a 204 for an empty page (`GET /items?page=`), so I think the guard still earns its place. Happy to be wrong if you've seen otherwise.", - } -); - -const threadNoReply = carryQuestion( - 5, - 'd-cache-ttl', - 'lib/cache.ts:9', - { post: true, resolve: false }, - { - round: 2, - call: 'not-fixed', - original: - '**question:** is a 24h TTL intended here? The other caches in this module use 5 minutes.', - note: 'no reply and no change at cache.ts:9', - reply: 'Bumping this one: is the 24h TTL intended?', - } -); - -const NEW = { - n1: { - id: 'n1', - severity: 'important', - title: 'Cache key ignores the tenant', - file: 'lib/cache.ts:23', - fix: 'prefix the key with the tenant id', - body: 'Two tenants asking for the same page share one cache entry, so the second one is served the first tenant’s results.', - disposition: 'new', - }, - n2: { - id: 'n2', - severity: 'minor', - title: 'Debug log prints the whole session object', - file: 'lib/auth/session.ts:61', - fix: 'log the session id only', - body: 'The new `log.debug` call serializes the full session, refresh token included.', - disposition: 'new', - }, -}; - -const newFindingsQuestion = question( - 'findings-1', - 'Post which findings to !31?', - true, - [ - { - value: 'n1', - label: '[Important] Cache key ignores the tenant', - description: 'lib/cache.ts:23 · prefix the key with the tenant id', - }, - { - value: 'n2', - label: '[Minor] Debug log prints the whole session object', - description: 'lib/auth/session.ts:61 · log the session id only', - }, - ], - findingsCtx(NEW.n1, NEW.n2) -); - -const skippedQuestion = question( - 'skipped-1', - 'Bring back which skipped findings?', - true, - [ - { - value: 'restore:s1', - label: '[Minor] Unused import', - description: 'lib/utils.ts:3 · skipped in round 1', - }, - { - value: 'restore:s2', - label: '[Minor] Inconsistent spacing', - description: 'lib/format.ts:9 · skipped in round 1 · code changed since', - }, - { - value: 'restore:s3', - label: '[Important] Config defaults live in two files', - description: 'lib/config.ts:12 · skipped in round 2', - }, - ], - ctx({ - 'gate-ctx': 'skipped@1', - skipped: [ - { - id: 's1', - round: 1, - severity: 'minor', - title: 'Unused import', - file: 'lib/utils.ts:3', - }, - { - id: 's2', - round: 1, - severity: 'minor', - title: 'Inconsistent spacing', - file: 'lib/format.ts:9', - changed: true, - }, - { - id: 's3', - round: 2, - severity: 'important', - title: 'Config defaults live in two files', - file: 'lib/config.ts:12', - }, - ], - }) -); - -const commentFirst = question('outcome', 'Verdict on !31', false, [ - { - value: 'comment', - label: 'comment (recommended)', - description: - 'Submit as a Comment review: the replies, resolves and new findings post together.', - }, - { - value: 'approve', - label: 'approve', - description: 'Submit the review and approve !31.', - }, -]); - -const roundThreeGate: GateRow = { - gateId: 'sheet-round-three', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/31', - kind: 'review-post', - label: 'review', - status: 'open', - openedAt: 1788969120000, - context: ctx({ - 'gate-ctx': 'review@1', - reviewer: 'renee', - readiness: 'with-fixes', - summary: - 'The injection fix and the backoff both landed. An aborted request still retries, the 204 guard is still missing, and the new cache layer shares entries across tenants.', - findings: { important: 1, minor: 1 }, - round: 3, - re_review: true, - }), - questions: [ - threadFixed, - threadNotFixed, - threadPushbackAccepted, - threadPushbackRejected, - threadNoReply, - newFindingsQuestion, - skippedQuestion, - commentFirst, - ], - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -/** Round 3: five earlier threads, one per call the agent can make (plus a - thread the author never answered), two new findings, and three findings - skipped in earlier rounds, collapsed. Fixed and accepted pushback start - on reply + resolve; the rest start on reply only. */ -export const RoundThree: Story = { - decorators: [no404], - render: () => , -}; - -const bringingBackGate: GateRow = { - ...roundThreeGate, - gateId: 'sheet-round-three-bringing-back', -}; - -/** The same round with one skipped finding ticked to come back: the skipped - row opens on its own, its hint and the submit label count it, and the - rest stay off the MR. */ -export const RoundThreeBringingOneBack: Story = { - decorators: [no404], - render: () => { - seedDraft(bringingBackGate.gateId, { - selections: { skipped: ['restore:s2'] }, - notes: {}, - item: null, - }); - return ; - }, -}; - -const settledGate: GateRow = { - gateId: 'sheet-round-two-settled', - subject: 'mr:gitlab.example.com/acme/widgets/-/merge_requests/31', - kind: 'review-post', - label: 'review', - status: 'open', - openedAt: 1788970320000, - context: ctx({ - 'gate-ctx': 'review@1', - reviewer: 'renee', - readiness: 'yes', - summary: - 'Both asks from round 1 are settled and nothing new turned up. Ready to approve.', - findings: {}, - round: 2, - re_review: true, - }), - questions: [ - threadFixed, - threadPushbackAccepted, - question('outcome', 'Verdict on !31', false, [ - { - value: 'approve', - label: 'approve (recommended)', - description: 'Submit the replies and resolves, and approve !31.', - }, - { - value: 'comment', - label: 'comment', - description: 'Submit the replies and resolves without approving.', - }, - ]), - ], - origin: { paneId: 'pane-1', worktree: 'widgets' }, -}; - -/** Round 2 where everything from round 1 is settled: the earlier threads - start on reply + resolve, the new-findings section says nothing turned - up, and approve is the recommendation. */ -export const RoundTwoAllSettled: Story = { - decorators: [no404], - render: () => , -}; - -// --- DraftedReReview ----------------------------------------------------- - -const draftedNewFindings = question( - 'findings-1', - 'Post which findings to !31?', - true, - newFindingsQuestion.options, - findingsCtx( - { - ...NEW.n1, - comment: - 'Two tenants asking for the same page share one entry here, so the second one gets the first tenant’s results. Could the key start with the tenant id?\n\n```ts\nconst key = `${tenantId}:${page}`;\n```', - }, - { - ...NEW.n2, - comment: - 'Small one: this `log.debug` serializes the whole session, refresh token included. Logging `session.id` would be enough.', - } - ) -); - -const draftedReReviewGate: GateRow = { - ...roundThreeGate, - gateId: 'sheet-drafted-re-review', - context: ctx({ - ...(JSON.parse(roundThreeGate.context!) as Record), - editable: true, - summaryComments: { - comment: - 'Thanks for the quick turnaround. Two earlier threads still need a look, and two new things turned up inline.', - approve: - 'Thanks for the quick turnaround. The rest are small, so approving; the inline notes can land in a follow-up.', - }, - }), - questions: roundThreeGate.questions.map(q => - q.id === 'findings-1' ? draftedNewFindings : q - ), -}; - -/** A drafted round 3: the earlier threads keep their reply composers, each - with Post reply and Resolve thread in its footer, then the new findings - as drafted cards. No diff source is wired, so each card's code shows its - file header only. */ -export const DraftedReReview: Story = { - decorators: [no404], - render: () => , -}; - -// --- DraftedFirstRound --------------------------------------------------- - -const draftedFirstRoundFindings = question( - 'findings-1', - 'Post which findings to !31?', - true, - [ - { - value: 'f1', - label: '[Critical] SQL built from unsanitized input', - description: 'lib/db/query.ts:42 · parameterize the query', - }, - { - value: 'f5', - label: '[Important] Retry loop lacks backoff', - description: 'lib/retry.ts:41 · add exponential backoff', - }, - { - value: 'n-readme', - label: '[Minor] README still names the old folder', - description: 'README.md · mention the new path', - }, - { - value: 'n-evidence', - label: '[Minor] No run for the --changed flag', - description: 'evidence · one quick run', - }, - ], - findingsCtx( - { - ...F.f1, - comment: - '**issue:** the search handler drops the raw `q` straight into the `WHERE` clause, so a crafted query string reaches the database as SQL. could this use a bound parameter?\n\n```ts\ndb.query("SELECT * FROM items WHERE name = $1", [q]);\n```', - }, - { - ...F.f5, - comment: - '**suggestion:** the loop retries immediately up to five times, so one upstream blip turns into six requests. something like `Math.min(200 * 2 ** attempt, 5000)` would back it off.', - }, - { - id: 'n-readme', - severity: 'minor', - title: 'README still names the old folder', - file: 'README.md', - fix: 'mention the new path', - body: 'The README still says the scanner reads `lib/jobs`, and nothing mentions the move to `lib/tasks`.', - comment: - '**suggestion:** the `README.md` still says the scanner reads `lib/jobs`. anyone with a local overlay keyed on the old paths will get `merged 0/N` with no hint why. could we add a line about the move?', - }, - { - id: 'n-evidence', - severity: 'minor', - title: 'No run for the --changed flag', - fix: 'one quick run', - body: 'Every AC has a run in the evidence except `--changed` picking up a file under `lib/tasks/**`.', - comment: - '**suggestion:** thanks for the extra evidence, that covers it nicely. the one AC without a run is `--changed` picking up a file under `lib/tasks/**`... would you mind grabbing one quick run of that?', - } - ) -); - -const draftedFirstRoundGate: GateRow = { - ...sixFindingGate, - gateId: 'sheet-drafted-first-round', - context: ctx({ - ...(JSON.parse(sixFindingGate.context!) as Record), - findings: { critical: 1, important: 1, minor: 2 }, - editable: true, - summaryComments: { - approve: - 'Left a few comments, the injection one is the main one. Approving once that lands.', - comment: 'Left a few comments, the injection one is the main one.', - }, - }), - questions: [ - draftedFirstRoundFindings, - question('outcome', 'Verdict on !31', false, [ - { - value: 'comment', - label: 'comment (recommended)', - description: 'Post the selected findings without a verdict.', - }, - { - value: 'approve', - label: 'approve', - description: 'Approve !31 and post the selected findings.', - }, - ]), - ], -}; - -const FIRST_ROUND_PATCHES: Record = { - 'lib/db/query.ts': `@@ -35,12 +35,14 @@ export async function searchItems( - ) { - const db = await pool.connect(); - const q = req.query.q ?? ''; - const limit = Number(req.query.limit ?? 20); - -- const rows = await db.query('SELECT * FROM items LIMIT $1', [limit]); -+ const rows = await db.query( -+ \`SELECT * FROM items -+ WHERE name LIKE '%\${q}%' -+ LIMIT \${limit}\` -+ ); - return rows.map(toItem); - } - - export async function itemById(id: string) {`, - 'lib/retry.ts': `@@ -34,12 +34,14 @@ export async function withRetry( - job: () => Promise, - attempts = 5 - ): Promise { -- return job(); -+ for (let attempt = 0; ; attempt++) { -+ try { -+ return await job(); -+ } catch (err) { -+ if (attempt >= attempts) throw err; -+ log.warn('retrying', { attempt }); -+ } -+ } - } - - export function isRetryable(err: unknown) {`, -}; - -/** Invented patches for the two files the drafted findings sit on. */ -const firstRoundDiffs: DiffSource = { - fetchFileDiff: async ({ file }) => - FIRST_ROUND_PATCHES[file] - ? { - path: file, - patch: FIRST_ROUND_PATCHES[file]!, - forge: 'gitlab', - webUrl: `${boardMr.webUrl}/diffs`, - } - : null, - loadView: () => import('./DiffView.tsx'), -}; - -/** A drafted first round with click-to-edit comments: two findings on a - diff line, two that fold into the summary comment. Every comment reads - rendered and opens raw on a click; a folded one edits in the summary or - in its own card. */ -export const DraftedFirstRound: Story = { - // A real gate's report.json carries the record cluster; without it the - // column falls back to the Full report disclosure, which real gates - // rarely show. - decorators: [ - fetchStub(url => - url.startsWith('/review/report.json') - ? new Response(reportJson, { - status: 200, - headers: { 'content-type': 'application/json' }, - }) - : undefined - ), - ], - render: () => ( - - - - ), -}; diff --git a/apps/board/src/client/board/gate-stories.fixtures.ts b/apps/board/src/client/board/gate-stories.fixtures.ts new file mode 100644 index 0000000000..4ecf3b85be --- /dev/null +++ b/apps/board/src/client/board/gate-stories.fixtures.ts @@ -0,0 +1,801 @@ +import type { GateQuestion, GateRow } from '../../gates/store.ts'; +import type { BoardMRWithReview } from '../types.ts'; +import type { DiffSource, FileDiff } from './diff-source.ts'; + +/** + * The four gates Storybook shows, rebuilt from real review-post, + * respond-plan and respond-post rows: question ids and counts, option + * labels and descriptions, gate-ctx shapes, meta and origin keep the real + * rows' structure, while every name, ticket, MR, path and piece of domain + * wording is invented. This repo is public, so nothing here may be copied + * from a live board. + */ + +export interface StoryGate { + gate: GateRow; + mr: BoardMRWithReview; + /** Where a drafted review's code sections get their diffs. */ + diffs?: DiffSource; +} + +export const PEOPLE: ReadonlyMap = new Map([ + ['pat', 'Pat Lin'], + ['renee', 'Renee Park'], + ['dana', 'Dana Ortiz'], +]); + +const minutesAgo = (m: number) => Date.now() - m * 60_000; +const ctx = (v: unknown) => JSON.stringify(v); + +function opt(value: string, label: string, description?: string) { + return description === undefined + ? { value, label } + : { value, label, description }; +} + +function question( + id: string, + label: string, + multi: boolean, + options: GateQuestion['options'], + context?: string +): GateQuestion { + return { id, label, multi, options, ...(context ? { context } : {}) }; +} + +const FORGE = 'https://gitlab.example.com/acme/webapp/-/merge_requests'; +const TREES = '/Users/pat/worktrees/gl-acme-webapp'; +const mrSubject = (iid: number) => `mr:${FORGE}/${iid}`; + +function boardMr( + iid: number, + title: string, + author: string, + sourceBranch: string, + diff: [number, number, number], + approvals: [given: number, required: number] +): BoardMRWithReview { + const [given, required] = approvals; + return { + iid, + title, + webUrl: `${FORGE}/${iid}`, + author: { + id: `gitlab:${author.length + iid}`, + username: author, + name: PEOPLE.get(author) ?? author, + avatarUrl: null, + }, + sourceBranch, + targetBranch: 'main', + isDraft: false, + createdAt: new Date(minutesAgo(60 * 26)).toISOString(), + diff: { additions: diff[0], deletions: diff[1], filesChanged: diff[2] }, + pipelineState: 'passed', + behindTarget: null, + reviews: { + required, + given, + remaining: Math.max(required - given, 0), + isApproved: given >= required, + approvedBy: [], + reviewers: [], + totalAssigned: 0, + haveActed: given, + havePending: 0, + haveNotStarted: 0, + }, + blockers: { + isDraft: false, + hasConflicts: false, + needsRebase: false, + pipelineFailing: false, + pipelineRunning: false, + awaitingApprovals: given < required, + }, + } as unknown as BoardMRWithReview; +} + +// --- Review: Dana's MR, reviewed by me ------------------------------------- + +const TILE_MR = boardMr( + 826, + 'ACME-3420: cache forecast tiles per region', + 'dana', + 'acme-3420-forecast-tile-cache', + [184, 41, 5], + [0, 1] +); + +const CACHE = 'packages/forecast/src/tiles/tileCache.ts'; +const CACHE_TEST = 'packages/forecast/src/tiles/tileCache.test.ts'; + +const TILE_FINDINGS = { + f1: { + id: 'f1', + severity: 'critical', + title: 'Cache key drops the region', + body: '`tileKey` builds the key from zoom, x and y only. Two regions asking for the same tile coordinates share one entry, so the second region is served the first region’s forecast until the entry expires.', + file: `${CACHE}:31`, + fix: 'Put the region first in the key and add a two-region test.', + kind: 'issue', + comment: + '**issue:** the key here is zoom/x/y only, so two regions asking for the same tile share one entry and the second one gets the first region’s forecast. could the region go first?\n\n```ts\nconst tileKey = (t: TileRequest) => `${t.region}:${t.z}/${t.x}/${t.y}`;\n```', + }, + f2: { + id: 'f2', + severity: 'important', + title: 'An expired tile is served after a failed refetch', + body: 'When the refetch throws, the catch returns `entry.tile` without checking `expiresAt`, so a tile from yesterday keeps rendering with no stale marker.', + file: `${CACHE}:58`, + fix: 'Return the stale tile flagged as stale, or rethrow once it is past a hard limit.', + kind: 'question', + comment: + '**question:** if the refetch throws, this hands back `entry.tile` no matter how old it is. is that on purpose? a tile from yesterday would keep rendering with nothing saying it’s stale.', + }, + f3: { + id: 'f3', + severity: 'important', + title: 'Evidence stops at the unit tests', + body: 'The tests prove the cache in isolation, but nothing in the description shows the map reading cached tiles across a region switch, which is the AC the ticket leads with.', + fix: 'Add a before/after capture of a region switch on the forecast map.', + kind: 'suggestion', + comment: + '**suggestion:** would you mind adding a capture of a region switch on the forecast map? the tests cover the cache itself, but the first AC is about what the map shows.', + }, + f4: { + id: 'f4', + severity: 'minor', + title: 'Eviction never runs below the size cap', + body: '`evict` only runs when `size > maxEntries`, so expired entries sit in memory until the cache fills.', + file: `${CACHE}:66`, + fix: 'Drop expired entries on read as well.', + kind: 'issue', + comment: + '**issue (non-blocking):** expired entries only leave when the cache is full, so a quiet session keeps every old tile around. dropping them on read would cover it.', + }, + f5: { + id: 'f5', + severity: 'minor', + title: 'Two new branches have no test', + body: 'Nothing covers the refetch failure path or the eviction of an expired entry.', + fix: 'Add a case per branch.', + kind: 'suggestion', + comment: + '**suggestion (non-blocking):** the refetch failure and the expired eviction don’t have a test yet. a case each would pin them.', + }, + f6: { + id: 'f6', + severity: 'minor', + title: 'Test helper falls back to the first tile', + body: '`findTile(tiles, key) ?? tiles[0]` quietly tests the wrong tile if the fixture drifts.', + file: `${CACHE_TEST}:108`, + fix: 'Assert the tile exists instead of falling back.', + kind: 'nitpick', + comment: + '**nitpick:** the `?? tiles[0]` fallback would quietly test the wrong tile if the fixture changes. an assert would fail loudly instead.', + }, + f7: { + id: 'f7', + severity: 'minor', + title: 'Description lists the radar layer as a consumer', + body: 'The radar layer reads tiles through its own loader and never touches this cache.', + fix: 'Drop the radar layer from the consumer list.', + kind: 'nitpick', + comment: + '**nitpick:** the description lists the radar layer as a consumer, but it goes through its own loader. worth dropping so nobody goes looking.', + }, +}; + +const findingOption = ( + f: (typeof TILE_FINDINGS)[keyof typeof TILE_FINDINGS] +) => + opt( + f.id, + `[${f.severity[0]!.toUpperCase()}${f.severity.slice(1)}] ${f.title}`, + `${'file' in f ? f.file : 'not inline-anchorable (MR description)'} · ${f.fix} · kind:${f.kind}` + ); + +const tileOrigin = { + presentation: 'form' as const, + surface: 'board', + tabId: 'w41:t1', + worktree: `${TREES}/juniper`, + paneId: 'w41:p1', + runId: '20261027-142817-249f-11804', +}; + +const reviewMeta = { + label: 'review gate !826', + headline: 'Your review of !826 is ready', + summary: 'Pick what to post', +}; + +const { f1, f2, f3, f4, f5, f6, f7 } = TILE_FINDINGS; + +export const reviewFirstRound: StoryGate = { + mr: TILE_MR, + diffs: { + fetchFileDiff: async ({ file }) => TILE_DIFFS[file] ?? null, + loadView: () => import('./DiffView.tsx'), + }, + gate: { + gateId: 'story-review-first-round', + subject: mrSubject(826), + kind: 'review-post', + label: 'review gate !826', + status: 'open', + openedAt: minutesAgo(8), + meta: reviewMeta, + domain: 'review', + origin: tileOrigin, + context: ctx({ + 'gate-ctx': 'review@1', + readiness: 'with-fixes', + summary: + 'The cache does what the ticket asks for one region, and the tests are red on main and green here. The key drops the region, so a second region reads the first one’s tiles; that one blocks.', + findings: { critical: 1, important: 2, minor: 4 }, + editable: true, + summaryComments: { + comment: + 'Left a few comments! The region key one is blocking for me.', + approve: + 'Looks good to me, approving. Left a few inline notes to pick up on the way.', + }, + }), + questions: [ + question( + 'findings-1', + 'Post which findings to !826?', + true, + [f1, f2, f3, f4].map(findingOption), + ctx({ 'gate-ctx': 'findings@1', findings: [f1, f2, f3, f4] }) + ), + question( + 'findings-2', + 'Post which findings to !826?', + true, + [f5, f6, f7].map(findingOption), + ctx({ 'gate-ctx': 'findings@1', findings: [f5, f6, f7] }) + ), + question( + 'outcome', + 'Verdict on !826: ready once the cache key carries the region', + false, + [ + opt( + 'comment', + 'Comment (recommended)', + 'post the picked findings and leave approval for after the key fix' + ), + opt( + 'approve', + 'Approve', + 'post the picked findings and approve now despite the key bug' + ), + ] + ), + ], + }, +}; + +// Invented code for the two files the findings sit on, as GitLab's MR diffs +// API returns them: per-file hunks with no file headers. +const CACHE_PATCH = `@@ -24,8 +24,14 @@ export interface TileRequest { + z: number; + x: number; + y: number; + } + ++const TTL_MS = 15 * 60_000; ++ ++const tileKey = (t: TileRequest) => \`\${t.z}/\${t.x}/\${t.y}\`; ++ ++const entries = new Map(); ++ + export async function getTile(t: TileRequest): Promise { + const key = tileKey(t); + const entry = entries.get(key); +@@ -44,13 +50,26 @@ export async function getTile(t: TileRequest): Promise { + if (entry && entry.expiresAt > Date.now()) return entry.tile; + + try { + const tile = await fetchTile(t); +- return tile; ++ entries.set(key, { tile, expiresAt: Date.now() + TTL_MS }); ++ evict(); ++ return tile; + } catch (err) { +- throw err; ++ if (entry) return entry.tile; ++ throw err; + } + } + ++const maxEntries = 400; ++ ++function evict() { ++ if (entries.size <= maxEntries) return; ++ const oldest = [...entries.entries()] ++ .sort(([, a], [, b]) => a.expiresAt - b.expiresAt) ++ .slice(0, entries.size - maxEntries); ++ for (const [key] of oldest) entries.delete(key); ++} ++ + export function clearTiles() { + entries.clear(); + } +`; + +const CACHE_TEST_PATCH = `@@ -98,6 +98,23 @@ describe('getTile', () => { + expect(fetchTile).toHaveBeenCalledTimes(1); + }); + ++ it('serves a cached tile inside the TTL', async () => { ++ const tiles = await loadFixtureTiles(); ++ const key = '6/12/18'; ++ fetchTile.mockResolvedValueOnce(findTile(tiles, key)); ++ await getTile({ region: 'north', z: 6, x: 12, y: 18 }); ++ const again = await getTile({ region: 'north', z: 6, x: 12, y: 18 }); ++ expect(fetchTile).toHaveBeenCalledTimes(1); ++ expect(again).toBe(findTile(tiles, key) ?? tiles[0]); ++ }); ++ ++ it('evicts the oldest tiles past the cap', async () => { ++ for (let i = 0; i < 401; i++) ++ await getTile({ region: 'north', z: 6, x: i, y: 0 }); ++ await getTile({ region: 'north', z: 6, x: 0, y: 0 }); ++ expect(fetchTile).toHaveBeenCalledTimes(402); ++ }); ++ + it('rethrows when nothing is cached', async () => { + fetchTile.mockRejectedValueOnce(new Error('offline')); + await expect(getTile(req)).rejects.toThrow('offline'); +`; + +const TILE_DIFFS: Record = { + [CACHE]: { + path: CACHE, + patch: CACHE_PATCH, + forge: 'gitlab', + webUrl: `${FORGE}/826/diffs#tileCache.ts`, + }, + [CACHE_TEST]: { + path: CACHE_TEST, + patch: CACHE_TEST_PATCH, + forge: 'gitlab', + webUrl: `${FORGE}/826/diffs#tileCache.test.ts`, + }, +}; + +const carryover = ( + n: number, + thread: string, + label: string, + defaults: { post: boolean; resolve: boolean }, + carry: { + round: number; + call: 'fixed' | 'not-fixed' | 'pushback-accepted' | 'pushback-rejected'; + original: string; + reply: string; + file?: string; + authorReply?: string; + note?: string; + } +) => + question( + `thread-${n}`, + label, + true, + [ + opt( + `post:${thread}`, + defaults.post ? 'Post reply (recommended)' : 'Post reply' + ), + opt( + `resolve:${thread}`, + defaults.resolve ? 'Resolve thread (recommended)' : 'Resolve thread' + ), + ], + ctx({ 'gate-ctx': 'carryover@1', thread, ...carry }) + ); + +const TILE_THREADS = { + key: '5d1c0e7a93b24f6a8c0e1f2d3b4a5c6d7e8f9a01', + stale: 'a07e4c2b9d1f3e5a7c9b0d2f4e6a8c1b3d5f7e92', + evidence: 'c3f9e1d7b5a3c1e9f7d5b3a1c9e7f5d3b1a9c8e4', +}; + +const N1 = { + id: 'n1', + severity: 'minor', + title: 'Stale flag is never cleared on a fresh fetch', + body: 'The new `stale` flag is set when the refetch fails, but the success path builds a fresh entry without it, so it only clears by accident.', + file: `${CACHE}:54`, + fix: 'Set stale: false explicitly on the success path.', + kind: 'nitpick', + disposition: 'new', + comment: + '**nitpick:** the success path relies on the new entry not having `stale` at all. an explicit `stale: false` would say it out loud.', +}; + +/** Round 2 of the same review: the key fix landed, the stale-tile answer + was a fair pushback, the evidence is still missing, and one small new + finding turned up. */ +export const reviewReReview: StoryGate = { + mr: { ...TILE_MR, diff: { additions: 213, deletions: 44, filesChanged: 6 } }, + diffs: reviewFirstRound.diffs!, + gate: { + gateId: 'story-review-re-review', + subject: mrSubject(826), + kind: 'review-post', + label: 'review gate !826', + status: 'open', + openedAt: minutesAgo(5), + meta: reviewMeta, + domain: 'review', + origin: { ...tileOrigin, runId: '20261028-103324-937e-11805' }, + context: ctx({ + 'gate-ctx': 'review@1', + readiness: 'with-fixes', + summary: + 'The region key is fixed and pinned by a two-region test. The stale-tile pushback holds up. The region-switch evidence is still missing, which is the one thing left before approval.', + findings: { minor: 1 }, + round: 2, + re_review: true, + prior: { addressed: 2, still_open: 1 }, + editable: true, + summaryComments: { + comment: + 'Thanks for the updates! Replied on the threads; the evidence is the last thing for me.', + approve: + 'Looks good to me, thanks for the quick turnaround! Replied on the threads.', + }, + }), + questions: [ + carryover( + 1, + TILE_THREADS.key, + `${CACHE}:31`, + { post: true, resolve: true }, + { + round: 1, + call: 'fixed', + file: `${CACHE}:31`, + original: f1.comment, + authorReply: + 'good catch. the region goes first in the key now, and there’s a test that asks two regions for the same tile.', + note: 'tileKey (tileCache.ts:31) now starts with region; tileCache.test.ts:130 asks north and south for 6/12/18 and expects two fetches.', + reply: 'thanks, that’s exactly it. resolving.', + } + ), + carryover( + 2, + TILE_THREADS.stale, + `${CACHE}:58`, + { post: true, resolve: true }, + { + round: 1, + call: 'pushback-accepted', + file: `${CACHE}:58`, + original: f2.comment, + authorReply: + 'that’s on purpose: an old forecast beats a blank map when the tile service is down. i added a `stale` flag so the map can dim it, and the map work is ACME-3431.', + note: 'the stale flag lands on the entry (tileCache.ts:61) and ACME-3431 covers dimming; serving old tiles during an outage is the documented product call.', + reply: + 'fair, an old forecast is better than a blank map. the flag covers what i was after.', + } + ), + carryover( + 3, + TILE_THREADS.evidence, + 'General thread', + { post: true, resolve: false }, + { + round: 1, + call: 'not-fixed', + original: f3.comment, + note: 'the description still has no capture of a region switch; no reply on the thread.', + reply: + 'bumping this one: could you grab a quick capture of a region switch on the forecast map?', + } + ), + question( + 'findings-1', + 'Post which findings to !826?', + true, + [ + opt( + 'n1', + '[Minor] Stale flag is never cleared on a fresh fetch', + `${CACHE}:54 · Set stale: false explicitly on the success path. · kind:nitpick` + ), + ], + ctx({ 'gate-ctx': 'findings@1', findings: [N1] }) + ), + question( + 'outcome', + 'Verdict on !826: ready once the evidence is up', + false, + [ + opt( + 'comment', + 'Comment (recommended)', + 'Posts the thread replies and the new finding, and leaves approval for later.' + ), + opt( + 'approve', + 'Approve', + 'Posts the thread replies and the new finding, and approves the MR.' + ), + ] + ), + ], + }, +}; + +// --- Respond: my MR, reviewed by Renee ------------------------------------- + +const PIN_MR = boardMr( + 831, + 'ACME-3436: keep station pins when the map re-centers', + 'pat', + 'acme-3436-keep-station-pins', + [97, 22, 4], + [0, 1] +); + +const PIN_THREADS = { + t1: '7b2e9c4a1d6f8e3b5a0c2d4f6e8a1b3c5d7e9f02', + t2: 'e4a1c7b3d9f5e2a8c6b0d4f1e7a3c9b5d2f8e613', + t3: '2c8f4a6e1b9d3f7a5c0e8b2d6f4a1c9e3b7d5f24', + t4: '9d3b7f1e5a2c8d4b6f0a3e9c1d7b5f2a8e4c6d35', + t5: 'f6a0d2c8e4b1f7a3d9c5e2b8f0a6d4c1e7b3f946', +}; + +const PIN_FILES = { + t1: 'StationPins.tsx:48', + t2: 'useMapCenter.ts:22', + t3: 'StationPins.test.tsx:91', + t4: 'stationLayer.ts:130', + t5: 'General thread', +}; + +const PIN_PLAN = { + t1: { + fix: 'Key the pin list by station id instead of the array index.', + claim: + 'the pins are keyed by index, so a re-center that reorders stations remounts every pin and drops the open popover.', + note: 'confirmed: StationPins.tsx:48 keys by index, and the station list is re-sorted by distance on every re-center.', + reply: + 'pins are keyed by station id now, so a re-center that reorders them keeps the open popover.', + }, + t2: { + fix: 'Read the center from the map’s moveend event instead of a 200ms poll.', + claim: + 'polling the center every 200ms keeps a timer running while the map is idle; the map already fires `moveend`.', + note: 'confirmed: useMapCenter.ts:22 sets an interval with no idle check, and moveend fires after every pan and zoom.', + reply: + 'switched to `moveend`, so nothing runs while the map is idle.', + }, + t3: { + fix: 'Add a test that re-centers with a popover open and expects it to stay open.', + claim: + 'the tests only check pin count; nothing covers the bug the ticket is about, a popover closing on re-center.', + note: 'confirmed: StationPins.test.tsx has no popover assertion.', + reply: + 'added a test that opens a popover, re-centers, and expects it to stay open. it fails on main.', + }, + t4: { + fix: 'Drop the unused cluster radius export.', + claim: '`CLUSTER_RADIUS` is exported but nothing imports it.', + note: 'confirmed: no import of CLUSTER_RADIUS anywhere in the repo.', + reply: 'dropped it.', + }, +}; + +const planThread = ( + n: number, + key: keyof typeof PIN_PLAN, + severity: 'blocking' | 'non-blocking' +) => + question( + `thread-${n}`, + PIN_FILES[key], + false, + [ + opt( + `reply:${PIN_THREADS[key]}`, + 'Reply only', + 'Post the drafted reply and change no code.' + ), + opt( + `fix:${PIN_THREADS[key]}`, + 'Fix the code (Recommended)', + PIN_PLAN[key].fix + ), + opt( + `skip:${PIN_THREADS[key]}`, + 'Skip this thread', + 'Post nothing and change nothing for this thread.' + ), + ], + ctx({ + 'gate-ctx': 'thread@1', + author: 'renee', + severity, + claim: { summary: PIN_PLAN[key].claim }, + verdict: { call: 'valid', note: PIN_PLAN[key].note }, + reply: { kind: 'direction', text: PIN_PLAN[key].reply }, + }) + ); + +const pinOrigin = { + presentation: 'form' as const, + surface: 'board', + tabId: 'w44:t1', + worktree: `${TREES}/hazel`, + paneId: 'w44:p1', + runId: '20261028-091512-4c7d-11806', +}; + +export const respondPlan: StoryGate = { + mr: PIN_MR, + gate: { + gateId: 'story-respond-plan', + subject: mrSubject(831), + kind: 'respond-plan', + label: 'respond gate !831', + status: 'open', + openedAt: minutesAgo(9), + meta: { label: 'respond gate !831' }, + domain: 'respond', + origin: pinOrigin, + context: ctx({ + 'gate-ctx': 'plan@1', + reviewer: 'renee', + round: 1, + threads: { total: 5, blocking: 1 }, + adjudication: '4 fix, 1 reply', + }), + questions: [ + planThread(1, 't1', 'blocking'), + planThread(2, 't2', 'non-blocking'), + planThread(3, 't3', 'non-blocking'), + planThread(4, 't4', 'non-blocking'), + question( + 'thread-5', + PIN_FILES.t5, + false, + [ + opt( + `reply:${PIN_THREADS.t5}`, + 'Reply only (Recommended)', + 'Post the drafted reply and change no code.' + ), + opt( + `fix:${PIN_THREADS.t5}`, + 'Fix the code', + 'Nothing to change; the thread asks for a re-review ping.' + ), + opt( + `skip:${PIN_THREADS.t5}`, + 'Skip this thread', + 'Post nothing and change nothing for this thread.' + ), + ], + ctx({ + 'gate-ctx': 'thread@1', + author: 'renee', + severity: 'none', + claim: { + summary: + 'Left a few comments, the index key one is the main one. Ping me when it’s ready for another look.', + }, + verdict: { + call: 'valid', + note: 'process only; reply once threads 1-4 are in.', + }, + reply: { kind: 'verbatim', text: 'Ready for another look!' }, + }) + ), + question('code-changes', 'Approve the proposed code changes?', false, [ + opt('approve', 'Approve the changes'), + opt('revise', 'Revise the proposal'), + opt('skip', 'Skip code changes'), + ]), + ], + }, +}; + +const postThread = ( + n: number, + key: keyof typeof PIN_THREADS, + reply: { verb: 'reply' | 'fix'; text: string; sha?: string }, + resolvable = true +) => + question( + `thread-${n}`, + PIN_FILES[key], + true, + [ + opt( + `post:${PIN_THREADS[key]}`, + 'Post this reply (Recommended)', + 'Post this reply to the thread.' + ), + ...(resolvable + ? [ + opt( + `resolve:${PIN_THREADS[key]}`, + 'Resolve the thread (Recommended)', + 'Resolve the thread after its reply.' + ), + ] + : []), + ], + ctx({ + 'gate-ctx': 'reply@1', + thread: PIN_THREADS[key], + file: PIN_FILES[key], + ...reply, + }) + ); + +/** The same MR after the fixes landed: four fix replies with their commits, + and the re-review ping on the Overview thread, which GitLab cannot + resolve. */ +export const respondPost: StoryGate = { + mr: PIN_MR, + gate: { + gateId: 'story-respond-post', + subject: mrSubject(831), + kind: 'respond-post', + label: 'respond gate !831', + status: 'open', + openedAt: minutesAgo(4), + meta: { label: 'respond gate !831' }, + domain: 'respond', + origin: pinOrigin, + context: ctx({ + 'gate-ctx': 'post@1', + reviewer: 'renee', + round: 1, + replies: 5, + fixes: [ + { sha: '3e7a9c1b5d2' }, + { sha: '8f2d4b6a0c9' }, + { sha: 'b5c1e7d3a9f' }, + { sha: '0a6e2c8f4b1' }, + ], + }), + questions: [ + postThread(1, 't1', { + verb: 'fix', + sha: '3e7a9c1b5d2', + text: 'pins are keyed by station id now, so a re-center that reorders them keeps the open popover.', + }), + postThread(2, 't2', { + verb: 'fix', + sha: '8f2d4b6a0c9', + text: 'switched to `moveend`, so nothing runs while the map is idle. the hook also stops listening on unmount now, which the poll never did.', + }), + postThread(3, 't3', { + verb: 'fix', + sha: 'b5c1e7d3a9f', + text: 'added a test that opens a popover, re-centers, and expects it to stay open. it fails on main.', + }), + postThread(4, 't4', { + verb: 'fix', + sha: '0a6e2c8f4b1', + text: 'dropped it.', + }), + postThread( + 5, + 't5', + { verb: 'reply', text: 'Ready for another look!' }, + false + ), + ], + }, +}; From 7de95a9b2fcd6172cf9428af62eec10e7de968aa Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 13:07:39 -0500 Subject: [PATCH 002/149] board: drop the click-to-edit component stories; the gate stories show the field Co-Authored-By: Claude Opus 5.5 --- .../client/board/ClickToEditField.stories.tsx | 323 ------------------ 1 file changed, 323 deletions(-) delete mode 100644 apps/board/src/client/board/ClickToEditField.stories.tsx diff --git a/apps/board/src/client/board/ClickToEditField.stories.tsx b/apps/board/src/client/board/ClickToEditField.stories.tsx deleted file mode 100644 index ace80a57ce..0000000000 --- a/apps/board/src/client/board/ClickToEditField.stories.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useState, type ReactNode } from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; - -import { Chip, Markdown } from '@mattstack/tui-kit'; -import { registerTheme, SoribashiProvider } from '@mattstack/tui-kit/provider'; -import { tuiTheme } from '@mattstack/tui-kit/theme'; - -import '@mattstack/tui-kit/theme.css'; -import '@mattstack/tui-kit/canvas.css'; -import '../../style.css'; - -import { ClickToEditField } from './ClickToEditField.tsx'; -import { AgentGlyph, CodeGlyph, CommentGlyph, FileGlyph } from './icons.tsx'; - -/** - * First build pass of the click-to-edit comment (board.pen B26 and B27): - * every comment reads rendered, a click on its text opens the raw Markdown - * with the caret at the click, and there is no Write/Preview toggle. The - * summary card stacks bare fields so it reads as the one comment the forge - * shows; a comment from a finding edits in the summary or in its own card, - * both on the same text. Invented content only (the repo is public). - */ -function Stage({ - scheme, - children, -}: { - scheme: 'light' | 'dark'; - children: ReactNode; -}) { - return ( -
- -
{children}
-
-
- ); -} - -const meta = { - title: 'Gates/Board/Click to edit', - decorators: [ - (Story, context) => ( - - - - ), - ], - parameters: { layout: 'fullscreen' }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -registerTheme(tuiTheme); - -interface StoryFinding { - id: string; - title: string; - body: string; - comment: string; - file?: { path: string; line: number }; -} - -const FINDINGS: StoryFinding[] = [ - { - id: 'd1', - title: 'retryPolicy retries a job that already failed for good', - body: '`retryPolicy` retries every rejection, including `PermanentJobError`, so a job that can never succeed burns all five attempts.', - comment: - "**issue:** this retries `PermanentJobError` too, so a job that can't succeed eats all five attempts before it fails. could we skip retries for that one?", - file: { path: 'src/jobs/retry-policy.ts', line: 34 }, - }, - { - id: 'd2', - title: 'The delay never backs off', - body: 'The delay is fixed at `200ms`; the spec asks for exponential backoff capped at 5s.', - comment: - '**suggestion:** the spec wants exponential backoff capped at 5s, and this is a flat `200ms`. something like `Math.min(200 * 2 ** attempt, 5000)` would cover it.', - file: { path: 'src/jobs/retry-policy.ts', line: 41 }, - }, - { - id: 'f1', - title: 'No run for the --changed flag', - body: 'The AC for `--changed` on files under `src/jobs/**` has no run in the evidence.', - comment: - '**suggestion:** thanks for the extra evidence, that covers it nicely. the one AC without a run is `--changed` picking up a file under `src/jobs/**`... would you mind grabbing one quick run of that?', - }, - { - id: 'f2', - title: 'Stale comments and a stale stat name', - body: 'Comments in `scan-jobs.ts` still describe the old single folder, and `stats.reachableInJobs` now counts both.', - comment: - "**nitpick:** a few comments in `scan-jobs.ts` still describe the old single folder: `JobRow.path` (:88) says it's relative to `src/jobs/`. `stats.reachableInJobs` (:140) counts both folders now too, so maybe rename it?", - }, - { - id: 'f3', - title: 'README is out of step with the script', - body: 'The README still names the old folder, and nothing mentions the path change.', - comment: - '**suggestion:** the `README.md` still says the scanner reads `src/jobs` (:42). anyone with a local overlay keyed on the old paths will get `merged 0/N` with no hint why. could we add a line about both? see [the overlay docs](https://example.test/docs/overlay).', - }, -]; - -function SummaryAndFindings() { - const [summary, setSummary] = useState( - 'Left a few comments, the `retryPolicy` one is the main one.' - ); - const [texts, setTexts] = useState>( - Object.fromEntries(FINDINGS.map(f => [f.id, f.comment])) - ); - const [ticked, setTicked] = useState(new Set(FINDINGS.map(f => f.id))); - const [editing, setEditing] = useState(null); - const setText = (id: string) => (t: string) => - setTexts(prev => ({ ...prev, [id]: t })); - const track = (key: string) => (on: boolean) => - setEditing(prev => (on ? key : prev === key ? null : prev)); - const folded = FINDINGS.filter(f => !f.file && ticked.has(f.id)); - return ( - <> -
- Summary comment - - - for comment - - posts on !1182 - -
-
-
-
- - {folded.map(f => ( - - ))} -
- {editing?.startsWith('summary') && ( - Markdown supported - )} -
-
- -
- Findings - - {ticked.size} of {FINDINGS.length} selected - -
-
- {FINDINGS.map(f => { - const on = ticked.has(f.id); - return ( -
- - {on && ( - <> - {f.file && ( -
-
- - - The Code - -
-
-
- - - {f.file.path} - - :{f.file.line} - - -
-
-
- )} -
-
- - - Agent Finding - -
-
- - {f.body} - -
-
-
-
- - - Comment - - - {f.file - ? 'drafted by the agent' - : editing === `summary:${f.id}` - ? 'editing in summary' - : 'posts in summary'} - -
- -
- - )} -
- ); - })} -
- - ); -} - -export const SummaryWithFindings: Story = { - name: 'Summary and finding cards', - render: () => , -}; - -function OneField() { - const [text, setText] = useState( - [ - '**question:** does the retry here back off, or hammer the queue?', - '', - '- `retryPolicy.max` is 5', - '- the delay looks fixed at 200ms', - '', - '```ts', - 'await retry(job, { max: 5, delayMs: 200 });', - '```', - '', - 'see [the retry notes](https://example.test/retry) for the old numbers.', - ].join('\n') - ); - return ; -} - -export const SingleField: Story = { - name: 'One boxed field (lists, code, a link)', - render: () => , -}; - -function EmptyField() { - const [text, setText] = useState(''); - return ( - - ); -} - -export const Empty: Story = { - name: 'An empty comment', - render: () => , -}; From ff23668c6a46b568f62596649061d0fb50059a92 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 13:28:24 -0500 Subject: [PATCH 003/149] board: the review sheet's round heading sits above its tabs Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewGateSheet.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index a2fe453c96..c7a62e1323 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -670,6 +670,9 @@ function ReviewGateSheet({ >
+ {review.round !== undefined && ( + + )}
{mainTab === 'findings' && ( <> - {review.round !== undefined && ( - - )} {carryovers.length > 0 && ( <>
From 399fe60755b6e7be13e6ac6d1ac43256dd2db04f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 13:29:06 -0500 Subject: [PATCH 004/149] board: the review round band spans the column, its text in line with the capped content Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 5b808e7d6f..fc503629c2 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -6615,6 +6615,16 @@ a.tui-id-card-ref:focus-visible { max-width: 1120px; margin-inline: auto; } +/* The round band spans the whole column; its padding lines its text up + with the capped content below. */ +@media (min-width: 721px) { + .tui-sheet-main[data-sheet='review'] > .tui-sheet-round { + max-width: none; + margin-inline: -26px -28px; + padding-inline: calc(26px + max(0px, (100% - 1120px) / 2)) + calc(28px + max(0px, (100% - 1120px) / 2)); + } +} /* The summary comment in the dock: two lines at rest, whole when open. */ .tui-review-dock-summary { From 32d4edd948e3a27b851f6347257be3fc0cb798c9 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 13:35:42 -0500 Subject: [PATCH 005/149] board: re-review threads read as a conversation and reply in the click-to-edit field Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ReviewDraftCard.tsx | 4 +- .../src/client/board/ReviewGateSheet.tsx | 7 +- .../src/client/board/ReviewRoundParts.tsx | 130 ++++-------- .../src/client/board/ThreadReplyCard.tsx | 195 ++++++++++++++++++ apps/board/src/style.css | 152 ++++++++++++++ 5 files changed, 395 insertions(+), 93 deletions(-) create mode 100644 apps/board/src/client/board/ThreadReplyCard.tsx diff --git a/apps/board/src/client/board/ReviewDraftCard.tsx b/apps/board/src/client/board/ReviewDraftCard.tsx index 2ba1bb8e3a..59049a62ff 100644 --- a/apps/board/src/client/board/ReviewDraftCard.tsx +++ b/apps/board/src/client/board/ReviewDraftCard.tsx @@ -455,7 +455,7 @@ function CodeSection({ } /** "edited · reset to draft", or who wrote the text. */ -function DraftMeta({ +export function DraftMeta({ edited, label, onReset, @@ -495,7 +495,7 @@ function DraftMeta({ /** A comment that reads rendered and opens raw on a click, with its blank-edit hint, in a part that `DraftMeta`'s reset finds by `data-draft-part`. */ -function CommentEditor({ +export function CommentEditor({ label, draft, value, diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index c7a62e1323..fe6d2f8dde 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -713,7 +713,12 @@ function ReviewGateSheet({
{carryovers.map(c => ( - + ))}
diff --git a/apps/board/src/client/board/ReviewRoundParts.tsx b/apps/board/src/client/board/ReviewRoundParts.tsx index cf5f31d5cd..0d787de9b6 100644 --- a/apps/board/src/client/board/ReviewRoundParts.tsx +++ b/apps/board/src/client/board/ReviewRoundParts.tsx @@ -1,25 +1,22 @@ import { useState } from 'react'; import type { GateSelections } from '@mattstack/gate-kit'; -import { Markdown } from '@mattstack/tui-kit'; -import { CommentComposer } from './CommentComposer.tsx'; import { Disclosure, DisclosureHead } from './Disclosure.tsx'; import type { CarryoverCall, SkippedEntry } from './gate-ctx.ts'; import type { GateFormState } from './GateForm.tsx'; -import { SearchCheckIcon } from './icons.tsx'; import { sendableTexts } from './respond-post.ts'; -import { PostResolveChoice } from './RespondCards.tsx'; import { SEVERITY_LABEL, type CarryoverPick } from './review-gate.ts'; +import { ThreadReplyCard } from './ThreadReplyCard.tsx'; /** Every label names who has to act; none says "open" or "unresolved". */ export const CARRYOVER_CALL: Record< CarryoverCall, { text: string; hue: 'green' | 'amber' | 'accent' } > = { - fixed: { text: 'fixed by author', hue: 'green' }, - 'not-fixed': { text: 'waiting on author', hue: 'amber' }, - 'pushback-accepted': { text: 'author pushed back · accept', hue: 'accent' }, - 'pushback-rejected': { text: 'author pushed back · hold firm', hue: 'amber' }, + fixed: { text: 'Fixed', hue: 'green' }, + 'not-fixed': { text: 'Waiting on author', hue: 'amber' }, + 'pushback-accepted': { text: 'Pushback accepted', hue: 'accent' }, + 'pushback-rejected': { text: 'Holding firm', hue: 'amber' }, }; export function isPosting( @@ -73,100 +70,53 @@ export function carryoverTally(picks: CarryoverPick[]): string { .join(' · '); } -function Quote({ - k, - who, - text, -}: { - k: string; - who: 'you' | 'author'; - text: string; -}) { - return ( -
- {k} -
- - {text} - -
-
- ); -} - /** One of the reviewer's earlier threads: what they wrote, what the author - said back, the agent's check, and the reply this round posts, with the - respond gate's own post/hold and resolve controls. */ + said back, the agent's check, and the reply this round posts. */ export function CarryoverCard({ pick, form, + author, }: { pick: CarryoverPick; form: GateFormState; + /** The MR author's name, who the reviewer's thread is with. */ + author: string; }) { const { carry } = pick; const posting = isPosting(pick, form.selections); - const call = CARRYOVER_CALL[carry.call]; return ( -
-
- {pick.label} - - {call.text} - -
-
- - {carry.authorReply ? ( - - ) : ( -

- The author hasn't replied in this thread. -

- )} - {carry.note && ( -
form.toggleMulti(pick.name, pick.post, on)} + resolve={ + pick.resolve + ? { + on: isResolving(pick, form.selections), + onChange: on => form.toggleMulti(pick.name, pick.resolve!, on), } - > - - - - agent's check - {carry.note} -
- )} - form.setText(pick.name, t)} - onReset={() => form.clearText(pick.name)} - controls={} - /> -
-
+ : undefined + } + onChange={t => form.setText(pick.name, t)} + onReset={() => form.clearText(pick.name)} + /> ); } diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx new file mode 100644 index 0000000000..9cc90d6322 --- /dev/null +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -0,0 +1,195 @@ +import type { ReactNode } from 'react'; +import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'; + +import { Markdown } from '@mattstack/tui-kit'; +import { commentState } from './CommentComposer.tsx'; +import { SearchCheckIcon } from './icons.tsx'; +import { CommentEditor, DraftMeta } from './ReviewDraftCard.tsx'; + +export interface ThreadMessage { + who: string; + meta?: string; + text: string; +} + +export interface ThreadReplyCardProps { + /** The thread's file:line, or "General thread". */ + label: string; + status: { text: string; hue: 'green' | 'amber' | 'accent' | 'muted' }; + /** The fix commit a respond reply points at. */ + sha?: string; + messages: ThreadMessage[]; + /** Said in place of the other side's message when they never replied. */ + silence?: string; + check?: { note: string; settled: boolean }; + draft: string; + value: string | undefined; + posting: boolean; + onPosting: (on: boolean) => void; + /** Absent when the thread cannot be resolved (an Overview comment). */ + resolve?: { on: boolean; onChange: (on: boolean) => void }; + onChange: (text: string) => void; + onReset: () => void; +} + +function ResolveTick({ + label, + resolve, +}: { + label: string; + resolve: NonNullable; +}) { + return ( + + ); +} + +/** A reply on a review thread, shaped like a finding card: the header's + tick posts the reply, the thread reads as who said what, and the reply + is the click-to-edit comment. Unticked, the card folds to its header, + which keeps the resolve tick so a thread can close without a reply. */ +export function ThreadReplyCard(p: ThreadReplyCardProps) { + const { edited } = commentState(p.draft, p.value, true); + const label = `Reply on ${p.label}`; + const reset = (resetText?: string) => ( + + ); + return ( +
+
+ + {!p.posting && p.resolve && ( + + )} +
+ + + {p.posting && ( + + {(p.messages.length > 0 || p.silence || p.check) && ( +
+ {p.messages.map((msg, i) => ( + + ))} + {p.silence && ( +

{p.silence}

+ )} + {p.check && ( +

+ + + + {p.check.note} +

+ )} +
+ )} +
+ + Edited +
+
+ )} +
+
+
+ ); +} + +function Message({ who, meta, text }: ThreadMessage): ReactNode { + return ( +
+
+ + {who.slice(0, 1).toUpperCase()} + + {who} + {meta && {meta}} +
+
+ + {text} + +
+
+ ); +} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index fc503629c2..c43238efba 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4408,6 +4408,158 @@ a.tui-id-card-ref:focus-visible { .tui-review-finding .tui-review-section[data-section='comment']:last-child { border-radius: 0 0 7px 7px; } + +/* A reply on a review thread: the finding card's header, comment area and + corners, with the thread as who said what in between. */ +.tui-reply-card .tui-review-section[data-section='comment'] { + position: relative; + padding: 16px 14px 14px; + background: var(--panel); + border-radius: 0 0 7px 7px; +} +.tui-reply-head { + padding: 0; +} +.tui-reply-head-label { + flex: 1; + min-width: 0; + align-items: center; + margin: 0; + padding: 10px 14px; + border-radius: inherit; +} +.tui-reply-head-label:focus-within { + background: none; + outline: none; +} +.tui-reply-head-label .tui-gate-choice-input { + margin-top: 0; +} +.tui-reply-head .tui-reply-resolve { + padding-right: 14px; +} +.tui-reply-title[data-file] { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 400; +} +.tui-reply-card[data-off] .tui-reply-title { + color: var(--text-3); +} +.tui-reply-status { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + font-size: 12px; + font-weight: 500; + color: var(--text-3); +} +.tui-reply-status::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} +.tui-reply-status[data-hue='green'] { + color: var(--text-ok-small); +} +.tui-reply-status[data-hue='amber'] { + color: var(--text-warn-small); +} +.tui-reply-status[data-hue='accent'] { + color: var(--text-accent-small); +} +.tui-reply-sha { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-accent-small); +} +.tui-reply-thread { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 14px 14px; +} +.tui-reply-msg { + display: flex; + flex-direction: column; + gap: 4px; +} +.tui-reply-who { + display: flex; + align-items: center; + gap: 8px; +} +.tui-reply-avatar { + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid var(--border-soft); + background: var(--panel); + font-size: 10px; + font-weight: 600; + color: var(--text-3); +} +.tui-reply-name { + font-size: 13px; + font-weight: 600; + color: var(--text-1); +} +.tui-reply-meta { + font-size: 12px; + color: var(--text-3); +} +.tui-reply-text, +.tui-reply-silence { + padding-left: 26px; +} +.tui-reply-text [data-part='markdown'] > * { + margin: 0; +} +.tui-reply-text [data-part='markdown'] > * + * { + margin-top: 6px; +} +.tui-reply-silence { + margin: 0; + font-size: 13px; + color: var(--text-3); +} +.tui-reply-check { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + font-size: 13px; + color: var(--text-3); +} +.tui-reply-check-icon { + display: inline-flex; + margin-top: 2px; + color: var(--text-warn); +} +.tui-reply-check[data-settled] .tui-reply-check-icon { + color: var(--text-ok); +} +.tui-reply-resolve { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + font-size: 13px; + color: var(--text-1); + cursor: pointer; +} +.tui-reply-resolve .tui-gate-choice-input[data-checked], +.tui-reply-head-label .tui-gate-choice-input[data-checked] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 11px 11px; +} /* The diff draws in a shadow root; these reach it as inherited custom properties. */ .tui-diff { From 7e118d1f3f8fc85078f2cca3293a9bd0e8bbff10 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 13:36:40 -0500 Subject: [PATCH 006/149] board: respond post replies use the shared thread reply card Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/RespondSheet.tsx | 74 +++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/apps/board/src/client/board/RespondSheet.tsx b/apps/board/src/client/board/RespondSheet.tsx index a76bdceec5..39a76a4fa7 100644 --- a/apps/board/src/client/board/RespondSheet.tsx +++ b/apps/board/src/client/board/RespondSheet.tsx @@ -21,11 +21,17 @@ import type { GateRow } from '../../gates/store.ts'; import { approvalSlots } from '../../view.ts'; import type { BoardMRWithReview } from '../types.ts'; import { CommentComposer } from './CommentComposer.tsx'; -import { parseGateCtx, type PlanCtx, type PostCtx } from './gate-ctx.ts'; +import { + parseGateCtx, + type PlanCtx, + type PostCtx, + type ThreadCtx, +} from './gate-ctx.ts'; import type { GateFormState } from './GateForm.tsx'; import { MrCard } from './MrCard.tsx'; import { forgeNoun, mrRef } from './MrLinks.tsx'; import { usePeople } from './people.ts'; +import { ThreadReplyCard } from './ThreadReplyCard.tsx'; import { PersonLead, PersonTag } from './PersonLead.tsx'; import { joinPlan, @@ -47,7 +53,6 @@ import { ReplyBlock, ReplyChoiceBody, SeverityPill, - StripOutcome, ThreadCard, ThreadOutcome, } from './RespondCards.tsx'; @@ -625,28 +630,43 @@ function RespondSheetBody({ const v = form.selections[p.name]; return Array.isArray(v) && v.includes(p.post); }; - // `framed`: the composer is the thread's whole card, so its strip names - // the thread and its outcome. - const editableReply = (p: PostPick, framed = false) => { + // A reply card: the header's tick posts, the reviewer's comment reads + // above the reply when the plan gate's thread is known. + const replyCard = (p: PostPick, thread?: ThreadCtx) => { const live = postingPick(p); + const reply = p.reply!; return ( - , - heldMeta: 'not posting', - } - : { kicker: 'will post as reply' })} - draft={p.reply!.text} + form.toggleMulti(p.name, p.post, on)} + resolve={ + p.resolve + ? { + on: (() => { + const v = form.selections[p.name]; + return Array.isArray(v) && v.includes(p.resolve!); + })(), + onChange: on => form.toggleMulti(p.name, p.resolve!, on), + } + : undefined + } onChange={text => form.setText(p.name, text)} onReset={() => form.clearText(p.name)} - controls={} /> ); }; @@ -814,11 +834,11 @@ function RespondSheetBody({ {threadJoin ? threadJoin.map(j => { const pick = picks.find(p => p.threadId === j.threadId); + if (pick?.reply) return replyCard(pick, j.thread); return ( {pick && !pick.reply && ( @@ -836,19 +856,7 @@ function RespondSheetBody({ ); const p = item.pick; const display = displayOf(p.name); - if (p.reply) - return ( -
- {editableReply(p, true)} -
- ); + if (p.reply) return replyCard(p); return (
Date: Wed, 28 Oct 2026 13:43:57 -0500 Subject: [PATCH 007/149] board: respond sheet takes the review sheet's frame; the post story shows the reviewer's comments Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/RespondSheet.tsx | 9 ++--- .../src/client/board/ReviewRoundParts.tsx | 4 ++- .../src/client/board/ThreadReplyCard.tsx | 12 +++---- .../src/client/board/gate-stories.fixtures.ts | 33 ++++++++++++++----- apps/board/src/style.css | 9 ++--- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/board/src/client/board/RespondSheet.tsx b/apps/board/src/client/board/RespondSheet.tsx index 39a76a4fa7..388de0fc6a 100644 --- a/apps/board/src/client/board/RespondSheet.tsx +++ b/apps/board/src/client/board/RespondSheet.tsx @@ -31,7 +31,6 @@ import type { GateFormState } from './GateForm.tsx'; import { MrCard } from './MrCard.tsx'; import { forgeNoun, mrRef } from './MrLinks.tsx'; import { usePeople } from './people.ts'; -import { ThreadReplyCard } from './ThreadReplyCard.tsx'; import { PersonLead, PersonTag } from './PersonLead.tsx'; import { joinPlan, @@ -80,6 +79,7 @@ import { type RowChip, type SheetRow, } from './SheetParts.tsx'; +import { ThreadReplyCard } from './ThreadReplyCard.tsx'; /** A post-step thread's decision: post its final reply or hold it. Both write the replies checklist the gate answers with. */ @@ -802,7 +802,7 @@ function RespondSheetBody({ return (
-
+
@@ -836,10 +836,7 @@ function RespondSheetBody({ const pick = picks.find(p => p.threadId === j.threadId); if (pick?.reply) return replyCard(pick, j.thread); return ( - + {pick && !pick.reply && ( )} diff --git a/apps/board/src/client/board/ReviewRoundParts.tsx b/apps/board/src/client/board/ReviewRoundParts.tsx index 0d787de9b6..af59c7d5b1 100644 --- a/apps/board/src/client/board/ReviewRoundParts.tsx +++ b/apps/board/src/client/board/ReviewRoundParts.tsx @@ -90,7 +90,9 @@ export function CarryoverCard({ status={CARRYOVER_CALL[carry.call]} messages={[ { who: 'You', meta: `round ${carry.round}`, text: carry.original }, - ...(carry.authorReply ? [{ who: author, text: carry.authorReply }] : []), + ...(carry.authorReply + ? [{ who: author, text: carry.authorReply }] + : []), ]} silence={carry.authorReply ? undefined : `${author} hasn't replied.`} check={ diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index 9cc90d6322..ccc9d4af93 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -63,12 +63,7 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) { const { edited } = commentState(p.draft, p.value, true); const label = `Reply on ${p.label}`; const reset = (resetText?: string) => ( - + ); return (
Edited -
diff --git a/apps/board/src/style.css b/apps/board/src/style.css index a7ecd67455..5f4555f759 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4518,7 +4518,15 @@ a.tui-id-card-ref:focus-visible { border-top: 1px solid var(--border-soft); font-size: 13px; line-height: 1.45; - color: var(--text-3); + color: var(--text-2); +} +.tui-reply-check-k { + margin-right: 4px; + font-weight: 600; + color: var(--text-warn); +} +.tui-reply-check[data-settled] .tui-reply-check-k { + color: var(--text-ok); } .tui-reply-check-icon { display: inline-flex; From 184e97c47c47a070d34e25ab528e9591e7a6674e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 13:59:14 -0500 Subject: [PATCH 012/149] board: the agent's check reads at full body size and contrast Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 5f4555f759..f786ea1aa6 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4516,9 +4516,9 @@ a.tui-id-card-ref:focus-visible { margin: 0; padding-top: 12px; border-top: 1px solid var(--border-soft); - font-size: 13px; - line-height: 1.45; - color: var(--text-2); + font-size: 14px; + line-height: 1.5; + color: var(--text-1); } .tui-reply-check-k { margin-right: 4px; From f3b29a9891844cc4518e4e3d04d8c3db1a3c9a26 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:01:25 -0500 Subject: [PATCH 013/149] board: the agent's check is a kit button that opens its note Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ThreadReplyCard.tsx | 49 ++++++++++++------- apps/board/src/style.css | 33 +++++-------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index b5ac91e194..69ef1d75be 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -1,8 +1,10 @@ -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'; +import { Button, ICONS } from '@mattstack/tui-kit'; import { commentState } from './CommentComposer.tsx'; import { CommentMarkdown } from './CommentMarkdown.tsx'; +import { Disclosure } from './Disclosure.tsx'; import { SearchCheckIcon } from './icons.tsx'; import { CommentEditor, DraftMeta } from './ReviewDraftCard.tsx'; @@ -127,22 +129,7 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) { {p.silence && (

{p.silence}

)} - {p.check && ( -

- - - - - - Agent's check - {' '} - {p.check.note} - -

- )} + {p.check && }
)}
); } + +/** What the agent found on the thread, behind a badge: verified when the + author's change or pushback holds, else still open. */ +function AgentCheck({ note, settled }: { note: string; settled: boolean }) { + const [open, setOpen] = useState(false); + const label = settled ? 'Agent verified' : 'Agent: still open'; + return ( +
+ + +

{note}

+
+
+ ); +} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index f786ea1aa6..198c40d6b4 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4509,34 +4509,27 @@ a.tui-id-card-ref:focus-visible { font-size: 13px; color: var(--text-3); } +/* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { display: flex; + flex-direction: column; align-items: flex-start; - gap: 8px; +} +.tui-reply-check-chevron { + display: inline-flex; + margin-left: 2px; + transition: transform 150ms; +} +.tui-reply-check-chevron[data-open] { + transform: rotate(90deg); +} +.tui-reply-check-note { margin: 0; - padding-top: 12px; - border-top: 1px solid var(--border-soft); + padding-top: 8px; font-size: 14px; line-height: 1.5; color: var(--text-1); } -.tui-reply-check-k { - margin-right: 4px; - font-weight: 600; - color: var(--text-warn); -} -.tui-reply-check[data-settled] .tui-reply-check-k { - color: var(--text-ok); -} -.tui-reply-check-icon { - display: inline-flex; - flex-shrink: 0; - margin-top: 2px; - color: var(--text-warn); -} -.tui-reply-check[data-settled] .tui-reply-check-icon { - color: var(--text-ok); -} .tui-reply-resolve { display: inline-flex; align-items: center; From 5d5a2746d3bb9ccf186e74f0dc7b817def64d13f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:01:53 -0500 Subject: [PATCH 014/149] board: the agent's check button takes the outline variant so its tone reads Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ThreadReplyCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index 69ef1d75be..0add2e44cd 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -192,7 +192,7 @@ function AgentCheck({ note, settled }: { note: string; settled: boolean }) {
diff --git a/apps/board/src/style.css b/apps/board/src/style.css index d4f5627aad..f1adb49ff3 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4522,9 +4522,10 @@ a.tui-id-card-ref:focus-visible { color: var(--text-1); } .tui-reply-silence { - margin: -8px 0 0; - font-size: 13px; - color: var(--text-3); + margin: 0; + font-size: 14px; + font-weight: 500; + color: var(--text-warn); } /* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { From b2c40f36d555ce25115710de840e223184b2c52f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:08:57 -0500 Subject: [PATCH 022/149] board: the no-reply line reads in body ink, italic, apart from the flag's tone Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index f1adb49ff3..e064f51de9 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4525,7 +4525,8 @@ a.tui-id-card-ref:focus-visible { margin: 0; font-size: 14px; font-weight: 500; - color: var(--text-warn); + color: var(--text-1); + font-style: italic; } /* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { From 4a2a69dc39930656f87b41ab8eeaebc4df92fc43 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:09:11 -0500 Subject: [PATCH 023/149] board: the no-reply line takes the purple tone the board names people in Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index e064f51de9..e6ed21b3d5 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4525,8 +4525,7 @@ a.tui-id-card-ref:focus-visible { margin: 0; font-size: 14px; font-weight: 500; - color: var(--text-1); - font-style: italic; + color: var(--text-purple); } /* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { From 146379d05f862fc6a759c53c656b273fd0933c9a Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:09:55 -0500 Subject: [PATCH 024/149] board: the no-reply line takes the crimson text tone Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index e6ed21b3d5..315dfa8e1e 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4525,7 +4525,7 @@ a.tui-id-card-ref:focus-visible { margin: 0; font-size: 14px; font-weight: 500; - color: var(--text-purple); + color: var(--text-bad); } /* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { From 0e87ac89ae530d45cb7b7fadfc6bda9cd9e5fd0b Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:10:34 -0500 Subject: [PATCH 025/149] board: the no-reply line softens to a muted crimson at regular weight Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 315dfa8e1e..9dbc18a40a 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4524,8 +4524,8 @@ a.tui-id-card-ref:focus-visible { .tui-reply-silence { margin: 0; font-size: 14px; - font-weight: 500; - color: var(--text-bad); + font-weight: 400; + color: color-mix(in srgb, var(--text-bad) 70%, var(--text-3)); } /* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { From e5c35463bd34481887c2dabd2ea84f1de23d70ff Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:12:28 -0500 Subject: [PATCH 026/149] board: no author reply shows as a kit chip with an alert icon in the reply's slot Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewRoundParts.tsx | 2 +- apps/board/src/client/board/ThreadReplyCard.tsx | 12 ++++++++++-- apps/board/src/style.css | 5 +---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/board/src/client/board/ReviewRoundParts.tsx b/apps/board/src/client/board/ReviewRoundParts.tsx index 504faeca0a..89bb5e335c 100644 --- a/apps/board/src/client/board/ReviewRoundParts.tsx +++ b/apps/board/src/client/board/ReviewRoundParts.tsx @@ -95,7 +95,7 @@ export function CarryoverCard({ ? [{ who: author, text: carry.authorReply }] : []), ]} - silence={carry.authorReply ? undefined : `${author} hasn't replied.`} + silence={carry.authorReply ? undefined : 'No reply from author'} check={ carry.note ? { diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index 45b753c15e..d804e51ae9 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from 'react'; import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'; -import { Button, CHECK_ICON, Icon, ICONS } from '@mattstack/tui-kit'; +import { Button, CHECK_ICON, Chip, Icon, ICONS } from '@mattstack/tui-kit'; import { commentState } from './CommentComposer.tsx'; import { CommentMarkdown } from './CommentMarkdown.tsx'; import { Disclosure } from './Disclosure.tsx'; @@ -126,7 +126,15 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) { ))} {p.silence && ( -

{p.silence}

+
+ + {p.silence} + +
)} {p.check && }
diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 9dbc18a40a..c4e7e62a43 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4522,10 +4522,7 @@ a.tui-id-card-ref:focus-visible { color: var(--text-1); } .tui-reply-silence { - margin: 0; - font-size: 14px; - font-weight: 400; - color: color-mix(in srgb, var(--text-bad) 70%, var(--text-3)); + align-items: flex-start; } /* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { From aa2d1ebb84f30cb45d55b04f6b1b4823a3f5d626 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:13:58 -0500 Subject: [PATCH 027/149] board: the agent's verdict gets its own band between the thread and the reply Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ThreadReplyCard.tsx | 8 ++++++-- apps/board/src/style.css | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index d804e51ae9..a14eb4570f 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -120,7 +120,7 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) { exit={{ height: 0, opacity: 0, overflow: 'hidden' }} transition={{ duration: 0.15, ease: [0.4, 0, 0.2, 1] }} > - {(p.messages.length > 0 || p.silence || p.check) && ( + {(p.messages.length > 0 || p.silence) && (
{p.messages.map((msg, i) => ( @@ -136,7 +136,11 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) {
)} - {p.check && } +
+ )} + {p.check && ( +
+
)}
Date: Wed, 28 Oct 2026 14:14:56 -0500 Subject: [PATCH 028/149] board: the agent's verdict leads the comment area instead of its own band Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ThreadReplyCard.tsx | 6 +----- apps/board/src/style.css | 9 ++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index a14eb4570f..04dc5c8170 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -138,11 +138,6 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) { )}
)} - {p.check && ( -
- -
- )}
+ {p.check && } .tui-reply-check { + margin-bottom: 6px; } /* The agent's check is a kit button; a click opens what it found. */ .tui-reply-check { From 3fd7cce12b21c8ffac72457575b8d702c18bc394 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:16:09 -0500 Subject: [PATCH 029/149] board: the agent's verdict button is filled in its tone Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ThreadReplyCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index 04dc5c8170..380e6d3097 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -203,7 +203,7 @@ function AgentCheck({ note, settled }: { note: string; settled: boolean }) { >
+ {footer &&
{footer}
} {hint && (editing || variant === 'plain') && ( // At rest, plain's hint is the hover "Click to edit" pill; the // textbox already says it edits, so the pill stays out of the diff --git a/apps/board/src/client/board/ReviewDraftCard.tsx b/apps/board/src/client/board/ReviewDraftCard.tsx index 9eedd20848..42fc0a932a 100644 --- a/apps/board/src/client/board/ReviewDraftCard.tsx +++ b/apps/board/src/client/board/ReviewDraftCard.tsx @@ -509,6 +509,7 @@ export function CommentEditor({ heading, actions, badge, + footer, }: { label: string; draft: string; @@ -520,6 +521,7 @@ export function CommentEditor({ heading?: string; actions?: ReactNode; badge?: ReactNode; + footer?: ReactNode; }) { const { text, empty } = commentState(draft, value, true); const hintId = useId(); @@ -538,6 +540,7 @@ export function CommentEditor({ heading={heading} actions={actions} badge={badge} + footer={footer} /> {empty && ( diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index 1b222bb035..474d9e9553 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -166,11 +166,13 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) { ) } + footer={ + p.resolve && ( + + ) + } onChange={p.onChange} /> - {p.resolve && ( - - )} )} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 6940734bee..15502c1cb5 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4524,6 +4524,60 @@ a.tui-id-card-ref:focus-visible { .tui-reply-silence { align-items: flex-start; } +/* A field with a footer is one box: the text, a hairline, then the + footer's controls, inside the same border and corners. */ +.tui-cte-wrap:has(> .tui-cte-footer) > .tui-cte[data-variant='plain'] { + border-bottom-color: var(--border-soft); + border-radius: 6px 6px 0 0; +} +.tui-cte-wrap:has(> .tui-cte-footer):has( + > .tui-cte[data-variant='plain']:is( + [data-editing], + :has(.tui-cte-view:focus-visible) + ) + ) { + border-radius: 6px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--fill-accent) 15%, transparent); +} +.tui-cte-wrap:has(> .tui-cte-footer) + > .tui-cte[data-variant='plain']:is( + [data-editing], + :has(.tui-cte-view:focus-visible) + ) { + box-shadow: none; + border-bottom-color: var(--border-soft); +} +.tui-cte-wrap:has( + > .tui-cte[data-variant='plain']:is( + [data-editing], + :has(.tui-cte-view:focus-visible) + ) + ) + > .tui-cte-footer { + border-color: color-mix(in srgb, var(--fill-accent) 70%, var(--border)); +} +/* While writing, the hint line hangs under the box rather than inside the + ringed wrap, and the wrap keeps room for it. */ +.tui-cte-wrap:has(> .tui-cte-footer):has(> .tui-cte[data-editing]) { + position: relative; + margin-bottom: 22px; +} +.tui-cte-wrap:has(> .tui-cte-footer) > .tui-cte[data-editing] ~ .tui-cte-hint { + position: absolute; + top: 100%; + left: 0; + right: 0; +} +.tui-cte-footer { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 12px; + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 6px 6px; + background: var(--card); +} /* The findings close on a quiet line with room under it, so the last card never sits against the end of the scroll. */ .tui-sheet-end { @@ -5209,7 +5263,7 @@ a.tui-id-card-ref:focus-visible { /* At rest the hint is a pill riding the field's top border, in the field's top padding, so it never covers the text; it eases in with the pointer. The section it sits in is its positioning box. */ -.tui-cte[data-variant='plain']:not([data-editing]) + .tui-cte-hint { +.tui-cte[data-variant='plain']:not([data-editing]) ~ .tui-cte-hint { position: absolute; top: -10px; right: 12px; @@ -5233,7 +5287,7 @@ a.tui-id-card-ref:focus-visible { transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); } /* Write's hint tucks just under the field. */ -.tui-cte[data-variant='plain'][data-editing] + .tui-cte-hint { +.tui-cte[data-variant='plain'][data-editing] ~ .tui-cte-hint { margin-top: 5px; } .tui-cte-hint svg { @@ -5242,14 +5296,14 @@ a.tui-id-card-ref:focus-visible { flex-shrink: 0; } @media (hover: hover) { - .tui-cte[data-variant='plain']:not([data-editing]):hover + .tui-cte-hint { + .tui-cte[data-variant='plain']:not([data-editing]):hover ~ .tui-cte-hint { opacity: 1; transform: none; } } /* The flash mounts already shown, so it eases in from the hidden pose. */ .tui-cte[data-variant='plain']:not([data-editing]) - + .tui-cte-hint[data-flash='shown'] { + ~ .tui-cte-hint[data-flash='shown'] { opacity: 1; transform: none; @starting-style { @@ -5259,7 +5313,7 @@ a.tui-id-card-ref:focus-visible { } /* "Comment updated" is a done state, so it takes the ok wash, not the accent the edit cue wears; it keeps it through the fade. */ -.tui-cte[data-variant='plain']:not([data-editing]) + .tui-cte-hint[data-flash] { +.tui-cte[data-variant='plain']:not([data-editing]) ~ .tui-cte-hint[data-flash] { color: var(--text-ok); background: color-mix(in srgb, var(--fill-ok) 12%, var(--card)); box-shadow: From 7e61fc8b9e26cd6d02c8047d6b90df385c6bef59 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:33:03 -0500 Subject: [PATCH 038/149] board: finding heads drop the in-summary chip; the kind pill sits beside the title Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewGateSheet.tsx | 15 --------------- apps/board/src/style.css | 8 +++----- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index 113a544ccb..deb8ace752 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -520,21 +520,6 @@ function ReviewGateSheet({ {tick} {title} {pills} - {!hasDiffLine(f) && ( - - in summary - - )} {SEVERITY_LABEL[f.severity]} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 15502c1cb5..a30db15deb 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4135,6 +4135,7 @@ a.tui-id-card-ref:focus-visible { margin-top: 0; } .tui-review-card-head .tui-review-finding-title { + flex: 0 1 auto; font-size: 14px; font-weight: 600; } @@ -6951,11 +6952,8 @@ a.tui-id-card-ref:focus-visible { outline-offset: 2px; } -/* A finding with no diff line says where its comment goes in the head; - its comment then follows the head straight on, under the head's line. */ -.tui-review-in-summary { - flex-shrink: 0; -} +/* A finding with no diff line: its comment follows the head straight on, + under the head's line. */ .tui-review-card-head + .tui-review-card-body > .tui-review-section:first-child { From d648de89cd9aeaa0e23426809ea7ca16a751f85c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:35:48 -0500 Subject: [PATCH 039/149] board: a reply card's status holds the head's right edge again Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index a30db15deb..d63b157db1 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4449,6 +4449,7 @@ a.tui-id-card-ref:focus-visible { } .tui-reply-status { display: inline-flex; + margin-left: auto; align-items: center; gap: 6px; flex-shrink: 0; From 91a6988363cc9dc792b388822a7712bcaa3eedfc Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:36:50 -0500 Subject: [PATCH 040/149] board: accepted pushback gets its own verdict, Agent accepted, in accent blue Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ReviewRoundParts.tsx | 8 ++- .../src/client/board/ThreadReplyCard.tsx | 50 +++++++++++++++---- apps/board/src/style.css | 5 +- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/board/src/client/board/ReviewRoundParts.tsx b/apps/board/src/client/board/ReviewRoundParts.tsx index 89bb5e335c..842a848f40 100644 --- a/apps/board/src/client/board/ReviewRoundParts.tsx +++ b/apps/board/src/client/board/ReviewRoundParts.tsx @@ -100,8 +100,12 @@ export function CarryoverCard({ carry.note ? { note: carry.note, - settled: - carry.call === 'fixed' || carry.call === 'pushback-accepted', + verdict: + carry.call === 'fixed' + ? 'verified' + : carry.call === 'pushback-accepted' + ? 'accepted' + : 'flagged', } : undefined } diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index 474d9e9553..ede4db9d17 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from 'react'; -import { Flag } from 'lucide-react'; +import { Flag, ThumbsUp } from 'lucide-react'; import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'; import { Button, CHECK_ICON, Chip, Icon, ICONS } from '@mattstack/tui-kit'; @@ -25,7 +25,7 @@ export interface ThreadReplyCardProps { messages: ThreadMessage[]; /** Said in place of the other side's message when they never replied. */ silence?: string; - check?: { note: string; settled: boolean }; + check?: { note: string; verdict: AgentVerdict }; draft: string; value: string | undefined; posting: boolean; @@ -193,27 +193,55 @@ function Message({ who, text }: ThreadMessage): ReactNode { ); } -/** What the agent found on the thread, behind a badge: verified when the - author's change or pushback holds, else still open. */ -function AgentCheck({ note, settled }: { note: string; settled: boolean }) { - const [open, setOpen] = useState(!settled); - const label = settled ? 'Agent verified' : 'Agent flagged'; +/** How the agent judged the author's answer: the change holds, the + pushback holds, or the thread is still open. */ +export type AgentVerdict = 'verified' | 'accepted' | 'flagged'; + +const VERDICT = { + verified: { + label: 'Agent verified', + intent: 'ok', + icon: , + }, + accepted: { + label: 'Agent accepted', + intent: 'accent', + icon: , + }, + flagged: { + label: 'Agent flagged', + intent: 'warn', + icon: , + }, +} as const; + +/** What the agent found on the thread, behind a badge; a flagged thread + opens with its note showing. */ +function AgentCheck({ + note, + verdict, +}: { + note: string; + verdict: AgentVerdict; +}) { + const [open, setOpen] = useState(verdict === 'flagged'); + const v = VERDICT[verdict]; return (
+ + Agent finding + } >
diff --git a/apps/board/src/style.css b/apps/board/src/style.css index e251cdd29e..9a0b851f10 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7101,26 +7101,6 @@ a.tui-id-card-ref:focus-visible { /* The info mark beside a code file's path, and the agent's finding it opens on a click. */ -.tui-review-info { - display: inline-flex; - align-items: center; - padding: 2px; - border: none; - border-radius: 4px; - background: none; - color: var(--text-3); - cursor: pointer; - transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); -} -.tui-review-info:hover, -.tui-review-info[data-popup-open] { - color: var(--text-accent); - background: color-mix(in srgb, var(--fill-accent) 10%, transparent); -} -.tui-review-info:focus-visible { - outline: 2px solid var(--fill-accent); - outline-offset: 1px; -} .tui-review-info-card { display: flex; flex-direction: column; From 69234dea893a49bf8692360fb87cafe61dad4552 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:39:20 -0500 Subject: [PATCH 042/149] tui-kit: subtle hover washes the tone over transparent too Co-Authored-By: Claude Opus 5.5 --- packages/tui-kit/src/intent-resolver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui-kit/src/intent-resolver.ts b/packages/tui-kit/src/intent-resolver.ts index e99105eb45..84ea5fc2de 100644 --- a/packages/tui-kit/src/intent-resolver.ts +++ b/packages/tui-kit/src/intent-resolver.ts @@ -78,9 +78,9 @@ export const tuiIntentResolver: IntentResolver = ({ intent, variant }) => { }; } - // The helper mixes an outline's hover over the page surface, which reads + // The helper mixes outline and subtle hovers over the page surface, which reads // as a grey patch on a card; a wash of the tone alone sits on any ground. - if (variant === "outline") { + if (variant === "outline" || variant === "subtle") { result = { ...result, hover: `color-mix(in srgb, ${tone} 8%, transparent)` }; } From 8a22de9d781a18d6d267154c5710e18a2e9a2611 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:40:17 -0500 Subject: [PATCH 043/149] board: the Agent finding button carries a chevron that turns when it opens Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewDraftCard.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/board/src/client/board/ReviewDraftCard.tsx b/apps/board/src/client/board/ReviewDraftCard.tsx index 8ec03673fc..0ab546d18d 100644 --- a/apps/board/src/client/board/ReviewDraftCard.tsx +++ b/apps/board/src/client/board/ReviewDraftCard.tsx @@ -13,7 +13,7 @@ import { import { Info } from 'lucide-react'; import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'; -import { Button, Modal, Popover } from '@mattstack/tui-kit'; +import { Button, ICONS, Modal, Popover } from '@mattstack/tui-kit'; import { ClickToEditField } from './ClickToEditField.tsx'; import { commentState, resetToWrite } from './CommentComposer.tsx'; import { CommentMarkdown } from './CommentMarkdown.tsx'; @@ -586,6 +586,13 @@ function FindingInfo({ body }: { body: string }) { > Agent finding + + {ICONS['chevron-right']} + } > From 2a1970dd3fd37aa445341ef46be68ff304994431 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:41:11 -0500 Subject: [PATCH 044/149] tui-kit: popovers fade and scale in from the trigger's side, and back out Co-Authored-By: Claude Opus 5.5 --- .../src/recipes/Popover/Popover.module.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/tui-kit/src/recipes/Popover/Popover.module.css b/packages/tui-kit/src/recipes/Popover/Popover.module.css index 4a46e07015..02b5c41b1a 100644 --- a/packages/tui-kit/src/recipes/Popover/Popover.module.css +++ b/packages/tui-kit/src/recipes/Popover/Popover.module.css @@ -22,5 +22,23 @@ box-shadow: var(--shadow-menu); font-size: var(--font-size-px13); outline: none; + transform-origin: var(--transform-origin); + transition: + opacity 150ms ease, + transform 150ms ease; + } + + /* Base UI marks the popup's first and last frames: it grows in from the + trigger's side and shrinks back as it leaves. */ + .popup[data-starting-style], + .popup[data-ending-style] { + opacity: 0; + transform: scale(0.96); + } + + @media (prefers-reduced-motion: reduce) { + .popup { + transition: none; + } } } From acee39d2507c21143cc6fb5dea5281b050a9cd0e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:43:29 -0500 Subject: [PATCH 045/149] board: long code paths shorten from the folders' start; Agent finding drops its info icon Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ReviewDraftCard.tsx | 36 ++++++++++++++----- apps/board/src/style.css | 18 +++++++++- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/board/src/client/board/ReviewDraftCard.tsx b/apps/board/src/client/board/ReviewDraftCard.tsx index 0ab546d18d..0a723cad74 100644 --- a/apps/board/src/client/board/ReviewDraftCard.tsx +++ b/apps/board/src/client/board/ReviewDraftCard.tsx @@ -10,7 +10,6 @@ import { type ReactNode, type RefObject, } from 'react'; -import { Info } from 'lucide-react'; import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'; import { Button, ICONS, Modal, Popover } from '@mattstack/tui-kit'; @@ -343,12 +342,7 @@ function CodeSection({ - - {path} - {line !== undefined && ( - :{line} - )} - + {finding.body && } {forgeLink && ( + {cut > 0 && ( + + {path.slice(0, cut)} + + )} + + {path.slice(cut)} + {line !== undefined && ( + :{line} + )} + + + ); +} + /** The agent's finding behind an info mark in the code's file bar: the comment already says it to the author, so it stays a click away. */ function FindingInfo({ body }: { body: string }) { @@ -584,7 +603,6 @@ function FindingInfo({ body }: { body: string }) { size="sm" className="tui-review-info" > - Agent finding - {f.file} +
)}
diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 9a0b851f10..352b6acd31 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4309,11 +4309,27 @@ a.tui-id-card-ref:focus-visible { border-bottom: 1px solid var(--border-soft); } .tui-review-code-path { + display: flex; min-width: 0; font-family: var(--font-mono); font-size: 12px; + white-space: nowrap; color: var(--text-1); - overflow-wrap: anywhere; +} +/* The folders shrink first and lose their start: right-to-left puts the + ellipsis on the left, and the inner run keeps the path in order. */ +.tui-review-code-dir { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; + text-align: left; +} +.tui-review-code-name { + flex: none; +} +.tui-review-code-file > :not(.tui-review-code-path) { + flex-shrink: 0; } /* The anchor's line reads like the file name in a finding's anchor. */ .tui-review-code-line { From 7b4cdb8ff1a243728d92e580e3b15b7d6fde90ae Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:44:13 -0500 Subject: [PATCH 046/149] board: the Agent finding popover shows the suggested fix under the finding Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewDraftCard.tsx | 12 ++++++++++-- apps/board/src/style.css | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/board/src/client/board/ReviewDraftCard.tsx b/apps/board/src/client/board/ReviewDraftCard.tsx index 0a723cad74..c55bb43dd0 100644 --- a/apps/board/src/client/board/ReviewDraftCard.tsx +++ b/apps/board/src/client/board/ReviewDraftCard.tsx @@ -343,7 +343,9 @@ function CodeSection({ - {finding.body && } + {finding.body && ( + + )} {forgeLink && ( Agent finding {body} + {fix && ( +
+ Suggested fix + {fix} +
+ )}
); diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 352b6acd31..6c9f7fbbe0 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7132,6 +7132,15 @@ a.tui-id-card-ref:focus-visible { font-weight: 500; color: var(--text-3); } +/* The suggested fix follows the finding under a hairline, same label. */ +.tui-review-info-fix { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--border-soft); +} /* The code under its file bar; motion eases its height on fold. */ .tui-review-code-body { From ea39f8a7e72a884b7df01b6c68cca2d80ad6b3c9 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:45:46 -0500 Subject: [PATCH 047/149] board: within each severity, findings on the code come before the ones with no diff line Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewGateSheet.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index deb8ace752..7052e906cf 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -208,13 +208,15 @@ const DISPOSITION: Record< function severityGroups( findings: FindingEntry[] ): Array<[FindingSeverity, FindingEntry[]]> { - return SEVERITY_ORDER.map( - s => - [s, findings.filter(f => f.severity === s)] as [ - FindingSeverity, - FindingEntry[], - ] - ).filter(([, items]) => items.length > 0); + // Inside a severity, the findings on the code come first and the ones + // with no diff line after, each keeping the agent's order. + return SEVERITY_ORDER.map(s => { + const items = findings.filter(f => f.severity === s); + return [ + s, + [...items.filter(hasDiffLine), ...items.filter(f => !hasDiffLine(f))], + ] as [FindingSeverity, FindingEntry[]]; + }).filter(([, items]) => items.length > 0); } /** The drafts the poster falls back to, or null when the gate does not From e3c8eccbb93febe30c810d8460befb1a3de7a675 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:46:22 -0500 Subject: [PATCH 048/149] board: finding severity reads a step stronger, 13px semibold with larger dots Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 6c9f7fbbe0..14a5a72cbe 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4144,16 +4144,16 @@ a.tui-id-card-ref:focus-visible { align-items: center; gap: 6px; flex-shrink: 0; - font-size: 12px; - font-weight: 500; - color: var(--text-3); + font-size: 13px; + font-weight: 600; + color: var(--text-2); } .tui-review-sev::before { content: ''; - width: 8px; - height: 8px; + width: 9px; + height: 9px; border-radius: 50%; - background: var(--text-4); + background: var(--text-3); } .tui-review-sev[data-sev='critical'] { color: var(--text-bad-small); From fb4f26aee93ba552416cf386c843a955b78ed7ed Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:47:14 -0500 Subject: [PATCH 049/149] board: the Report tab opens on the decision context card, which stays in the rail too Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ReviewGateSheet.tsx | 67 ++++++++++--------- apps/board/src/style.css | 5 ++ 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index 7052e906cf..81cd51e516 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -628,6 +628,36 @@ function ReviewGateSheet({ .filter(Boolean) .join(' · '); + const contextCard = ( +
+ decision context +

+ {readinessProse(review.readiness)} +

+
+ + {review.summary} + +
+ {meta &&

{meta}

} + {SEVERITY_ORDER.some(s => review.findings[s] > 0) && ( +
+ {SEVERITY_ORDER.filter(s => review.findings[s] > 0).map(s => ( + + {SEVERITY_LABEL[s]} + {review.findings[s]} + + ))} +
+ )} +
+ ); + return ( )} + {mainTab === 'report' && ( +
{contextCard}
+ )} {mainTab === 'report' && (record || checks.length > 0) && (
{strengths.length > 0 && ( @@ -897,39 +930,7 @@ function ReviewGateSheet({ <>
{mr && } -
- - decision context - -

- {readinessProse(review.readiness)} -

-
- - {review.summary} - -
- {meta &&

{meta}

} - {SEVERITY_ORDER.some(s => review.findings[s] > 0) && ( -
- {SEVERITY_ORDER.filter(s => review.findings[s] > 0).map( - s => ( - - {SEVERITY_LABEL[s]} - - {review.findings[s]} - - - ) - )} -
- )} -
+ {contextCard} {checks.length > 0 && }
diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 14a5a72cbe..a31daaa3e4 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7194,3 +7194,8 @@ a.tui-id-card-ref:focus-visible { animation: none; } } + +/* The Report tab opens on the same decision context card as the rail. */ +.tui-review-report-context { + margin-bottom: 16px; +} From c53a261a883274d1f139a875cf72b8bc02f1228d Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:47:46 -0500 Subject: [PATCH 050/149] board: the Report tab opens on the MR card above the decision context Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewGateSheet.tsx | 5 ++++- apps/board/src/style.css | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index 81cd51e516..5ab78a3e46 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -835,7 +835,10 @@ function ReviewGateSheet({ )} {mainTab === 'report' && ( -
{contextCard}
+
+ {mr && } + {contextCard} +
)} {mainTab === 'report' && (record || checks.length > 0) && (
diff --git a/apps/board/src/style.css b/apps/board/src/style.css index a31daaa3e4..45a425a4f8 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7195,7 +7195,11 @@ a.tui-id-card-ref:focus-visible { } } -/* The Report tab opens on the same decision context card as the rail. */ +/* The Report tab opens on the same MR and decision context cards as the + rail. */ .tui-review-report-context { + display: flex; + flex-direction: column; + gap: 12px; margin-bottom: 16px; } From 54f1a31d6623dd929b1de36cac4f98ba3c1bb5b5 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 14:48:18 -0500 Subject: [PATCH 051/149] board: review tabs carry icons; the Report tab's MR card takes the card ground Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewGateSheet.tsx | 9 +++++---- apps/board/src/style.css | 9 +++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index 5ab78a3e46..82613db96c 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { CheckCheck } from 'lucide-react'; +import { CheckCheck, FileText, ListChecks } from 'lucide-react'; import { collapseChunks, @@ -698,10 +698,10 @@ function ReviewGateSheet({ > {( [ - ['findings', 'Findings', findings.length], - ['report', 'Report', undefined], + ['findings', 'Findings', ListChecks, findings.length], + ['report', 'Report', FileText, undefined], ] as const - ).map(([id, label, count]) => ( + ).map(([id, label, TabIcon, count]) => (
); } From 15322b3965345e8809efe62ada9bb7df8dda3117 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 15:01:16 -0500 Subject: [PATCH 072/149] board: the earlier threads' tally lists each status as its card shows it, with a count Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ReviewGateSheet.tsx | 6 ++-- .../src/client/board/ReviewRoundParts.tsx | 29 +++++++++++++++++++ apps/board/src/style.css | 8 +++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/board/src/client/board/ReviewGateSheet.tsx b/apps/board/src/client/board/ReviewGateSheet.tsx index 0326a60363..4a27055605 100644 --- a/apps/board/src/client/board/ReviewGateSheet.tsx +++ b/apps/board/src/client/board/ReviewGateSheet.tsx @@ -46,7 +46,7 @@ import { import { FindingCard, SummaryCard, type FilePin } from './ReviewDraftCard.tsx'; import { CarryoverCard, - carryoverTally, + CarryoverTally, carryoverTexts, FindingAnchor, SkippedEarlier, @@ -816,9 +816,7 @@ function ReviewGateSheet({ Earlier threads ({carryovers.length}) - - {carryoverTally(carryovers)} - +
{carryovers.map(c => ( diff --git a/apps/board/src/client/board/ReviewRoundParts.tsx b/apps/board/src/client/board/ReviewRoundParts.tsx index 842a848f40..f15a7ca841 100644 --- a/apps/board/src/client/board/ReviewRoundParts.tsx +++ b/apps/board/src/client/board/ReviewRoundParts.tsx @@ -70,6 +70,35 @@ export function carryoverTally(picks: CarryoverPick[]): string { .join(' · '); } +const TALLY_ORDER: CarryoverCall[] = [ + 'fixed', + 'pushback-accepted', + 'not-fixed', + 'pushback-rejected', +]; + +/** The earlier threads counted by status, each status as its card shows it: + the same dot, word and colour, then its count. */ +export function CarryoverTally({ picks }: { picks: CarryoverPick[] }) { + const counts = new Map(); + for (const p of picks) + counts.set(p.carry.call, (counts.get(p.carry.call) ?? 0) + 1); + return ( + + {TALLY_ORDER.filter(c => counts.has(c)).map(c => ( + + {CARRYOVER_CALL[c].text} + {counts.get(c)} + + ))} + + ); +} + /** One of the reviewer's earlier threads: what they wrote, what the author said back, the agent's check, and the reply this round posts. */ export function CarryoverCard({ diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 9551a3c206..6172173638 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -6974,6 +6974,14 @@ a.tui-id-card-ref:focus-visible { .tui-review-tier-pills:has(.tui-review-sev) { gap: 14px; } +/* The earlier threads' tally: the cards' own status marks, in a row. */ +.tui-carry-tally { + gap: 14px; + margin-top: 0; +} +.tui-carry-tally > .tui-reply-status { + margin-left: 0; +} .tui-review-sev-count { margin-left: 2px; font-weight: 400; From 45ddaf7fbd042d601a0d4b0a1ab65e6582456609 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 15:01:26 -0500 Subject: [PATCH 073/149] board: drop the unused prose tally Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ReviewRoundParts.tsx | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/apps/board/src/client/board/ReviewRoundParts.tsx b/apps/board/src/client/board/ReviewRoundParts.tsx index f15a7ca841..1ac29f4b48 100644 --- a/apps/board/src/client/board/ReviewRoundParts.tsx +++ b/apps/board/src/client/board/ReviewRoundParts.tsx @@ -51,25 +51,6 @@ export function carryoverTexts( ); } -/** "2 fixed · 1 pushback accepted · 1 waiting on author" */ -export function carryoverTally(picks: CarryoverPick[]): string { - let fixed = 0; - let accepted = 0; - let waiting = 0; - for (const p of picks) { - if (p.carry.call === 'fixed') fixed++; - else if (p.carry.call === 'pushback-accepted') accepted++; - else waiting++; - } - return [ - fixed > 0 ? `${fixed} fixed` : null, - accepted > 0 ? `${accepted} pushback accepted` : null, - waiting > 0 ? `${waiting} waiting on author` : null, - ] - .filter(Boolean) - .join(' · '); -} - const TALLY_ORDER: CarryoverCall[] = [ 'fixed', 'pushback-accepted', From a3d33a67c492700c851d94ce5246a806c8c52124 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 15:02:50 -0500 Subject: [PATCH 074/149] board: the review comment in the dock is a plain field, white with the blue ring while writing Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/ReviewDraftCard.tsx | 2 +- apps/board/src/style.css | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/board/src/client/board/ReviewDraftCard.tsx b/apps/board/src/client/board/ReviewDraftCard.tsx index c55bb43dd0..39b5bb72b9 100644 --- a/apps/board/src/client/board/ReviewDraftCard.tsx +++ b/apps/board/src/client/board/ReviewDraftCard.tsx @@ -814,7 +814,7 @@ export function SummaryCard({ label="Summary line" draft={draft} value={form.texts[key]} - variant="filled" + variant="plain" onChange={t => form.setText(key, t)} /> diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 6172173638..d21f656a8b 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -6939,10 +6939,6 @@ a.tui-id-card-ref:focus-visible { -webkit-box-orient: vertical; overflow: hidden; } -.tui-review-dock-summary .tui-cte[data-variant='filled']:not([data-editing]) { - border-color: var(--border-soft); - background: var(--card); -} /* The rail's checks: a tally beside the title, then every check. */ .tui-review-checks { From ee2ae88b68fa97690dfe44a761cf2cde376990fb Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 15:12:12 -0500 Subject: [PATCH 075/149] board: a plan thread's drafted reply is a click-to-edit field; opening it picks Reply only Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/RespondSheet.tsx | 63 ++++++++++++++++++-- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/board/src/client/board/RespondSheet.tsx b/apps/board/src/client/board/RespondSheet.tsx index 388de0fc6a..0a4863c2d3 100644 --- a/apps/board/src/client/board/RespondSheet.tsx +++ b/apps/board/src/client/board/RespondSheet.tsx @@ -20,7 +20,7 @@ import { Button, Chip } from '@mattstack/tui-kit'; import type { GateRow } from '../../gates/store.ts'; import { approvalSlots } from '../../view.ts'; import type { BoardMRWithReview } from '../types.ts'; -import { CommentComposer } from './CommentComposer.tsx'; +import { commentState } from './CommentComposer.tsx'; import { parseGateCtx, type PlanCtx, @@ -64,6 +64,7 @@ import { respondPerson, subjectRef, } from './RespondGateHeader.tsx'; +import { CommentEditor, DraftMeta } from './ReviewDraftCard.tsx'; import { useSheetKeys } from './sheet-keys.ts'; import { sheetAnswers } from './sheet-payload.ts'; import { @@ -192,6 +193,57 @@ function PostStepCard({ ); } +/** A plan thread's drafted reply as a click-to-edit field. The draft is the + reply-only option's text, so opening it to write picks that option. */ +function PlanReplyField({ + label, + draft, + value, + onEdit, + onChange, + onReset, +}: { + label: string; + draft: string; + value: string | undefined; + onEdit: () => void; + onChange: (text: string) => void; + onReset: () => void; +}) { + const { edited } = commentState(draft, value, true); + const reset = (resetText?: string) => ( + + ); + return ( +
+ + Edited +
+ ); +} + /** A Gate 1 reply-only thread on a post sheet that could not be joined to its plan card by card: the joined card when the plan card still parses and the text is known, else a plain card with the text or a line saying @@ -903,14 +955,13 @@ function RespondSheetBody({
{qctx?.shape === 'thread@1' && pr ? ( - { + if (!replying) form.setSingle(q.name, pr.value); + }} onChange={t => form.setText(q.name, t)} onReset={() => form.clearText(q.name)} /> From 016483bd713d3f0500e000db08a487a1c13f6bed Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 15:16:11 -0500 Subject: [PATCH 076/149] board: a plan's reply direction reads as a quoted line; picking Reply only on a fix says the reply comes at posting Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/RespondCards.tsx | 14 ++++++++------ apps/board/src/client/board/RespondSheet.tsx | 12 +++++++++++- apps/board/src/style.css | 20 ++++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/board/src/client/board/RespondCards.tsx b/apps/board/src/client/board/RespondCards.tsx index a12f091565..570b487358 100644 --- a/apps/board/src/client/board/RespondCards.tsx +++ b/apps/board/src/client/board/RespondCards.tsx @@ -103,12 +103,14 @@ function ThreadCard({ )} {reply.kind === 'direction' && ( - +
+ Reply after the fix +
+ + {reply.text} + +
+
)} {children} diff --git a/apps/board/src/client/board/RespondSheet.tsx b/apps/board/src/client/board/RespondSheet.tsx index 0a4863c2d3..6f47572851 100644 --- a/apps/board/src/client/board/RespondSheet.tsx +++ b/apps/board/src/client/board/RespondSheet.tsx @@ -967,7 +967,17 @@ function RespondSheetBody({ />
) : qctx?.shape === 'thread@1' ? ( - + + {qctx.reply.kind === 'direction' && + String(form.selections[q.name] ?? '').startsWith( + 'reply:' + ) && ( +

+ The agent drafts this reply next. You edit it when + posting. +

+ )} +
) : ( )} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index d21f656a8b..c0aa5a923a 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7232,3 +7232,23 @@ a.tui-id-card-ref:focus-visible { .tui-review-tabs .tui-tab > svg { margin-right: 6px; } + +/* A fix's reply direction: what the reply will say once the fix lands, + quoted under a small label rather than framed like a field. */ +.tui-thread-direction { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 12px; + border-left: 2px solid var(--border); +} +.tui-thread-direction-k { + font-size: 12px; + font-weight: 600; + color: var(--text-3); +} +.tui-thread-direction-text { + font-size: 14px; + line-height: 1.5; + color: var(--text-2); +} From f241731687588ab3b47fd00221d314375c161d95 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 15:58:29 -0500 Subject: [PATCH 077/149] board: respond gates take the review card (B43); shared gate parts unify the cards - GateParts.tsx: card shell, body, comment area, draft field, said box, answer elbow, agent check and verdict calls, used by findings, re-review threads, respond plan threads and respond post replies - PlanThreadCard: tick keeps a thread (unticked skips), the code, the agent's verdict, a fix or reply switch, the reviewer's comment and the answer; the gate's fix/reply/skip options are unchanged - respond post replies stack: verdict, comment, then your reply - file-diff: a basename path takes the one changed file ending in it - bun.lock: lucide-react only (keeps @types/bun at main's pin) Co-Authored-By: Claude Opus 5.5 --- .../src/client/board/ClickToEditField.tsx | 5 + apps/board/src/client/board/GateParts.tsx | 376 ++++++++++++++++++ .../board/src/client/board/PlanThreadCard.tsx | 178 +++++++++ apps/board/src/client/board/RespondSheet.tsx | 127 ++---- .../src/client/board/ReviewDraftCard.tsx | 90 ++--- .../src/client/board/ThreadReplyCard.tsx | 264 +++++------- .../src/client/board/gate-stories.fixtures.ts | 64 +++ apps/board/src/style.css | 116 +++++- bun.lock | 10 +- lib/daemon/file-diff.ts | 9 +- 10 files changed, 896 insertions(+), 343 deletions(-) create mode 100644 apps/board/src/client/board/GateParts.tsx create mode 100644 apps/board/src/client/board/PlanThreadCard.tsx diff --git a/apps/board/src/client/board/ClickToEditField.tsx b/apps/board/src/client/board/ClickToEditField.tsx index 8877a83bbf..1c1e92f896 100644 --- a/apps/board/src/client/board/ClickToEditField.tsx +++ b/apps/board/src/client/board/ClickToEditField.tsx @@ -93,6 +93,7 @@ export function ClickToEditField({ actions, badge, footer, + lead, }: { label: string; text: string; @@ -113,6 +114,9 @@ export function ClickToEditField({ /** Controls in a strip along the field's bottom, inside its border and outside the text, so a click there never opens the field. */ footer?: ReactNode; + /** A line inside the box above the text, such as who is writing ("You:"); + it is not part of the click target. */ + lead?: ReactNode; }) { const [editing, setEditing] = useState(false); const area = useRef(null); @@ -266,6 +270,7 @@ export function ClickToEditField({ onAnimationComplete={() => setSwapping(false)} >
+ {lead &&
{lead}
} {editing ? ( diff --git a/apps/board/src/client/board/GateParts.tsx b/apps/board/src/client/board/GateParts.tsx new file mode 100644 index 0000000000..a9be267167 --- /dev/null +++ b/apps/board/src/client/board/GateParts.tsx @@ -0,0 +1,376 @@ +import { useState, type ReactNode } from 'react'; +import { Flag, ThumbsUp } from 'lucide-react'; +import { AnimatePresence, domAnimation, LazyMotion, m } from 'motion/react'; + +import { Button, CHECK_ICON, Icon, ICONS } from '@mattstack/tui-kit'; +import { commentState } from './CommentComposer.tsx'; +import { CommentMarkdown } from './CommentMarkdown.tsx'; +import { Disclosure } from './Disclosure.tsx'; +import type { FindingEntry, VerdictCall } from './gate-ctx.ts'; +import { CodeSection, CommentEditor, DraftMeta } from './ReviewDraftCard.tsx'; + +/** The parts every gate card is built from: a review finding, a re-review + thread, a respond plan thread and a respond post reply are the same + card with different contents. */ + +export type StatusHue = 'green' | 'amber' | 'accent' | 'muted'; + +/** A card's state on the right of its head: a dot and a word in its hue. */ +export function StatusMark({ + text, + hue, + children, +}: { + text: string; + hue: StatusHue; + children?: ReactNode; +}) { + return ( + + {text} + {children} + + ); +} + +/** A file:line label anchors a diff; "General thread" and "Overview + comment" do not. */ +export const isAnchor = (label: string) => /:\d+$/.test(label); + +/** The card's title: a file:line in mono, anything else in the sans. */ +export function CardTitle({ + label, + children, +}: { + label: string; + children?: ReactNode; +}) { + return ( + + {label} + {children} + + ); +} + +/** The card's body, folding away with the head's tick. Clips only while it + eases: open, a popover inside it must be free to spill out. */ +export function CardBody({ + open, + children, +}: { + open: boolean; + children: ReactNode; +}) { + return ( + + + {open && ( + + {children} + + )} + + + ); +} + +/** A thread card: the tick and everything beside it are one label, so the + whole head toggles the card; `aside` sits outside it, in the head's + right end, for a control of its own. */ +export function GateCard({ + label, + checked, + tickLabel, + onCheck, + head, + aside, + className, + children, +}: { + label: string; + checked: boolean; + tickLabel: string; + onCheck: (on: boolean) => void; + head: ReactNode; + aside?: ReactNode; + className?: string; + children: ReactNode; +}) { + return ( +
+
+ + {aside} +
+ {children} +
+ ); +} + +/** The code a thread was left on, through the review card's code section; + nothing for a thread with no file:line. */ +export function ThreadCode({ + label, + mr, +}: { + label: string; + mr?: { url?: string; ref?: string; revision?: string }; +}) { + if (!isAnchor(label) || !mr?.url) return null; + return ( + + ); +} + +/** The card's grey comment area; `edited` marks a changed draft inside it, + which its reset finds by `data-draft-part`. */ +export function CommentArea({ + label, + edited = false, + className, + children, +}: { + label: string; + edited?: boolean; + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** A drafted comment or reply: the click-to-edit field, with "Edited | + Reset" on its border at rest and the reset beside its heading while + writing. */ +export function DraftField({ + label, + draft, + value, + heading, + lead, + footer, + onChange, + onReset, + onEditing, +}: { + label: string; + draft: string; + value: string | undefined; + heading: string; + lead?: ReactNode; + footer?: ReactNode; + onChange: (text: string) => void; + onReset: () => void; + onEditing?: (editing: boolean) => void; +}) { + const { edited } = commentState(draft, value, true); + const reset = (resetText?: string) => ( + + ); + return ( + + Edited +
)} - - - + + )} + + {p.check && } + {said ? ( + <> + + {field} + + ) : ( + field + )} + + ); } @@ -193,62 +169,4 @@ function Message({ who, text }: ThreadMessage): ReactNode { ); } -/** How the agent judged the author's answer: the change holds, the - pushback holds, or the thread is still open. */ -export type AgentVerdict = 'verified' | 'accepted' | 'flagged'; - -const VERDICT = { - verified: { - label: 'Agent verified', - intent: 'ok', - icon: , - }, - accepted: { - label: 'Agent accepted', - intent: 'accent', - icon: , - }, - flagged: { - label: 'Agent flagged', - intent: 'warn', - icon: , - }, -} as const; - -/** What the agent found on the thread, behind a badge; a flagged thread - opens with its note showing. */ -function AgentCheck({ - note, - verdict, -}: { - note: string; - verdict: AgentVerdict; -}) { - const [open, setOpen] = useState(verdict === 'flagged'); - const v = VERDICT[verdict]; - return ( -
- - -

{note}

-
-
- ); -} +export type { AgentVerdict }; diff --git a/apps/board/src/client/board/gate-stories.fixtures.ts b/apps/board/src/client/board/gate-stories.fixtures.ts index a42f0834de..896c71c987 100644 --- a/apps/board/src/client/board/gate-stories.fixtures.ts +++ b/apps/board/src/client/board/gate-stories.fixtures.ts @@ -635,7 +635,70 @@ const pinOrigin = { runId: '20261028-091512-4c7d-11806', }; +// Invented code for the respond threads. Respond threads name their file +// by basename, so the story's diff source answers to that. +const pinDiff = (file: string, patch: string): FileDiff => ({ + path: file, + patch, + forge: 'gitlab', + webUrl: `${FORGE}/831/diffs#${file}`, +}); + +const PLAN_PATCHES: Record = { + 'StationPins.tsx': `@@ -44,6 +44,7 @@ export function StationPins({ stations }: Props) { + const map = useMap(); + + return ( + ++ {stations.map((s, i) => )} + + ); +`, + 'useMapCenter.ts': `@@ -19,5 +19,6 @@ import { useInterval } from './useInterval'; + + export function useMapCenter(map: MapView) { + const [center, setCenter] = useState(map.center); ++ useInterval(() => setCenter(map.center), 200); + return center; + } +`, + 'StationPins.test.tsx': `@@ -88,5 +88,8 @@ describe('StationPins', () => { + }); + ++ it('renders a pin per station', () => { ++ render(); ++ expect(screen.getAllByRole('button')).toHaveLength(fixtures.length); + }); +`, + 'stationLayer.ts': `@@ -127,4 +127,5 @@ export function stationLayer(stations: Station[]) { + return source; + } + ++export const CLUSTER_RADIUS = 48; +`, +}; + +const POST_PATCHES: Record = { + ...PLAN_PATCHES, + 'StationPins.tsx': `@@ -44,6 +44,7 @@ export function StationPins({ stations }: Props) { + const map = useMap(); + + return ( + ++ {stations.map(s => )} + + ); +`, +}; + +const pinDiffs = (patches: Record): DiffSource => ({ + fetchFileDiff: async ({ file }) => + patches[file] ? pinDiff(file, patches[file]!) : null, + loadView: () => import('./DiffView.tsx'), +}); + export const respondPlan: StoryGate = { + diffs: pinDiffs(PLAN_PATCHES), mr: PIN_MR, gate: { gateId: 'story-respond-plan', @@ -742,6 +805,7 @@ const postThread = ( and the re-review ping on the Overview thread, which GitLab cannot resolve. */ export const respondPost: StoryGate = { + diffs: pinDiffs(POST_PATCHES), // The answered plan gate rides on the MR, as on the board, so each reply // card shows the reviewer's comment above it. mr: { diff --git a/apps/board/src/style.css b/apps/board/src/style.css index c0aa5a923a..1aca1f7f61 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4368,21 +4368,24 @@ a.tui-id-card-ref:focus-visible { /* Inside a drafted card the card is the box: the code loses its own frame and its file bar spans the card. The snippet, not the whole block, opens the full diff. */ -.tui-review-finding .tui-review-section[data-section='code'] { +:is(.tui-review-finding, .tui-reply-card) + .tui-review-section[data-section='code'] { padding: 0; border-top: none; } -.tui-review-finding .tui-review-code, -.tui-review-finding .tui-review-code:hover { +:is(.tui-review-finding, .tui-reply-card) .tui-review-code, +:is(.tui-review-finding, .tui-reply-card) .tui-review-code:hover { border: none; border-radius: 0; cursor: default; } -.tui-review-finding .tui-review-code-file { +:is(.tui-review-finding, .tui-reply-card) .tui-review-code-file { padding: 6px 14px; background: var(--panel); } -.tui-review-finding .tui-review-code[data-folded] .tui-review-code-file { +:is(.tui-review-finding, .tui-reply-card) + .tui-review-code[data-folded] + .tui-review-code-file { border-bottom: none; } .tui-review-code-snippet { @@ -7252,3 +7255,106 @@ a.tui-id-card-ref:focus-visible { line-height: 1.5; color: var(--text-2); } + +/* A plan thread in the review card (B43): the verdict and the fix or + reply switch, the reviewer's comment in a box, then what answers it. */ +.tui-plan-card .tui-review-section[data-section='comment'] { + gap: 12px; +} +.tui-plan-verdict { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.tui-plan-verdict > .tui-reply-check { + flex: 1; + min-width: 0; +} +.tui-plan-switch { + display: inline-flex; + flex-shrink: 0; + gap: 2px; + padding: 3px; + border-radius: 8px; + background: color-mix(in srgb, var(--fg) 6%, transparent); +} +.tui-plan-said { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: 6px; + background: var(--card); + font-size: 14px; + line-height: 1.5; + color: var(--text-1); +} +.tui-plan-said-who { + font-size: 13px; + font-weight: 600; +} +.tui-plan-fix { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + padding-left: 10px; + font-size: 14px; + line-height: 1.5; + color: var(--text-1); +} +.tui-plan-fix > svg { + flex-shrink: 0; + margin-top: 2px; + color: var(--text-accent); +} +/* What answers the comment hangs under it on a rounded elbow that turns + into the answer's name line. */ +.tui-plan-answer { + position: relative; + padding-left: 28px; +} +.tui-plan-answer::before { + content: ''; + position: absolute; + left: 10px; + top: -12px; + width: 15px; + height: 32px; + border-left: 1.5px solid var(--text-4); + border-bottom: 1.5px solid var(--text-4); + border-bottom-left-radius: 8px; +} +.tui-plan-later { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border: 1px dashed var(--border); + border-radius: 6px; + background: var(--card); + font-size: 14px; + line-height: 1.5; + color: var(--text-2); +} +/* The name line inside a comment field, like the reviewer's name in theirs. */ +.tui-cte-lead { + padding: 10px 12px 0; + font-size: 13px; + font-weight: 600; + color: var(--text-1); +} +.tui-plan-later > .tui-cte-lead { + padding: 0; +} +.tui-cte[data-variant='plain'] + .tui-cte-lead + ~ * + :is(.tui-cte-view, .tui-cte-input) { + padding-top: 2px; +} +.tui-stacked-area { + gap: 12px; +} diff --git a/bun.lock b/bun.lock index 446ac7e779..3f41f9f79e 100644 --- a/bun.lock +++ b/bun.lock @@ -1535,7 +1535,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -3359,8 +3359,6 @@ "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@types/bun/bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="], - "@types/pixelmatch/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@types/pngjs/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], @@ -3419,8 +3417,6 @@ "cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], - "deck/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], - "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -3591,8 +3587,6 @@ "@textlint/linter-formatter/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "@types/bun/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - "@types/pixelmatch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "@types/pngjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -3789,8 +3783,6 @@ "@mattstack/glance-react/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], - "@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "@vue/language-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@mattstack/glance-react/eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/lib/daemon/file-diff.ts b/lib/daemon/file-diff.ts index 2ec5d30f80..d8ba5110cd 100644 --- a/lib/daemon/file-diff.ts +++ b/lib/daemon/file-diff.ts @@ -212,7 +212,14 @@ export function createFileDiffReader(opts: { now?: () => number } = {}) { } const listed = await entry.listing; if (!listed.ok) return listed; - const file = listed.files.find((f) => f.newPath === path) ?? listed.files.find((f) => f.oldPath === path) ?? null; + // A respond thread names its file by basename alone, so a path with no + // exact match takes the one changed file ending in it; two such files + // are ambiguous and match nothing. + const bySuffix = path.includes("/") ? [] : listed.files.filter((f) => f.newPath.endsWith(`/${path}`)); + const file = + listed.files.find((f) => f.newPath === path) ?? + listed.files.find((f) => f.oldPath === path) ?? + (bySuffix.length === 1 ? bySuffix[0]! : null); return { ok: true, data: { forge: ctx.forge, headSha: head.headSha, truncated: listed.truncated, file } }; }; } From c9c2133cb7c1deb7e6a915d209c93eed21a6972b Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 17:02:45 -0500 Subject: [PATCH 078/149] board: the fix/reply switch is the kit's soft LabeledSeg (B43); post cards carry the severity pill, reply-only drops the agent check Co-Authored-By: Claude Opus 5.5 --- .../board/src/client/board/PlanThreadCard.tsx | 55 ++++++++----------- apps/board/src/client/board/RespondSheet.tsx | 7 ++- .../src/client/board/ThreadReplyCard.tsx | 3 + apps/board/src/style.css | 17 +++++- .../src/recipes/Segmented/Segmented.tsx | 4 +- 5 files changed, 49 insertions(+), 37 deletions(-) diff --git a/apps/board/src/client/board/PlanThreadCard.tsx b/apps/board/src/client/board/PlanThreadCard.tsx index 17b6b4e947..45177dcdb1 100644 --- a/apps/board/src/client/board/PlanThreadCard.tsx +++ b/apps/board/src/client/board/PlanThreadCard.tsx @@ -1,11 +1,8 @@ -import { useEffect, useRef, type ReactNode } from 'react'; +import { useEffect, useRef } from 'react'; import { CornerDownRight, MessageSquare, Wrench } from 'lucide-react'; -import type { - GateItemChoice, - GateItemDisplay, -} from '@mattstack/gate-kit/react'; -import { Button } from '@mattstack/tui-kit'; +import type { GateItemDisplay } from '@mattstack/gate-kit/react'; +import { LabeledSeg } from '@mattstack/tui-kit'; import { commentState } from './CommentComposer.tsx'; import type { ThreadCtx } from './gate-ctx.ts'; import type { GateFormState } from './GateForm.tsx'; @@ -78,20 +75,6 @@ export function PlanThreadCard({ const value = replying ? form.texts[q.name] : undefined; const { edited } = commentState(draft ?? '', value, true); - const option = (c: GateItemChoice, icon: ReactNode, text: string) => ( - - ); - return ( )} {fix && reply && ( -
- {option(fix, , 'Fix the code')} - {option( - reply, - , - 'Reply only' - )} -
+ legend="Fix or reply" + options={[fix.value, reply.value]} + labels={{ + [fix.value]: ( + <> + + Fix the code + + ), + [reply.value]: ( + <> + + Reply only + + ), + }} + value={replying ? reply.value : fix.value} + onChange={choose} + /> )} } layout="stacked" mr={ mr @@ -657,7 +658,11 @@ function RespondSheetBody({ : undefined } check={(() => { - const verdict = thread && THREAD_CALL[thread.verdict.call].check; + // A reply-only thread's call was settled on the plan gate. + const verdict = + reply.verb === 'fix' && + thread && + THREAD_CALL[thread.verdict.call].check; return verdict && thread.verdict.note ? { note: thread.verdict.note, verdict } : undefined; diff --git a/apps/board/src/client/board/ThreadReplyCard.tsx b/apps/board/src/client/board/ThreadReplyCard.tsx index 98c82251fe..0e9c86f46f 100644 --- a/apps/board/src/client/board/ThreadReplyCard.tsx +++ b/apps/board/src/client/board/ThreadReplyCard.tsx @@ -31,6 +31,8 @@ export interface ThreadReplyCardProps { sha?: string; /** The review round the thread was opened in. */ round?: number; + /** Beside the title, as the plan card shows the thread's severity. */ + tag?: ReactNode; /** `conversation`: the thread reads as who said what above the reply (a re-review). `stacked`: the agent's check, the comment being answered in its box, and the reply hung under it (a respond post). */ @@ -110,6 +112,7 @@ export function ThreadReplyCard(p: ThreadReplyCardProps) {
)} + {p.tag} {p.sha && {p.sha}} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 1aca1f7f61..cb6ad1ddbb 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7272,12 +7272,23 @@ a.tui-id-card-ref:focus-visible { min-width: 0; } .tui-plan-switch { - display: inline-flex; flex-shrink: 0; - gap: 2px; padding: 3px; border-radius: 8px; - background: color-mix(in srgb, var(--fg) 6%, transparent); +} +.tui-plan-switch [data-part='segmented-option'] { + flex: none; + gap: 6px; + white-space: nowrap; + padding: 5px 12px; + border-radius: 6px; + font-size: 13px; +} +.tui-plan-switch [data-part='segmented-option'][data-active] { + font-weight: 600; +} +.tui-plan-switch [data-part='segmented-option'][data-active] svg { + color: var(--text-accent-small); } .tui-plan-said { display: flex; diff --git a/packages/tui-kit/src/recipes/Segmented/Segmented.tsx b/packages/tui-kit/src/recipes/Segmented/Segmented.tsx index 20b5161cac..209a78b7a2 100644 --- a/packages/tui-kit/src/recipes/Segmented/Segmented.tsx +++ b/packages/tui-kit/src/recipes/Segmented/Segmented.tsx @@ -1,5 +1,5 @@ import type { StylesApiProps, UniversalStyleProps } from "@soribashi/core"; -import type { ReactElement, Ref } from "react"; +import type { ReactElement, ReactNode, Ref } from "react"; import { defineGenericComponent } from "../../builders.ts"; import { ICONS } from "../Icon/Icon.tsx"; import classes from "./Segmented.module.css"; @@ -136,7 +136,7 @@ export interface LabeledSegOwnProps { soft?: boolean; legend: string; options: readonly T[]; - labels: Record; + labels: Record; value: T; onChange: (v: T) => void; } From 5bb95309705e1fe74f6f8172d31f03246c583ddb Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 19:01:56 -0500 Subject: [PATCH 079/149] board: plan thread cards drop the per-thread note (B43) Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/PlanThreadCard.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/board/src/client/board/PlanThreadCard.tsx b/apps/board/src/client/board/PlanThreadCard.tsx index 45177dcdb1..e814ff10f6 100644 --- a/apps/board/src/client/board/PlanThreadCard.tsx +++ b/apps/board/src/client/board/PlanThreadCard.tsx @@ -19,7 +19,6 @@ import { ThreadCode, } from './GateParts.tsx'; import { SeverityPill } from './RespondCards.tsx'; -import { Note } from './SheetParts.tsx'; /** One reviewer thread on the plan gate, in the review finding's card: the tick keeps it (unticked posts nothing and changes nothing), the code the @@ -164,7 +163,6 @@ export function PlanThreadCard({ )} )} - ); From 7d80f2a0cca7d5fdc37a544f39af17e8f0e7fa54 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 19:03:03 -0500 Subject: [PATCH 080/149] board: the plan's reply-later placeholder drops its You: line Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/PlanThreadCard.tsx | 9 +++------ apps/board/src/style.css | 7 +------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/board/src/client/board/PlanThreadCard.tsx b/apps/board/src/client/board/PlanThreadCard.tsx index e814ff10f6..3dae43d2ab 100644 --- a/apps/board/src/client/board/PlanThreadCard.tsx +++ b/apps/board/src/client/board/PlanThreadCard.tsx @@ -154,12 +154,9 @@ export function PlanThreadCard({ onReset={() => form.clearText(q.name)} /> ) : ( -
- You: - - The agent drafts this reply next. You edit it when posting. - -
+

+ The agent drafts this reply next. You edit it when posting. +

)} )} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index cb6ad1ddbb..a3c4c0139c 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7339,9 +7339,7 @@ a.tui-id-card-ref:focus-visible { border-bottom-left-radius: 8px; } .tui-plan-later { - display: flex; - flex-direction: column; - gap: 2px; + margin: 0; padding: 10px 12px; border: 1px dashed var(--border); border-radius: 6px; @@ -7357,9 +7355,6 @@ a.tui-id-card-ref:focus-visible { font-weight: 600; color: var(--text-1); } -.tui-plan-later > .tui-cte-lead { - padding: 0; -} .tui-cte[data-variant='plain'] .tui-cte-lead ~ * From 421296462e7afff25eca66908e785922dd9e4ee0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 19:03:19 -0500 Subject: [PATCH 081/149] board: plan reply placeholder copy to ui-copy shape Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/PlanThreadCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/board/src/client/board/PlanThreadCard.tsx b/apps/board/src/client/board/PlanThreadCard.tsx index 3dae43d2ab..fdca49a3de 100644 --- a/apps/board/src/client/board/PlanThreadCard.tsx +++ b/apps/board/src/client/board/PlanThreadCard.tsx @@ -155,7 +155,7 @@ export function PlanThreadCard({ /> ) : (

- The agent drafts this reply next. You edit it when posting. + Agent drafts this reply next

)} From 156b05873def7a1f2675afab90ca10dff93388e0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 19:03:52 -0500 Subject: [PATCH 082/149] board: plan reply placeholder reads Drafted at posting Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/PlanThreadCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/board/src/client/board/PlanThreadCard.tsx b/apps/board/src/client/board/PlanThreadCard.tsx index fdca49a3de..96a11fee2a 100644 --- a/apps/board/src/client/board/PlanThreadCard.tsx +++ b/apps/board/src/client/board/PlanThreadCard.tsx @@ -155,7 +155,7 @@ export function PlanThreadCard({ /> ) : (

- Agent drafts this reply next + Drafted at posting

)} From de7cf02a2f1d021e98db9ac8c516038ab46a93ec Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 19:05:08 -0500 Subject: [PATCH 083/149] board: the respond plan list opens with a line on what the gate decides Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/RespondSheet.tsx | 6 ++++++ apps/board/src/style.css | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/apps/board/src/client/board/RespondSheet.tsx b/apps/board/src/client/board/RespondSheet.tsx index 800879c88c..8f26e9b58b 100644 --- a/apps/board/src/client/board/RespondSheet.tsx +++ b/apps/board/src/client/board/RespondSheet.tsx @@ -852,6 +852,12 @@ function RespondSheetBody({ : `${threadsDecided} of ${mainQs.length} decided`} + {!postThreads && !perThread && !repliesQ && ( +

+ Choose how to answer each comment: fix the code or just reply. + Untick one to skip it. +

+ )}
{threadJoin ? threadJoin.map(j => { diff --git a/apps/board/src/style.css b/apps/board/src/style.css index a3c4c0139c..809f985454 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -3824,6 +3824,13 @@ a.tui-id-card-ref:focus-visible { .tui-sheet-list-title { font-weight: 700; } +.tui-sheet-list-head:has(+ .tui-sheet-list-lede) { + margin-bottom: 4px; +} +.tui-sheet-list-lede { + margin: 0 0 12px; + color: var(--text-2); +} .tui-sheet-list-tally { color: var(--text-3); font-size: var(--gate-font-meta); From 9c9052d60de4f63ed1e48985dee42a304ad641b0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 19:12:46 -0500 Subject: [PATCH 084/149] board: respond gates show Plan / Fix / Post in the round header (B44 A2c) Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/RespondSheet.tsx | 6 ++- apps/board/src/client/board/SheetParts.tsx | 29 ++++++++++++- apps/board/src/style.css | 43 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/board/src/client/board/RespondSheet.tsx b/apps/board/src/client/board/RespondSheet.tsx index 8f26e9b58b..3d43aec103 100644 --- a/apps/board/src/client/board/RespondSheet.tsx +++ b/apps/board/src/client/board/RespondSheet.tsx @@ -71,6 +71,7 @@ import { Choices, KeyCap, Note, + PhaseTrack, ProseContext, reserveDock, RoundHeading, @@ -825,7 +826,10 @@ function RespondSheetBody({ return (
- + } + />
{postThreads || perThread ? ( diff --git a/apps/board/src/client/board/SheetParts.tsx b/apps/board/src/client/board/SheetParts.tsx index dc7574aa0e..14695e07d0 100644 --- a/apps/board/src/client/board/SheetParts.tsx +++ b/apps/board/src/client/board/SheetParts.tsx @@ -280,11 +280,14 @@ function RoundHeading({ round, revise, summary, + aside, }: { round?: number; /** Re-adjudications past the round, shown after it. */ revise?: number; summary?: string; + /** Held at the band's right edge. */ + aside?: ReactNode; }) { const text = [ round !== undefined ? `Round ${round}` : undefined, @@ -294,15 +297,36 @@ function RoundHeading({ ] .filter(Boolean) .join(' · '); - if (!text) return null; + if (!text && !aside) return null; return (
-

{text}

+ {text &&

{text}

} {summary && {summary}} + {aside &&
{aside}
}
); } +const RESPOND_PHASES = ['Plan', 'Fix', 'Post'] as const; + +/** Where a respond run is: you plan, the agent fixes, you post. */ +function PhaseTrack({ at }: { at: (typeof RESPOND_PHASES)[number] }) { + return ( +
    + {RESPOND_PHASES.map(p => ( +
  1. + + {p} +
  2. + ))} +
+ ); +} + /** A keyboard hint, drawn from `data-keys` by CSS so it stays out of the control's text and accessible name: the label carries the meaning. `onFill` sits it inside a filled button. */ @@ -326,6 +350,7 @@ export { Note, ProseContext, reserveDock, + PhaseTrack, RoundHeading, SheetLost, SheetRows, diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 809f985454..b5832bd398 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -3806,6 +3806,49 @@ a.tui-id-card-ref:focus-visible { font-size: var(--gate-font-body); color: var(--text-2); } +.tui-sheet-round-aside { + align-self: center; + margin-left: auto; +} +.tui-phase-track { + display: flex; + align-items: center; + margin: 0; + padding: 0; + list-style: none; +} +.tui-phase { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 500; + color: var(--text-3); +} +.tui-phase + .tui-phase::before { + content: ''; + width: 40px; + height: 2px; + margin: 0 8px; + border-radius: 1px; + background: color-mix(in srgb, var(--border) 75%, transparent); +} +.tui-phase-dot { + box-sizing: border-box; + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-radius: 50%; + background: var(--card); +} +.tui-phase[aria-current] { + font-weight: 700; + color: var(--text-accent-small); +} +.tui-phase[aria-current] .tui-phase-dot { + border-color: var(--fill-accent); + background: radial-gradient(var(--on-fill-accent) 0 2.5px, var(--fill-accent) 3px); +} .tui-sheet-round + .tui-sheet-list-head { margin-top: 0; } From dfc2c841454bf236531d57dc1acf5d4f3671b5dc Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:44:01 -0500 Subject: [PATCH 085/149] board: plan reply placeholder hugs its text Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index b5832bd398..399957ddac 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7389,6 +7389,8 @@ a.tui-id-card-ref:focus-visible { border-bottom-left-radius: 8px; } .tui-plan-later { + align-self: flex-start; + width: fit-content; margin: 0; padding: 10px 12px; border: 1px dashed var(--border); From ba910994b5c95351711350e1b19fefdbb614778e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:45:12 -0500 Subject: [PATCH 086/149] board: plan reply placeholder gets an info tip on when the reply is written Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/PlanThreadCard.tsx | 15 +++++++++++++-- apps/board/src/style.css | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/board/src/client/board/PlanThreadCard.tsx b/apps/board/src/client/board/PlanThreadCard.tsx index 96a11fee2a..dae988d81f 100644 --- a/apps/board/src/client/board/PlanThreadCard.tsx +++ b/apps/board/src/client/board/PlanThreadCard.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react'; -import { CornerDownRight, MessageSquare, Wrench } from 'lucide-react'; +import { CornerDownRight, Info, MessageSquare, Wrench } from 'lucide-react'; import type { GateItemDisplay } from '@mattstack/gate-kit/react'; -import { LabeledSeg } from '@mattstack/tui-kit'; +import { LabeledSeg, Tooltip } from '@mattstack/tui-kit'; import { commentState } from './CommentComposer.tsx'; import type { ThreadCtx } from './gate-ctx.ts'; import type { GateFormState } from './GateForm.tsx'; @@ -20,6 +20,8 @@ import { } from './GateParts.tsx'; import { SeverityPill } from './RespondCards.tsx'; +const LATER_TIP = 'The agent drafts it in Fix. You edit it in Post'; + /** One reviewer thread on the plan gate, in the review finding's card: the tick keeps it (unticked posts nothing and changes nothing), the code the reviewer commented on, the agent's verdict beside the choice between @@ -156,6 +158,15 @@ export function PlanThreadCard({ ) : (

Drafted at posting + + +

)} diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 399957ddac..ac79e35d2b 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7389,6 +7389,9 @@ a.tui-id-card-ref:focus-visible { border-bottom-left-radius: 8px; } .tui-plan-later { + display: flex; + align-items: center; + gap: 6px; align-self: flex-start; width: fit-content; margin: 0; @@ -7400,6 +7403,19 @@ a.tui-id-card-ref:focus-visible { line-height: 1.5; color: var(--text-2); } +.tui-plan-later-info { + display: inline-flex; + padding: 2px; + border: 0; + border-radius: 4px; + background: none; + color: var(--text-3); + cursor: help; +} +.tui-plan-later-info:hover, +.tui-plan-later-info:focus-visible { + color: var(--text-1); +} /* The name line inside a comment field, like the reviewer's name in theirs. */ .tui-cte-lead { padding: 10px 12px 0; From 3e8e03a036d989ae69e1896255be6f967bc43a9f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:45:40 -0500 Subject: [PATCH 087/149] board: plan info tip uses the pointer cursor Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index ac79e35d2b..e8f0bb6d8e 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7410,7 +7410,7 @@ a.tui-id-card-ref:focus-visible { border-radius: 4px; background: none; color: var(--text-3); - cursor: help; + cursor: pointer; } .tui-plan-later-info:hover, .tui-plan-later-info:focus-visible { From 1287184000018b3e10302022b3a31e970229387c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:46:18 -0500 Subject: [PATCH 088/149] board: plan info tip centres on the placeholder text Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index e8f0bb6d8e..396106664e 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7403,6 +7403,9 @@ a.tui-id-card-ref:focus-visible { line-height: 1.5; color: var(--text-2); } +.tui-plan-later > [data-part='tooltip'] { + display: flex; +} .tui-plan-later-info { display: inline-flex; padding: 2px; From 45e9d0e60503f20274662414a07ac188b38a909f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:47:32 -0500 Subject: [PATCH 089/149] board: plan info tip sits 1px lower to meet the text's ink Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 396106664e..58c401ef70 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7408,6 +7408,8 @@ a.tui-id-card-ref:focus-visible { } .tui-plan-later-info { display: inline-flex; + /* The text's ink sits below its line box's centre. */ + translate: 0 1px; padding: 2px; border: 0; border-radius: 4px; From ea34731f5181166a65c379477efddc79b9be2413 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:48:27 -0500 Subject: [PATCH 090/149] board: plan info tip copy Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/PlanThreadCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/board/src/client/board/PlanThreadCard.tsx b/apps/board/src/client/board/PlanThreadCard.tsx index dae988d81f..0d488ebb37 100644 --- a/apps/board/src/client/board/PlanThreadCard.tsx +++ b/apps/board/src/client/board/PlanThreadCard.tsx @@ -20,7 +20,8 @@ import { } from './GateParts.tsx'; import { SeverityPill } from './RespondCards.tsx'; -const LATER_TIP = 'The agent drafts it in Fix. You edit it in Post'; +const LATER_TIP = + 'Your reply will be drafted during the Post step. The agent writes the draft and you can edit it freely at that point.'; /** One reviewer thread on the plan gate, in the review finding's card: the tick keeps it (unticked posts nothing and changes nothing), the code the From 44b194734e5c6e873408dd6bd914dd826e1d2651 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:51:09 -0500 Subject: [PATCH 091/149] board: reply elbow follows the field down when editing Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 58c401ef70..0bf5be7fe8 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7387,6 +7387,13 @@ a.tui-id-card-ref:focus-visible { border-left: 1.5px solid var(--text-4); border-bottom: 1.5px solid var(--text-4); border-bottom-left-radius: 8px; + /* Matches the edit heading's slide in ClickToEditField (SWAP). */ + transition: height 150ms cubic-bezier(0.4, 0, 0.2, 1); +} +/* Editing opens a heading row above the field; the elbow follows the + field's name line down past it. */ +.tui-plan-answer:has(.tui-cte[data-editing])::before { + height: 57px; } .tui-plan-later { display: flex; From df57c22cfbb6bfd09dd45ec739cf75865db61ec8 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:53:56 -0500 Subject: [PATCH 092/149] board: reply elbows drop to the control border colour Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 0bf5be7fe8..03352aa368 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -4573,8 +4573,8 @@ a.tui-id-card-ref:focus-visible { top: -12px; width: 12px; height: 21px; - border-left: 1.5px solid var(--text-4); - border-bottom: 1.5px solid var(--text-4); + border-left: 1.5px solid var(--border-control); + border-bottom: 1.5px solid var(--border-control); border-bottom-left-radius: 8px; } .tui-reply-name { @@ -7384,8 +7384,8 @@ a.tui-id-card-ref:focus-visible { top: -12px; width: 15px; height: 32px; - border-left: 1.5px solid var(--text-4); - border-bottom: 1.5px solid var(--text-4); + border-left: 1.5px solid var(--border-control); + border-bottom: 1.5px solid var(--border-control); border-bottom-left-radius: 8px; /* Matches the edit heading's slide in ClickToEditField (SWAP). */ transition: height 150ms cubic-bezier(0.4, 0, 0.2, 1); From 41660cd233b12ef77b20a11d7d00b3ff556f1cc8 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:55:16 -0500 Subject: [PATCH 093/149] board: your reply wears a faint accent wash on the respond gates Co-Authored-By: Claude Opus 5.5 --- apps/board/src/style.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/board/src/style.css b/apps/board/src/style.css index 03352aa368..ba5952b730 100644 --- a/apps/board/src/style.css +++ b/apps/board/src/style.css @@ -7395,6 +7395,12 @@ a.tui-id-card-ref:focus-visible { .tui-plan-answer:has(.tui-cte[data-editing])::before { height: 57px; } +/* Your reply carries a faint accent wash so it reads apart from the + reviewer's comment above it; editing turns it back into a plain field. */ +.tui-plan-answer .tui-cte:not([data-editing]) { + border-color: color-mix(in srgb, var(--fill-accent) 22%, var(--border)); + background: color-mix(in srgb, var(--fill-accent) 5%, var(--card)); +} .tui-plan-later { display: flex; align-items: center; From 18de419d7261802d7f7f76cac333b2e24f4aa0a1 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 20:56:45 -0500 Subject: [PATCH 094/149] board: respond gates end with the review sheet's end-of-list flourish SheetEnd moves into SheetParts so both bodies share it. Co-Authored-By: Claude Opus 5.5 --- apps/board/src/client/board/RespondSheet.tsx | 2 + .../src/client/board/ReviewGateSheet.tsx | 97 +------------------ apps/board/src/client/board/SheetParts.tsx | 92 ++++++++++++++++++ 3 files changed, 98 insertions(+), 93 deletions(-) diff --git a/apps/board/src/client/board/RespondSheet.tsx b/apps/board/src/client/board/RespondSheet.tsx index 3d43aec103..f2d61f2e0b 100644 --- a/apps/board/src/client/board/RespondSheet.tsx +++ b/apps/board/src/client/board/RespondSheet.tsx @@ -75,6 +75,7 @@ import { ProseContext, reserveDock, RoundHeading, + SheetEnd, SheetLost, SheetRows, SUBMIT_KEYS, @@ -987,6 +988,7 @@ function RespondSheetBody({ ); })}
+ You've reached the end of the threads.