From f1187924af51ba594a2ccd45cfbfe3c17dd2c106 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:47:26 +0100 Subject: [PATCH 1/7] Re-run pull request checks Co-Authored-By: Claude Sonnet 5.5 From 19b910c309b09fa1da27b8b1c8d349aea95a75e7 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:32:06 +0100 Subject: [PATCH 2/7] Polish the living document flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the living document one sync vocabulary (In sync, Out of sync, Syncing…, Sync failed) with a tooltip for every state, and make the Build headline match it. Finished tasks show what they did instead of boilerplate acceptance criteria. Tasks added by later syncs get their own "Since first build" section, and an unbuilt Planner draft no longer adds duplicate rows. The header slot sits correctly on narrow layouts, a failed first build stays visible, and spike callouts lead with the recommendation in shorter copy. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/experiments/spikes.test.ts | 6 +- apps/server/src/experiments/spikes.ts | 14 +-- apps/server/src/tasks/builds.ts | 10 +- apps/server/src/tasks/routes.test.ts | 10 ++ apps/web/src/build-model.test.ts | 33 +++++- apps/web/src/build-model.ts | 38 +++++-- apps/web/src/build-plan-button.tsx | 20 ++-- apps/web/src/build-view.css | 35 +++++++ apps/web/src/build-view.tsx | 116 +++++++++++++-------- apps/web/src/theme.css | 13 +++ 10 files changed, 221 insertions(+), 74 deletions(-) diff --git a/apps/server/src/experiments/spikes.test.ts b/apps/server/src/experiments/spikes.test.ts index 5eb18408d..d8bac2bbf 100644 --- a/apps/server/src/experiments/spikes.test.ts +++ b/apps/server/src/experiments/spikes.test.ts @@ -66,6 +66,8 @@ test("a submitted spike renders as a titled callout with findings and screenshot expect(source).toContain(`title="Drag handles work on touch with a 44px target"`); expect(source).toContain("- Pointer events fire on iOS Safari."); expect(source).toContain("**Recommendation:** Keep drag handles"); + // The recommendation leads; the findings that support it follow. + expect(source.indexOf("**Recommendation:**")).toBeLessThan(source.indexOf("- Pointer events")); expect(source).toContain(`![Prototype screenshot 1](${IMAGE})`); room.validate(source); let node = parse(source).children[0]; @@ -75,7 +77,7 @@ test("a submitted spike renders as a titled callout with findings and screenshot test("running and stopped spikes render their own callouts without naming a machine", () => { let running = serialize({ type: "root", children: [spikeCallout(spike("running"))] }); expect(running).toContain(`title="Prototyping…"`); - expect(running).toContain("maggie's coding agent"); + expect(running).toContain("@maggie’s coding agent"); room.validate(running); let stopped = spike("failed"); stopped.progress = "Agent stopped: cancelled"; @@ -86,7 +88,7 @@ test("running and stopped spikes render their own callouts without naming a mach expect(renderKey(spike("requested"))).toBe("queued"); expect(renderKey(spike("queued"))).toBe("queued"); let queued = serialize({ type: "root", children: [spikeCallout(spike("queued"))] }); - expect(queued).toContain(`title="Queued"`); + expect(queued).toContain(`title="Prototype queued"`); expect(queued).not.toContain("Prototyping"); room.validate(queued); expect(renderKey(spike("interrupted"))).toBe("stopped"); diff --git a/apps/server/src/experiments/spikes.ts b/apps/server/src/experiments/spikes.ts index f9a98802a..876968b3e 100644 --- a/apps/server/src/experiments/spikes.ts +++ b/apps/server/src/experiments/spikes.ts @@ -55,7 +55,7 @@ function paragraph(...children: PhrasingContent[]): BlockContent { return { type: "paragraph", children }; } -/** Canonical report MDX: bold headline, findings, recommendation, then screenshots. */ +/** Canonical report MDX: bold headline, the recommendation first, findings, then screenshots. */ export function spikeReport(input: SpikeSubmission): string { let items: ListItem[] = input.findings.map(finding => ({ type: "listItem", @@ -66,11 +66,11 @@ export function spikeReport(input: SpikeSubmission): string { type: "root", children: [ paragraph({ type: "strong", children: [{ type: "text", value: input.headline }] }), - { type: "list", ordered: false, spread: false, children: items }, paragraph( { type: "strong", children: [{ type: "text", value: "Recommendation:" }] }, { type: "text", value: ` ${input.recommendation}` }, ), + { type: "list", ordered: false, spread: false, children: items }, ...input.images.map((url, index) => paragraph({ type: "image", url, alt: `Prototype screenshot ${index + 1}` }) ), @@ -121,19 +121,19 @@ export function spikeCallout(value: Investigation): RootContent { ]); } if (key === "queued") { - return callout(spike.callout, "note", "Queued", [ + return callout(spike.callout, "note", "Prototype queued", [ paragraph({ type: "text", - value: `${spike.login}'s coding agent will build a quick prototype to test the passage ` - + "above once it finishes its current work. Delete this callout to cancel it.", + value: `@${spike.login}’s coding agent will prototype the passage above when it’s free. ` + + "Delete this callout to cancel.", }), ]); } return callout(spike.callout, "note", "Prototyping…", [ paragraph({ type: "text", - value: `${spike.login}'s coding agent is building a quick prototype to test the passage ` - + "above. Delete this callout to stop it.", + value: `@${spike.login}’s coding agent is testing the passage above with a quick prototype. ` + + "Delete this callout to stop it.", }), ]); } diff --git a/apps/server/src/tasks/builds.ts b/apps/server/src/tasks/builds.ts index 34f53abde..a57d8db85 100644 --- a/apps/server/src/tasks/builds.ts +++ b/apps/server/src/tasks/builds.ts @@ -774,8 +774,12 @@ export function reportRebuild( `Living-document rebuild of revisions ${live.baseRevision} to ${target.revision}.`, goal: task.goal, acceptance: [ - `Committed on ${task.pullRequest}.`, - `Reflects the document change since revision ${live.baseRevision}.`, + `Updates pull request ${ + task.pullRequest.startsWith(prefix) + ? `#${task.pullRequest.slice(prefix.length)}` + : task.pullRequest + }.`, + "Matches the document as edited.", ], dependsOn: [], })), @@ -884,8 +888,10 @@ export function liveSnapshot(plan: Plan, connections: Connection[]): LiveSnapsho // The first build's tasks, then each rebuild's appended version, with their last reported state. let first = plan.builds.find(build => build.id === live.buildId)?.graphVersion; let history = plan.graph ? historyFor(plan.graph, plan.lifecycle) : []; + // Only versions that ran: a Planner draft started since would list unbuilt duplicates. let tasks = (plan.graph?.versions ?? []).filter(version => first !== undefined && version.number >= first + && history.some(item => item.run.graphVersion === version.number) ).flatMap(version => { let progress = history.findLast(item => item.run.graphVersion === version.number)?.progress; return version.definition.tasks.map(task => { diff --git a/apps/server/src/tasks/routes.test.ts b/apps/server/src/tasks/routes.test.ts index 08c9b58ea..3720ff345 100644 --- a/apps/server/src/tasks/routes.test.ts +++ b/apps/server/src/tasks/routes.test.ts @@ -823,6 +823,16 @@ test("report_rebuild appends a completed version, records commits and advances t }); expect(snapshot.live.baseSource).toBeUndefined(); expect(snapshot.lifecycle.history.at(-1).outcome.kind).toBe("implemented"); + expect(snapshot.live.tasks.at(-1)).toMatchObject({ + id: "rebuild-2-1", + acceptance: ["Updates pull request #7.", "Matches the document as edited."], + }); + // A Planner draft started after the build is not built work, so the list ignores it. + plan.graph!.versions.push({ ...structuredClone(plan.graph!.versions[0]!), number: 3 }); + plan.graph!.versions.at(-1)!.state = "draft"; + let drafted = await (await context.call(context.path)).json(); + expect(drafted.live.tasks).toEqual(snapshot.live.tasks); + plan.graph!.versions.pop(); let live = plan.live; await Plan.close(plan); closed = true; diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index 1f7645c42..f01b76067 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -10,6 +10,7 @@ import { draftRefusalCopy, elapsed, firstBuildStep, + liveTaskGroups, pullRequestCommits, pullRequestNumber, shouldAutoDraft, @@ -17,7 +18,9 @@ import { startingHint, startingLabel, syncHint, + syncLabel, syncStatus, + syncTooltip, taskStartsOpen, waitingLabel, } from "./build-model"; @@ -473,14 +476,17 @@ describe("living document sync", () => { it("explains why it is out of sync", () => { let pending = live({ outOfSync: true, rebuild: rebuild("stopped") }); expect(syncStatus(pending)).toEqual({ kind: "out-of-sync", reason: "pending", outstanding: 0 }); - expect(syncHint(syncStatus(pending), pending, "me")).toBe("Changes will build shortly"); + expect(syncHint(syncStatus(pending), pending, "me")).toBe("Your edits will sync shortly"); let waiting = { ...live({ outOfSync: true, builderConnected: false }), builtBy: "jev" }; expect(syncStatus(waiting)).toEqual({ kind: "out-of-sync", reason: "waiting", outstanding: 0 }); expect(syncHint(syncStatus(waiting), waiting, "me")).toBe("Waiting for @jev’s agent"); expect(syncHint(syncStatus(waiting), waiting, "u")).toBe("Waiting for your agent"); let failed = live({ outOfSync: true, rebuild: rebuild("failed") }); expect(syncStatus(failed)).toEqual({ kind: "out-of-sync", reason: "failed", outstanding: 0 }); - expect(syncHint(syncStatus(failed), failed, "me")).toBe("The last rebuild failed"); + expect(syncHint(syncStatus(failed), failed, "me")).toBe("The next edit will try again"); + expect(syncLabel({ kind: "out-of-sync", reason: "failed", outstanding: 0 })).toBe("Sync failed"); + expect(syncLabel({ kind: "out-of-sync", reason: "waiting", outstanding: 0 })) + .toBe("Out of sync"); }); it("needs attention while a blocked task waits for an edit, even in sync", () => { @@ -497,7 +503,7 @@ describe("living document sync", () => { let edited = live({ outstandingTasks, outOfSync: true }); expect(syncStatus(edited)).toEqual({ kind: "out-of-sync", reason: "pending", outstanding: 2 }); expect(syncHint(syncStatus(edited), edited, "me")) - .toBe("Changes will build shortly · will also retry 2 blocked tasks"); + .toBe("Your edits will sync shortly · will also retry 2 blocked tasks"); // Syncing beats needing attention. expect(syncStatus(live({ outstandingTasks, rebuild: rebuild("running") }))) .toEqual({ kind: "building" }); @@ -534,6 +540,27 @@ describe("living document sync", () => { expect(syncStatus(live({ rebuild: rebuild("failed") }))).toEqual({ kind: "in-sync" }); expect(syncHint({ kind: "in-sync" }, live(), "me")).toBeUndefined(); }); + + it("names every state with one sync vocabulary and a tooltip", () => { + expect(syncLabel({ kind: "in-sync" })).toBe("In sync"); + expect(syncLabel({ kind: "building" })).toBe("Syncing…"); + expect(syncTooltip({ kind: "in-sync" }, live(), "me")).toBe("Pull requests match the document"); + expect(syncTooltip({ kind: "building" }, live(), "me")) + .toBe("Updating pull requests to match the document"); + let pending = live({ outOfSync: true, rebuild: rebuild("stopped") }); + expect(syncTooltip(syncStatus(pending)!, pending, "me")).toBe("Your edits will sync shortly"); + }); + + it("splits the first build's tasks from those later syncs added", () => { + let tasks = [{ id: "workspace" }, { id: "rebuild-2-1" }, { id: "notes" }, { + id: "rebuild-3-1", + }]; + expect(liveTaskGroups(tasks)).toEqual({ + first: [{ id: "workspace" }, { id: "notes" }], + since: [{ id: "rebuild-2-1" }, { id: "rebuild-3-1" }], + }); + expect(liveTaskGroups([{ id: "workspace" }]).since).toEqual([]); + }); }); describe("living document commits", () => { diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index c4d7ffcca..f3f51047f 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -63,12 +63,13 @@ export function syncStatus(snapshot: Snapshot | undefined): SyncStatus | undefin }; } -export const SYNC_LABEL: Record = { - building: "Building…", - "in-sync": "In sync", - "needs-attention": "Needs attention", - "out-of-sync": "Out of sync", -}; +/** One vocabulary for a living document: in sync, out of sync, syncing, or a failed sync. */ +export function syncLabel(status: SyncStatus): string { + if (status.kind === "building") return "Syncing…"; + if (status.kind === "in-sync") return "In sync"; + if (status.kind === "needs-attention") return "Needs attention"; + return status.reason === "failed" ? "Sync failed" : "Out of sync"; +} /** Why the pull requests lag the document, naming the builder whose agent must run them. */ export function syncHint( @@ -81,8 +82,9 @@ export function syncHint( let retry = status.outstanding ? ` · will also retry ${plural(status.outstanding, "blocked task", "blocked tasks")}` : ""; - if (status.reason === "pending") return `Changes will build shortly${retry}`; - if (status.reason === "failed") return `The last rebuild failed${retry}`; + if (status.reason === "pending") return `Your edits will sync shortly${retry}`; + // Every edit schedules another sync, so a failure is retried by the next one. + if (status.reason === "failed") return `The next edit will try again${retry}`; if (snapshot?.live?.user === userId) return `Waiting for your agent${retry}`; return snapshot?.builtBy ? `Waiting for @${snapshot.builtBy}’s agent${retry}` @@ -102,6 +104,17 @@ export function attentionHint(snapshot: Snapshot | undefined): string | undefine return `${said}${/[?!…]$/.test(said) ? "" : "."} Edit the document to retry.`; } +/** The header's tooltip: what the sync status means, or why it lags. */ +export function syncTooltip( + status: SyncStatus, + snapshot: Snapshot | undefined, + userId: string | undefined, +): string { + if (status.kind === "in-sync") return "Pull requests match the document"; + if (status.kind === "building") return "Updating pull requests to match the document"; + return syncHint(status, snapshot, userId) ?? "Pull requests lag the document"; +} + /** Why a first build has not started yet, when the viewer's agent is finishing a prototype. */ export function startingHint(snapshot: Snapshot | undefined): string | undefined { if (snapshot?.build?.state !== "queued") return; @@ -125,6 +138,15 @@ export function startingLabel( return hint ? { label: "Queued", queued: true, hint } : { label: "Building…", queued: false }; } +/** + * A living document's tasks split at its first build: the tasks it started with, then the + * ones each later sync added. The server names a sync's tasks `rebuild--`. + */ +export function liveTaskGroups(tasks: T[]): { first: T[]; since: T[] } { + let since = tasks.filter(task => task.id.startsWith("rebuild-")); + return { first: tasks.filter(task => !since.includes(task)), since }; +} + /** One pull request's living-document commits, newest first. */ export function pullRequestCommits(snapshot: Snapshot | undefined, url: string) { return (snapshot?.live?.commits ?? []).map((commit, index) => ({ commit, index })) diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx index 7a5846c04..023782db1 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -7,9 +7,9 @@ import { draftRefusalCopy, firstBuildStep, startingLabel, - SYNC_LABEL, - syncHint, + syncLabel, syncStatus, + syncTooltip, waitingLabel, } from "./build-model"; import { @@ -148,16 +148,17 @@ export function BuildPlanButton( let sync = syncStatus(snapshot); if (sync) { - let hint = syncHint(sync, snapshot, userId); + let hint = syncTooltip(sync, snapshot, userId); return ( ); } @@ -202,6 +203,7 @@ export function BuildPlanButton( aria-busy="true" aria-description={hint} className="btn btn-compact btn-ghost shrink-0" + data-build-slot="" data-tooltip={hint} data-tooltip-detail={hint ? "" : undefined} data-tooltip-verbatim={hint ? "" : undefined} @@ -222,12 +224,16 @@ export function BuildPlanButton( <> {failure && {failure}} diff --git a/apps/web/src/build-view.css b/apps/web/src/build-view.css index b5d4c6664..715630943 100644 --- a/apps/web/src/build-view.css +++ b/apps/web/src/build-view.css @@ -47,6 +47,18 @@ font-weight: var(--font-weight-medium); } +/* A status dot stays on the first line when the reason wraps. */ +.build-status-line > .build-task-dot { + align-self: flex-start; + margin-block-start: calc((1lh - var(--spacing) * 3) / 2); +} + +/* The reason or count after a status, quieter than the status itself. */ +.build-status-detail { + color: var(--color-text-tertiary); + font-weight: var(--font-weight-normal); +} + .build-elapsed { flex: none; color: var(--color-text-tertiary); @@ -162,6 +174,8 @@ .build-tasks { display: grid; + /* An auto column grows to a long title's width and pushes capsules past the edge. */ + grid-template-columns: minmax(0, 1fr); margin: calc(var(--spacing) * 4) 0 0; padding: 0; list-style: none; @@ -295,6 +309,27 @@ list-style: disc; } +.build-task-summary { + margin: 0; + color: var(--color-text-primary); +} + +/* Tasks a living document's later syncs added, after the first build's. */ +.build-since { + margin-block-start: calc(var(--spacing) * 6); +} + +.build-since-heading { + margin: 0; + color: var(--color-text-tertiary); + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); +} + +.build-since .build-tasks { + margin-block-start: calc(var(--spacing) * 2); +} + .build-task-blocker { margin: 0; color: var(--color-warning-ink); diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index 038fd7d4e..4912c0371 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -1,3 +1,4 @@ +import { CheckIcon } from "@chopin/icons"; import { useEffect, useId, useRef, useState } from "react"; import { @@ -9,14 +10,15 @@ import { draftKey, draftRefusalCopy, elapsed, + liveTaskGroups, plural, pullRequestCommits, pullRequestNumber, shouldAutoDraft, startedBy, startingLabel, - SYNC_LABEL, syncHint, + syncLabel, TASK_STATE_LABEL, taskStartsOpen, } from "./build-model"; @@ -88,6 +90,7 @@ export function BuildView( let [open, setOpen] = useState>({}); let [linked, setLinked] = useState(); let [now, setNow] = useState(() => Date.now()); + let sinceHeading = useId(); let [request, setRequestState] = useState(() => requests.get(room)); let setRequest = (next: DraftRequest | undefined) => { if (next) requests.set(room, next); @@ -421,7 +424,7 @@ export function BuildView( {queued ? "Queued" : "Building"} {hint - ? {` · ${hint}`} + ? {` · ${hint}`} : who && ` · started by ${who === "you" ? who : `@${who}`}`} @@ -444,16 +447,26 @@ export function BuildView( } } else if (phase.kind === "live") { let commits = snapshot?.live?.commits.length ?? 0; - let hint = syncHint(phase.sync, snapshot, userId); + let detail = syncHint(phase.sync, snapshot, userId) + ?? (phase.sync.kind === "in-sync" && commits > 0 + ? `${plural(commits, "commit", "commits")} since first build` + : undefined); status = ( <> {phase.sync.kind === "building" &&