From 0d015a914d978a49e61b624cb381dc83029afca3 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:13:16 +0100 Subject: [PATCH 1/5] Make Build plan explicit and show build status in one capsule Opening the Build tab no longer drafts tasks; only Build plan in the header does. Build plan is always visible to writers, inert with its reason until it can run, then becomes a quiet capsule whose glyph is shared with the Build tab. The tab stays off until the plan has been built. Live builds report how many top-level blocks changed since the last build. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/main.ts | 14 +- apps/server/src/tasks/builds.ts | 5 +- apps/server/src/tasks/changes.test.ts | 30 ++ apps/server/src/tasks/changes.ts | 29 ++ apps/server/src/tasks/readiness.test.ts | 4 +- apps/server/src/tasks/readiness.ts | 3 +- apps/server/src/tasks/routes.ts | 9 +- apps/server/src/testing/plan.ts | 2 + apps/web/DESIGN.md | 2 +- apps/web/src/build-model.test.ts | 251 ++++++++------ apps/web/src/build-model.ts | 332 +++++++++++------- apps/web/src/build-plan-button.css | 156 +++++++++ apps/web/src/build-plan-button.tsx | 364 ++++++++++++++------ apps/web/src/build-view.tsx | 434 ++++++------------------ apps/web/src/decision-view-control.tsx | 39 ++- apps/web/src/phone-header.tsx | 5 + apps/web/src/room-workspace.tsx | 85 +++-- apps/web/src/workspace.tsx | 17 +- docs/implementation-lifecycle.md | 25 +- docs/local-launcher.md | 40 +-- e2e/build-seed.ts | 21 ++ e2e/database.ts | 1 + e2e/harness.e2e.ts | 74 ++-- e2e/implementation-launcher.e2e.ts | 134 +++++--- e2e/responsive-workspace.e2e.ts | 7 +- packages/icons/src/index.ts | 1 + packages/icons/src/progress.tsx | 34 ++ packages/protocol/implementation.d.ts | 2 + 28 files changed, 1258 insertions(+), 862 deletions(-) create mode 100644 apps/server/src/tasks/changes.test.ts create mode 100644 apps/server/src/tasks/changes.ts create mode 100644 apps/web/src/build-plan-button.css create mode 100644 e2e/build-seed.ts create mode 100644 packages/icons/src/progress.tsx diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 6c3896488..89cfebf88 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -2000,19 +2000,19 @@ function pastReadiness(channelId: string): boolean { } if (config.liveBuild) { buildReadiness = new BuildReadiness({ - current: currentDocumentTarget, + current: currentDocumentTarget, skip: pastReadiness, - ask: process.env.JEV_API_KEY || process.env.TYPESAFE_API_KEY - ? request => askJev(request) - : undefined, - changed: (channelId, planRevision, ready) => + ask: process.env.JEV_API_KEY || process.env.TYPESAFE_API_KEY + ? request => askJev(request) + : undefined, + changed: (channelId, planRevision, ready) => broadcast(server, channelId, { kind: "implementation:readiness", ts: 0, planRevision, ready, }), - error: err => console.error("chopin: build readiness failed -", err), + error: err => console.error("chopin: build readiness failed -", err), }); } if (config.agent && config.backgroundJobs) { @@ -2110,7 +2110,7 @@ registerMcpRoutes(router, hostedAuth, { withDocumentTransition(channelId, () => withDocumentLock(channelId, action)), }); implementations = registerImplementationRoutes(router, hostedAuth, { - buildable: (id, planRevision) => buildReadiness?.ready(id, planRevision) ?? false, + buildable: buildReadiness && buildReadiness.ready.bind(buildReadiness), liveBuild: !!config.liveBuild, connections: experiments.connections, busy: async connectionId => diff --git a/apps/server/src/tasks/builds.ts b/apps/server/src/tasks/builds.ts index 8f734c9e0..d48984323 100644 --- a/apps/server/src/tasks/builds.ts +++ b/apps/server/src/tasks/builds.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { changedBlocks } from "./changes"; import { approveGraph, validate } from "./graphs"; import { implementationReadiness } from "./plan-graphs"; import { @@ -893,6 +894,7 @@ export function liveSnapshot( let builder = viewer === live.user; let latest = plan.builds.findLast(build => build.kind === "rebuild"); let rebuild = latest && (builder ? structuredClone(latest) : withoutConnection(latest)); + let current = source(plan); // 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) : []; @@ -917,7 +919,8 @@ export function liveSnapshot( tasks, ...(unfinished.length ? { outstandingTasks: unfinished } : {}), ...(rebuild ? { rebuild } : {}), - outOfSync: source(plan) !== baseSource, + outOfSync: current !== baseSource, + changes: changedBlocks(baseSource, current), ...(builder ? { checkout: structuredClone(checkout), diff --git a/apps/server/src/tasks/changes.test.ts b/apps/server/src/tasks/changes.test.ts new file mode 100644 index 000000000..e4f9eb9bd --- /dev/null +++ b/apps/server/src/tasks/changes.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { changedBlocks } from "./changes"; + +const BASE = "# Snooze\n\nFive minutes.\n\nFifteen minutes.\n\nAn hour.\n"; + +describe("changedBlocks", () => { + test("counts nothing for the same source", () => { + expect(changedBlocks(BASE, BASE)).toBe(0); + }); + + test("counts an edited block once", () => { + expect(changedBlocks(BASE, BASE.replace("An hour.", "Two hours."))).toBe(1); + }); + + test("counts added and removed blocks", () => { + expect(changedBlocks(BASE, `${BASE}\nA day.\n\nA week.\n`)).toBe(2); + expect(changedBlocks(BASE, BASE.replace("Fifteen minutes.\n\n", ""))).toBe(1); + }); + + test("counts the larger side when edits and additions mix", () => { + let after = BASE.replace("Five minutes.", "Ten minutes.").replace("An hour.", "Two hours."); + expect(changedBlocks(BASE, `${after}\nA day.\n`)).toBe(3); + }); + + test("counts a moved block as unchanged but any difference as at least one", () => { + let moved = "# Snooze\n\nAn hour.\n\nFive minutes.\n\nFifteen minutes.\n"; + expect(changedBlocks(BASE, moved)).toBe(1); + expect(changedBlocks(BASE, `${BASE}\n`)).toBe(1); + }); +}); diff --git a/apps/server/src/tasks/changes.ts b/apps/server/src/tasks/changes.ts new file mode 100644 index 000000000..bb9d5a02b --- /dev/null +++ b/apps/server/src/tasks/changes.ts @@ -0,0 +1,29 @@ +import { parse } from "@chopin/dialect/parse"; +import { serialize } from "@chopin/dialect/serialize"; + +/** Each top-level block in its canonical form, or blank-line paragraphs when it cannot parse. */ +function blocks(source: string): string[] { + try { + return parse(source).children.map(node => serialize({ type: "root", children: [node] }).trim()); + } catch { + return source.split(/\n{2,}/).map(block => block.trim()).filter(Boolean); + } +} + +/** + * How many top-level blocks an edit added, removed or changed. A changed block is one removal and + * one addition, so the larger side counts. Any difference counts as at least one change. + */ +export function changedBlocks(before: string, after: string): number { + if (before === after) return 0; + let left = new Map(); + for (let block of blocks(before)) left.set(block, (left.get(block) ?? 0) + 1); + let added = 0; + for (let block of blocks(after)) { + let count = left.get(block) ?? 0; + if (count > 0) left.set(block, count - 1); + else added += 1; + } + let removed = [...left.values()].reduce((sum, count) => sum + count, 0); + return Math.max(added, removed, 1); +} diff --git a/apps/server/src/tasks/readiness.test.ts b/apps/server/src/tasks/readiness.test.ts index e8e005343..e9518ca99 100644 --- a/apps/server/src/tasks/readiness.test.ts +++ b/apps/server/src/tasks/readiness.test.ts @@ -170,14 +170,14 @@ describe("BuildReadiness", () => { expect(clock.queue[0]!.cancelled).toBe(true); }); - test("skipped documents are never judged", async () => { + test("skipped documents are never judged and read as ready", async () => { let asks: JevRequest[] = []; let readiness = new BuildReadiness({ current: async () => target(long), ask: jev(1, asks), skip: () => true, }); - expect(readiness.ready("doc", 1)).toBe(false); + expect(readiness.ready("doc", 1)).toBe(true); await Bun.sleep(0); expect(asks.length).toBe(0); }); diff --git a/apps/server/src/tasks/readiness.ts b/apps/server/src/tasks/readiness.ts index 2f512a865..c4321c83c 100644 --- a/apps/server/src/tasks/readiness.ts +++ b/apps/server/src/tasks/readiness.ts @@ -108,7 +108,8 @@ export class BuildReadiness { * its successor arrives, so a small edit does not hide a ready document. */ ready(channelId: string, revision: number): boolean { - if (this.#options.skip?.(channelId)) return false; + // A document past readiness was already judged buildable. + if (this.#options.skip?.(channelId)) return true; let judged = this.#judged.get(channelId); if (judged) { this.#judged.delete(channelId); diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index b41d6045b..1a61ae8fa 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -18,7 +18,10 @@ type Options = { connections: Connections; /** Whether an investigation is executing on a connection, in any document. */ busy?: (connectionId: string) => Promise; - /** Whether the document reads as ready to build, apart from its blockers. */ + /** + * Whether the document reads as ready to build, apart from its blockers. Without a judge, a + * document with no blockers is ready. + */ buildable?: (id: string, planRevision: number) => boolean; /** LIVE_BUILD: builds may queue behind a prototype or another document's build. */ liveBuild?: boolean; @@ -136,7 +139,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o : free; if (here) list.unshift(connection); else list.push(connection); - } + } return [...free, ...prototyping, ...building]; } router.on( @@ -177,7 +180,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o }), blockers: ready.ok ? [] : ready.blockers, localAgent, - buildReady: ready.ok && (options.buildable?.(id, plan.revision) ?? false), + buildReady: ready.ok && (options.buildable?.(id, plan.revision) ?? true), ...(waiting ? { waitingForPrototype: true as const } : {}), ...(ahead ? { waitingForDocument: aheadTitle ? { title: aheadTitle } : {} } : {}), ...(plan.buildRequested ? { buildRequested: { ...plan.buildRequested } } : {}), diff --git a/apps/server/src/testing/plan.ts b/apps/server/src/testing/plan.ts index 1bec9207b..9b82352d5 100644 --- a/apps/server/src/testing/plan.ts +++ b/apps/server/src/testing/plan.ts @@ -15,6 +15,7 @@ import type { SocketData } from "../wire"; export type SeedState = { graph?: import("../tasks/graphs").Graph; + builds?: import("@chopin/protocol/implementation").BuildRequest[]; revision?: number; questions?: unknown[]; openQuestions?: unknown[]; @@ -185,6 +186,7 @@ export async function checkpointSource( threads: state.threads ?? [], transcript: state.transcript ?? [], ...(state.graph ? { graph: state.graph } : {}), + ...(state.builds ? { builds: state.builds } : {}), } as JsonValue; let document = await Room.create(source); let canonical = Room.project(document); diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index a36fcd9d4..27e6f79c4 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -149,7 +149,7 @@ text only when people need it to act or recover. ## Layout -The workspace places Chat on the left and the document on the right when space permits. Chat sits directly on the ground with no bar of its own; the document is a raised sheet that rises to the top margin and carries two 40px bars. The title bar holds the expand control, the project and title with rename and the document actions menu, then status and presence, separated by full-height dividers. The views bar below it holds Document, Decisions, and Build on the paper, with no rule beneath. Expanding the document only widens the sheet: Chat collapses, the title stays, and a Chat button in the same place restores it. As the sheet narrows, the title truncates first, then status labels fold to glyphs, then presence keeps one face and a count. A child document zooms in place of its root: the title bar shows Back, the root as a crumb, and the child's own title and actions, and the views fold away because a child has none. Compact layouts and phones show the path in a slim stack bar instead. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; a table wider than the measure grows evenly about the column's centre, up to 1.5 times the prose measure (60.9375rem, about 975px) or the gutters, whichever is narrower, and scrolls in its own lane beyond that. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. +The workspace places Chat on the left and the document on the right when space permits. Chat sits directly on the ground with no bar of its own; the document is a raised sheet that rises to the top margin and carries two 40px bars. The title bar holds the expand control, the project and title with rename and the document actions menu, then status and presence, separated by full-height dividers. The views bar below it holds Document, Decisions, and Build on the paper, with no rule beneath. Expanding the document only widens the sheet: Chat collapses, the title stays, and a Chat button in the same place restores it. As the sheet narrows, the title truncates first, then status labels fold to glyphs, then presence keeps one face and a count. A child document zooms in place of its root: the title bar shows Back, the root as a crumb, and the child's own title and actions, and the views fold away because a child has none. Compact layouts and phones show the path in a slim stack bar instead. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Tabs never trigger actions: Build stays off until the plan is built, and only the header's **Build plan** starts a build. Build plan sits left of the people here and is always visible to writers, inert with its reason in a tooltip until it can run. After the press the same slot becomes a quiet capsule (glyph and words such as _Building · 3 of 8_, _Built_, _3 changes to build_) that opens Build, with at most one small outline action (_Retry_, _Build_). One shared glyph (progress ring, status dot, clock or warning) appears identically in the capsule and on the Build tab; hovering either lights the other, and on the first press the glyph travels to the tab unless reduced motion is on. On a phone the capsule keeps only its glyph and Build's headline carries the words. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; a table wider than the measure grows evenly about the column's centre, up to 1.5 times the prose measure (60.9375rem, about 975px) or the gutters, whichever is narrower, and scrolls in its own lane beyond that. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes. Spacing starts from a 4px unit and uses the measured steps shown in the frontmatter. App chrome is dense; prose has larger margins and trailing space so the caret remains comfortable near the end of a document. Layout responds to both viewport and document container width. Safe-area insets and larger coarse-pointer targets are accounted for in the web styles. diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index 1f7645c42..bd38cccd2 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -1,25 +1,23 @@ import { describe, expect, it } from "bun:test"; import { - advanceDraft, advanceFirstBuild, ago, attentionHint, + buildLabel, + buildLook, buildPhase, - draftKey, + buildTabReady, draftRefusalCopy, elapsed, firstBuildStep, pullRequestCommits, pullRequestNumber, - shouldAutoDraft, startedBy, startingHint, - startingLabel, syncHint, syncStatus, taskStartsOpen, - waitingLabel, } from "./build-model"; import type { @@ -166,13 +164,6 @@ describe("buildPhase", () => { }); }); -describe("drafting", () => { - it("keys one automatic request per document, revision and reason", () => { - expect(draftKey("room", 4, "prepare")).toBe("room:4:prepare"); - expect(draftKey("room", 4, "revise")).not.toBe(draftKey("room", 5, "revise")); - }); -}); - describe("presentation", () => { it("reads pull request numbers from their URLs", () => { expect(pullRequestNumber("https://github.com/o/r/pull/412")).toBe(412); @@ -198,77 +189,6 @@ describe("presentation", () => { }); }); -describe("automatic drafting", () => { - let ready = { - active: true, - canDraft: true, - chatLoaded: true, - plannerBusy: false, - connected: true, - key: "room:4:prepare", - eligible: "room:4:prepare", - autoSent: false, - alreadyDrafted: false, - request: undefined, - }; - - it("drafts once for the need the first read of this visit saw", () => { - expect(shouldAutoDraft(ready)).toBe(true); - expect(shouldAutoDraft({ ...ready, autoSent: true })).toBe(false); - expect(shouldAutoDraft({ ...ready, alreadyDrafted: true })).toBe(false); - // A collaborator's edit while Build stays open changes the need, not the decision. - expect(shouldAutoDraft({ ...ready, key: "room:5:revise" })).toBe(false); - expect(shouldAutoDraft({ ...ready, eligible: undefined })).toBe(false); - }); - - it("waits for Chat, the socket and an idle Planner, and for any request in flight", () => { - expect(shouldAutoDraft({ ...ready, chatLoaded: false })).toBe(false); - expect(shouldAutoDraft({ ...ready, connected: false })).toBe(false); - expect(shouldAutoDraft({ ...ready, plannerBusy: true })).toBe(false); - expect(shouldAutoDraft({ ...ready, request: { key: "room:4:prepare", state: "queued" } })) - .toBe(false); - expect(shouldAutoDraft({ ...ready, request: { key: "room:4:prepare", state: "failed" } })) - .toBe(true); - }); - - it("follows only its own request from answer to end", () => { - let sending = { key: "k", state: "sending" as const }; - let queued = advanceDraft(sending, { type: "reply", id: "d1", state: "queued" })!; - expect(queued).toEqual({ key: "k", id: "d1", state: "queued" }); - // Another request's turn says nothing about this one. - expect(advanceDraft(queued, { type: "drafting", id: "d2", state: "ended" })).toBeUndefined(); - let running = advanceDraft(queued, { type: "drafting", id: "d1", state: "running" })!; - expect(running.state).toBe("running"); - expect(advanceDraft(running, { type: "drafting", id: "d1", state: "ended" })?.state) - .toBe("ended"); - expect(advanceDraft(undefined, { type: "busy", busy: false })).toBeUndefined(); - }); - - it("ends an acknowledged running request on an idle Planner, busy or not", () => { - let running = { key: "k", id: "d1", state: "running" as const }; - expect(advanceDraft(running, { type: "busy", busy: false })?.state).toBe("ended"); - // Not before the server has answered: the request might not have started. - expect(advanceDraft({ key: "k", state: "sending" }, { type: "busy", busy: false })) - .toBeUndefined(); - let sending = { key: "k", state: "sending" as const }; - expect(advanceDraft(sending, { type: "reply", id: "d1", state: "ended" })?.state) - .toBe("ended"); - }); - - it("fails a refused request, and ends a queued one on an idle Planner after seeing it busy", () => { - let sending = { key: "k", state: "sending" as const }; - expect(advanceDraft(sending, { type: "refused" })?.state).toBe("failed"); - let queued = { key: "k", id: "d1", state: "queued" as const }; - expect(advanceDraft(queued, { type: "busy", busy: false })).toBeUndefined(); - let seen = advanceDraft(queued, { type: "busy", busy: true })!; - expect(seen.busySeen).toBe(true); - expect(advanceDraft(seen, { type: "busy", busy: false })?.state).toBe("ended"); - // A late answer cannot move a request that has already finished. - expect(advanceDraft({ ...seen, state: "ended" }, { type: "reply", id: "d1", state: "queued" })) - .toBeUndefined(); - }); -}); - describe("attribution", () => { it("names the viewer as you and anyone else by login", () => { expect(startedBy(snapshot({ build: build("running") }), "u")).toBe("you"); @@ -296,18 +216,19 @@ describe("first build", () => { let idle = { stage: "idle" } as const; let fresh = snapshot({ graph: undefined }); - it("offers the button only for a ready document that was never built", () => { - expect(firstBuildStep(fresh, idle)).toEqual({ view: "ready" }); - expect(firstBuildStep(snapshot(), idle)).toEqual({ view: "ready" }); - expect(firstBuildStep(undefined, idle)).toEqual({ view: "hidden" }); - expect(firstBuildStep(snapshot({ graph: undefined, buildReady: false }), idle).view) - .toBe("hidden"); - expect(firstBuildStep(snapshot({ blockers: ["unanswered questionnaires"] }), idle).view) - .toBe("hidden"); - expect(firstBuildStep(snapshot({ build: build("failed") }), idle).view).toBe("hidden"); + it("stays idle until pressed, and reads a running build as working", () => { + expect(firstBuildStep(fresh, idle)).toEqual({ view: "idle" }); + expect(firstBuildStep(snapshot(), idle)).toEqual({ view: "idle" }); + expect(firstBuildStep(undefined, idle)).toEqual({ view: "idle" }); + expect(firstBuildStep(snapshot({ build: build("failed") }), idle).view).toBe("idle"); expect(firstBuildStep(snapshot({ build: build("running") }), idle).view).toBe("working"); }); + it("retries a build that failed before it claimed its tasks from the same tasks", () => { + let state = advanceFirstBuild(idle, { type: "press" }); + expect(firstBuildStep(snapshot({ build: build("failed") }), state).next).toBe("start"); + }); + it("drafts, then starts the drafted tasks without a review step", () => { let state = advanceFirstBuild(idle, { type: "press" }); expect(firstBuildStep(fresh, state)).toEqual({ view: "working", next: "draft" }); @@ -338,7 +259,7 @@ describe("first build", () => { expect(advanceFirstBuild(sent, { type: "draft-ended", drafted: true })).toBe(sent); let failed = advanceFirstBuild(sent, { type: "draft-ended", drafted: false }); expect(failed.stage).toBe("failed"); - expect(firstBuildStep(fresh, failed)).toEqual({ view: "ready" }); + expect(firstBuildStep(fresh, failed)).toEqual({ view: "idle" }); expect(advanceFirstBuild(failed, { type: "press" })).toEqual({ stage: "drafting", sent: false, @@ -347,7 +268,7 @@ describe("first build", () => { let starting = advanceFirstBuild(sent, { type: "start" }); let noAgent = advanceFirstBuild(starting, { type: "start-refused", agent: true }); expect(noAgent).toEqual({ stage: "failed", agent: true, message: undefined }); - expect(firstBuildStep(snapshot(), noAgent)).toEqual({ view: "ready" }); + expect(firstBuildStep(snapshot(), noAgent)).toEqual({ view: "idle" }); }); it("waits through a decision the draft raised, then redrafts and starts", () => { @@ -362,7 +283,6 @@ describe("first build", () => { // The draft turn ending while blocked is not a failure. expect(advanceFirstBuild(state, { type: "draft-ended", drafted: false })).toBe(state); expect(firstBuildStep(blocked, state, "me")).toEqual({ view: "waiting" }); - expect(waitingLabel(blocked)).toEqual({ label: "Waiting on a decision", target: "decisions" }); // Answering moved the document on, so the tasks are stale: draft them again. let answered = snapshot({ planRevision: 5, ...requested }); expect(firstBuildStep(answered, state, "me")).toEqual({ view: "working", next: "draft" }); @@ -379,7 +299,7 @@ describe("first build", () => { expect(firstBuildStep(snapshot(requested), idle, "me")) .toEqual({ view: "working", next: "resume" }); // Someone else's request, or one for a document since built, is not this viewer's to resume. - expect(firstBuildStep(blocked, idle, "you").view).toBe("hidden"); + expect(firstBuildStep(blocked, idle, "you").view).toBe("idle"); expect( firstBuildStep( { @@ -395,7 +315,7 @@ describe("first build", () => { expect(firstBuildStep(snapshot(requested), state, "me").next).toBe("start"); // Cancelled: the request is gone, so the wait ends. expect(firstBuildStep(snapshot({ blockers: ["unanswered questionnaires"] }), state, "me")) - .toEqual({ view: "hidden", next: "reset" }); + .toEqual({ view: "idle", next: "reset" }); }); }); @@ -444,11 +364,7 @@ describe("living document sync", () => { it("explains a first build queued behind another document's build", () => { let queued = snapshot({ build: build("queued"), waitingForDocument: { title: "Docs site" } }); - expect(startingLabel(queued)).toEqual({ - label: "Queued", - queued: true, - hint: "Starts when your agent finishes “Docs site”", - }); + expect(startingHint(queued)).toBe("Starts when your agent finishes “Docs site”"); expect(startingHint(snapshot({ build: build("queued"), waitingForDocument: {} }))) .toBe("Starts when your agent finishes another document"); }); @@ -457,14 +373,6 @@ describe("living document sync", () => { let queued = snapshot({ build: build("queued"), waitingForPrototype: true }); expect(firstBuildStep(queued, { stage: "idle" }).view).toBe("working"); expect(startingHint(queued)).toBe("Starts when the current prototype finishes"); - expect(startingLabel(queued)).toEqual({ - label: "Queued", - queued: true, - hint: "Starts when the current prototype finishes", - }); - expect(startingLabel(snapshot({ build: build("queued") }))) - .toEqual({ label: "Building…", queued: false }); - expect(startingLabel(undefined)).toEqual({ label: "Building…", queued: false }); expect(startingHint(snapshot({ build: build("queued") }))).toBeUndefined(); expect(startingHint(snapshot({ build: build("running"), waitingForPrototype: true }))) .toBeUndefined(); @@ -567,3 +475,126 @@ describe("living document commits", () => { expect(ago("nonsense", now)).toBeUndefined(); }); }); + +describe("build status", () => { + let context = { + state: { stage: "idle" } as const, + userId: "u", + live: false, + canEdit: true, + connected: true, + planner: true, + hasContent: true, + unanswered: 0, + }; + let look = (value: ImplementationSnapshot | undefined, change = {}) => + buildLook(value, { ...context, ...change }); + + it("offers Build plan before the first build, inert with its reason", () => { + expect(look(snapshot())).toEqual({ kind: "plan", blocked: undefined }); + expect(look(snapshot(), { hasContent: false })).toEqual({ + kind: "plan", + blocked: "Write the document first", + }); + expect(look(snapshot({ blockers: ["unanswered questionnaires"] }), { unanswered: 2 })) + .toEqual({ kind: "plan", blocked: "Answer 2 open decisions first" }); + expect(look(snapshot({ blockers: ["accepted comments awaiting plan changes"] })).kind) + .toBe("plan"); + expect(look(snapshot({ buildReady: false }))).toEqual({ + kind: "plan", + blocked: "Add more detail before building", + }); + expect(look(snapshot({ graph: undefined }), { planner: false })).toEqual({ + kind: "plan", + blocked: "Chopin isn’t running for this document", + }); + expect(look(undefined)).toEqual({ kind: "plan", blocked: "Chopin isn’t connected" }); + }); + + it("breaks into tasks once pressed, then counts tasks with pull requests", () => { + let pressed = { stage: "drafting", sent: true } as const; + expect(look(snapshot({ graph: undefined }), { state: pressed }).kind).toBe("tasks"); + let running = snapshot({ + build: build("running"), + lifecycle: { + execution: { state: "active" } as ImplementationSnapshot["lifecycle"]["execution"], + history: [], + activity: { + tasks: [ + { id: "a", state: "completed", pullRequest: { url: PR, state: "open" } }, + { id: "b", state: "in_progress" }, + { id: "c", state: "queued" }, + ], + events: [], + } as unknown as ImplementationSnapshot["lifecycle"]["activity"], + }, + }); + let building = look(running); + expect(building).toMatchObject({ kind: "building", done: 1, total: 3 }); + expect(buildLabel(building)).toBe("Building · 1 of 3"); + }); + + it("reads a delivered build as built, and a stopped first build as stopped with Retry", () => { + let done = snapshot({ + build: build("stopped"), + lifecycle: { execution: { state: "idle" }, history: [run({ kind: "implemented" }, 2)] }, + }); + expect(look(done)).toMatchObject({ kind: "built", pullRequests: 2 }); + let failed = look(snapshot({ build: build("failed") })); + expect(failed).toMatchObject({ kind: "stopped", retry: true }); + expect(buildLabel(failed)).toBe("Build stopped"); + expect(look(snapshot({ build: build("failed") }), { canEdit: false })) + .toMatchObject({ retry: false }); + let noAgent = look(snapshot(), { state: { stage: "failed", agent: true } }); + expect(noAgent).toEqual({ + kind: "stopped", + tip: "Start your local agent, then retry", + retry: true, + }); + }); + + it("counts changes to build since the last build, and waits for edits to settle when live", () => { + let stale = live({ outOfSync: true, changes: 3, rebuild: rebuild("stopped") }); + let manual = look(stale); + expect(manual).toMatchObject({ kind: "stale", changes: 3, live: false }); + expect(buildLabel(manual)).toBe("3 changes to build"); + expect(manual).toMatchObject({ tip: "Edits since the last build" }); + expect(buildLabel(look(live({ outOfSync: true, changes: 1 })))).toBe("1 change to build"); + expect(look(stale, { live: true })).toMatchObject({ + kind: "stale", + live: true, + tip: "Builds when editing settles", + }); + let theirs = { ...stale, builtBy: "maggie" }; + expect(look(theirs, { userId: "other", live: true })) + .toMatchObject({ tip: "Builds on @maggie’s agent when editing settles" }); + expect(look(theirs, { userId: "other" })).toMatchObject({ + tip: "Edits since @maggie built it", + }); + }); + + it("reads a rebuild as building changes, and a failed one as failed", () => { + let running = look(live({ outOfSync: true, rebuild: rebuild("running") })); + expect(buildLabel(running)).toBe("Building changes…"); + let failed = look(live({ outOfSync: true, rebuild: rebuild("failed") })); + expect(buildLabel(failed)).toBe("Build failed"); + expect(buildLabel(look(live()))).toBe("Built"); + expect(look({ ...live(), builtBy: "maggie" }, { userId: "other" })) + .toMatchObject({ tip: "Every pull request matches the document · built by @maggie" }); + }); + + it("opens Build only once there is a build or a press under way", () => { + expect(buildTabReady(snapshot(), look(snapshot()))).toBe(false); + let pressed = { stage: "drafting", sent: false } as const; + expect(buildTabReady(snapshot(), look(snapshot(), { state: pressed }))).toBe(true); + let returned = snapshot({ + build: build("stopped"), + lifecycle: { + execution: { state: "idle" }, + history: [run({ kind: "revision_requested", reason: "Change it" })], + }, + }); + expect(look(returned).kind).toBe("plan"); + expect(buildTabReady(returned, look(returned))).toBe(true); + }); +}); diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index 645da34a6..3e9fb95fb 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -63,13 +63,6 @@ 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", -}; - /** Why the pull requests lag the document, naming the builder whose agent must run them. */ export function syncHint( status: SyncStatus | undefined, @@ -114,17 +107,6 @@ export function startingHint(snapshot: Snapshot | undefined): string | undefined return snapshot.waitingForPrototype ? "Starts when the current prototype finishes" : undefined; } -/** - * The header slot while a first build is under way: `queued` while it waits for - * the agent's prototype, so the wait reads without hovering for the hint. - */ -export function startingLabel( - snapshot: Snapshot | undefined, -): { label: string; queued: boolean; hint?: string } { - let hint = startingHint(snapshot); - return hint ? { label: "Queued", queued: true, hint } : { label: "Building…", queued: false }; -} - /** 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 })) @@ -191,15 +173,6 @@ export function buildPhase(snapshot: Snapshot | undefined): BuildPhase { return { kind: "review" }; } -/** One automatic Planner request per document, revision and reason in this page. */ -export function draftKey( - channel: string, - planRevision: number, - draft: "prepare" | "revise" | "returned", -): string { - return `${channel}:${planRevision}:${draft}`; -} - export function pullRequestNumber(url: string): number | undefined { let match = url.match(/\/pull\/(\d+)(?:[/?#]|$)/); return match ? Number(match[1]) : undefined; @@ -239,82 +212,6 @@ export function startedBy(snapshot: Snapshot | undefined, userId: string): strin return snapshot.startedBy; } -/** A Planner request for tasks, from sending until its turn has ended. */ -export type DraftRequest = { - key: string; - /** The server's id for the request, once it answers. */ - id?: string; - state: "sending" | "queued" | "running" | "ended" | "failed"; - /** Chat was seen busy while this request was live. */ - busySeen?: boolean; -}; - -export function draftInFlight(request: DraftRequest | undefined): boolean { - return request?.state === "sending" || request?.state === "queued" - || request?.state === "running"; -} - -export type DraftEvent = - | { type: "reply"; id: string; state: "queued" | "running" | "ended" } - | { type: "drafting"; id: string; state: "queued" | "running" | "ended" } - | { type: "busy"; busy: boolean } - | { type: "refused" }; - -/** - * Follow one request through its own turn; `undefined` when nothing changes. - * - * Only events naming this request's id move it, so another turn ending never - * reads as this one failing. Chat going idle is a fallback for an `ended` - * broadcast missed across a reconnect: at once for a running request the - * server has acknowledged, and for a queued one only after it was seen busy. - */ -export function advanceDraft( - request: DraftRequest | undefined, - event: DraftEvent, -): DraftRequest | undefined { - if (!request) return; - if (event.type === "refused") { - return request.state === "sending" ? { ...request, state: "failed" } : undefined; - } - if (event.type === "reply") { - if (request.state !== "sending") return; - return { ...request, id: event.id, state: event.state }; - } - if (event.type === "drafting") { - if (!request.id || event.id !== request.id || !draftInFlight(request)) return; - return event.state === request.state ? undefined : { ...request, state: event.state }; - } - if (request.state !== "queued" && request.state !== "running") return; - if (event.busy) return request.busySeen ? undefined : { ...request, busySeen: true }; - return request.busySeen || request.state === "running" - ? { ...request, state: "ended" } - : undefined; -} - -/** - * Whether opening Build should ask for tasks now. - * - * Only the drafting need seen by the first read after entering Build counts, so - * a collaborator's edit while the view stays open never starts another turn. - */ -export function shouldAutoDraft(input: { - active: boolean; - canDraft: boolean; - chatLoaded: boolean; - plannerBusy: boolean; - connected: boolean; - key: string | undefined; - /** The key the first read of this activation needed, if it needed one. */ - eligible: string | undefined; - autoSent: boolean; - alreadyDrafted: boolean; - request: DraftRequest | undefined; -}): boolean { - return input.active && input.canDraft && input.chatLoaded && !input.plannerBusy - && input.connected && !!input.key && input.key === input.eligible && !input.autoSent - && !input.alreadyDrafted && !draftInFlight(input.request); -} - /** * Plain copy for a refusal the server explains, or `undefined` for one it does * not, which reads as Chopin failing to draft. @@ -406,25 +303,27 @@ export function advanceFirstBuild(state: FirstBuild, event: FirstBuildEvent): Fi } /** - * What the header shows for a snapshot and the first build's stage, and the - * one step the browser should take next, if any. + * Whether Build plan's press is still under way, and the one step the browser + * should take next, if any. * - * The button appears only for a document judged ready that has never been - * built. Once pressed it reads as working until the build runs or the attempt - * ends; `reset` hands any later state back to the Build view. A draft that - * raises a decision does not drop the press: the slot reads `waiting` until the - * blockers clear, then drafts again if the tasks are stale and starts them. The - * server keeps the press (`buildRequested`), so a reload `resume`s it. + * Once pressed it reads as `working` until the build runs or the attempt ends; + * `reset` hands any later state back to the snapshot. A draft that raises a + * decision does not drop the press: it reads `waiting` until the blockers + * clear, then drafts again if the tasks are stale and starts them. The server + * keeps the press (`buildRequested`), so a reload `resume`s it. A build that + * failed before it claimed its tasks starts again from the same tasks. */ export function firstBuildStep( snapshot: Snapshot | undefined, state: FirstBuild, userId?: string, ): { - view: "hidden" | "ready" | "working" | "waiting"; + view: "idle" | "working" | "waiting"; next?: "draft" | "start" | "reset" | "resume" | "wait"; } { - if (!snapshot) return { view: state.stage === "idle" ? "hidden" : "working" }; + if (!snapshot) { + return { view: state.stage === "idle" || state.stage === "failed" ? "idle" : "working" }; + } let phase = buildPhase(snapshot); if (phase.kind === "building") { return { view: "working", ...(state.stage === "idle" ? {} : { next: "reset" as const }) }; @@ -434,40 +333,209 @@ export function firstBuildStep( if (state.stage === "idle" && requested && pending) { return { view: phase.kind === "blocked" ? "waiting" : "working", next: "resume" }; } - if (state.stage === "idle" || state.stage === "failed") { - let open = phase.kind === "drafting" || phase.kind === "review"; - return { view: snapshot.buildReady && open && !hasBuilt(snapshot) ? "ready" : "hidden" }; - } + if (state.stage === "idle" || state.stage === "failed") return { view: "idle" }; if (state.stage === "starting") return { view: "working" }; if (state.stage === "started") { - return phase.kind === "review" ? { view: "working" } : { view: "hidden", next: "reset" }; + return phase.kind === "review" ? { view: "working" } : { view: "idle", next: "reset" }; } if (state.stage === "waiting") { // Cancelled, or someone else built it: nothing is left to continue. - if (!requested) return { view: "hidden", next: "reset" }; + if (!requested) return { view: "idle", next: "reset" }; if (phase.kind === "blocked") return { view: "waiting" }; if (phase.kind === "drafting") return { view: "working", next: "draft" }; if (phase.kind === "review") return { view: "working", next: "start" }; - return { view: "hidden", next: "reset" }; + return { view: "idle", next: "reset" }; } - if (phase.kind === "review") return { view: "working", next: "start" }; + if (phase.kind === "review" || phase.kind === "failed") return { view: "working", next: "start" }; if (phase.kind === "drafting") { return { view: "working", ...(state.sent ? {} : { next: "draft" }) }; } if (phase.kind === "blocked") return { view: "waiting", next: "wait" }; - return { view: "hidden", next: "reset" }; + return { view: "idle", next: "reset" }; } -/** The header's wait for a blocked one-click build, and where pressing it leads. */ -export function waitingLabel( - snapshot: Snapshot | undefined, -): { label: string; target: "decisions" | "build" } { - let phase = snapshot && buildPhase(snapshot); - if (phase?.kind === "blocked" && phase.decisions) { - return { label: "Waiting on a decision", target: "decisions" }; +/** + * What the header's build slot, the Build tab's glyph and Build's headline show. + * + * `plan` is the Build plan button, inert with `blocked` as its reason. Every + * other kind is the status capsule that replaces it after the first press. + * `retry` and `build` say whether this viewer can act from the capsule. + */ +export type BuildLook = + | { kind: "plan"; blocked?: string } + | { kind: "tasks"; tip: string } + | { kind: "building"; done: number; total: number; tip?: string } + | { kind: "stopped"; tip: string; retry: boolean } + | { kind: "built"; pullRequests: number; tip: string } + | { kind: "stale"; changes: number; live: boolean; tip: string; build: boolean } + | { kind: "rebuilding"; tip: string } + | { kind: "failed"; tip: string; retry: boolean }; + +export type BuildContext = { + state: FirstBuild; + userId?: string; + /** LIVE_BUILD: edits build themselves once editing settles. */ + live: boolean; + canEdit: boolean; + connected: boolean; + planner: boolean; + hasContent: boolean; + unanswered: number; +}; + +/** Tasks of the current build that have a pull request, out of all of them. */ +function progressCount(snapshot: Snapshot): { done: number; total: number } { + let tasks = buildProgress(snapshot)?.tasks ?? []; + let total = Math.max(tasks.length, snapshot.graph?.definition.tasks.length ?? 0, 1); + return { done: tasks.filter(task => task.state === "completed").length, total }; +} + +/** Why Build plan cannot be pressed yet, or nothing when it can. */ +function planBlocker(snapshot: Snapshot | undefined, context: BuildContext): string | undefined { + if (!context.hasContent) return "Write the document first"; + if (!context.connected || !snapshot) return "Chopin isn’t connected"; + let phase = buildPhase(snapshot); + if (phase.kind === "blocked" && phase.decisions) { + return context.unanswered > 0 + ? `Answer ${plural(context.unanswered, "open decision", "open decisions")} first` + : "Answer the open decisions first"; + } + if (phase.kind === "blocked" && phase.comments) { + return "Update the document for accepted comments first"; + } + if (phase.kind === "blocked") return "This document can’t be built yet"; + if (phase.kind === "drafting" && !context.planner) { + return "Chopin isn’t running for this document"; + } + if (!snapshot.buildReady) return "Add more detail before building"; +} + +function builder(snapshot: Snapshot, userId: string | undefined): string | undefined { + if (!snapshot.live || snapshot.live.user === userId) return; + return snapshot.builtBy ? `@${snapshot.builtBy}` : "the builder"; +} + +export function buildLook(snapshot: Snapshot | undefined, context: BuildContext): BuildLook { + let step = firstBuildStep(snapshot, context.state, context.userId); + let sync = syncStatus(snapshot); + if (snapshot && sync) { + let who = builder(snapshot, context.userId); + let rebuilding = !!snapshot.live?.rebuild + && ["queued", "starting", "running"].includes(snapshot.live.rebuild.state); + if (sync.kind === "building" && rebuilding) { + return { kind: "rebuilding", tip: "Committing the edits to the existing pull requests" }; + } + if (sync.kind === "building") { + let { done, total } = progressCount(snapshot); + return { kind: "building", done, total, tip: `${done} of ${total} tasks built` }; + } + if (sync.kind === "in-sync") { + return { + kind: "built", + pullRequests: snapshot.live!.pullRequests.length, + tip: `Every pull request matches the document${who ? ` · built by ${who}` : ""}`, + }; + } + if (sync.kind === "needs-attention") { + return { + kind: "failed", + tip: attentionHint(snapshot) ?? "The last build didn’t finish", + retry: false, + }; + } + if (sync.reason === "failed") { + return { + kind: "failed", + tip: "The last build didn’t finish. Edit the document to retry.", + retry: false, + }; + } + let changes = Math.max(snapshot.live!.changes ?? 1, 1); + let tip = context.live + ? sync.reason === "waiting" + ? who ? `Waiting for ${who}’s agent` : "Waiting for your agent" + : who + ? `Builds on ${who}’s agent when editing settles` + : "Builds when editing settles" + : who + ? `Edits since ${who} built it` + : "Edits since the last build"; + return { kind: "stale", changes, live: context.live, tip, build: false }; } - if (phase?.kind === "blocked" && phase.comments) { - return { label: "Waiting on comments", target: "build" }; + if (!snapshot) { + return step.view === "idle" ? { kind: "plan", blocked: "Chopin isn’t connected" } : { + kind: "tasks", + tip: "Breaking the document into tasks", + }; } - return { label: "Waiting to build", target: "build" }; + let phase = buildPhase(snapshot); + if (phase.kind === "building") { + let { done, total } = progressCount(snapshot); + let tip = startingHint(snapshot); + return { + kind: "building", + done, + total, + tip: tip ?? `${done} of ${total} tasks built`, + }; + } + if (phase.kind === "done") { + return { + kind: "built", + pullRequests: phase.pullRequests, + tip: "Every task has a pull request", + }; + } + if (phase.kind === "stopped") { + return { + kind: "stopped", + tip: "A task is blocked. Open Build to return it for changes.", + retry: false, + }; + } + if (step.view === "working") return { kind: "tasks", tip: "Breaking the document into tasks" }; + if (step.view === "waiting") { + return { kind: "tasks", tip: "Continues once the open decisions are answered" }; + } + let retry = context.canEdit && context.connected; + if (context.state.stage === "failed") { + return { + kind: "stopped", + tip: context.state.agent + ? "Start your local agent, then retry" + : context.state.message ?? "The build stopped before it started", + retry, + }; + } + if (phase.kind === "failed") { + return { kind: "stopped", tip: "The build stopped. Your agent may not be running.", retry }; + } + return { kind: "plan", blocked: planBlocker(snapshot, context) }; +} + +/** The capsule's words; Build plan has none of its own. */ +export function buildLabel(look: BuildLook): string { + switch (look.kind) { + case "plan": + return "Build plan"; + case "tasks": + return "Breaking into tasks…"; + case "building": + return `Building · ${look.done} of ${look.total}`; + case "stopped": + return "Build stopped"; + case "built": + return "Built"; + case "stale": + return `${plural(look.changes, "change", "changes")} to build`; + case "rebuilding": + return "Building changes…"; + case "failed": + return "Build failed"; + } +} + +/** Build opens once there is a build, or a press under way, to show. */ +export function buildTabReady(snapshot: Snapshot | undefined, look: BuildLook): boolean { + return look.kind !== "plan" || !!snapshot && hasBuilt(snapshot); } diff --git a/apps/web/src/build-plan-button.css b/apps/web/src/build-plan-button.css new file mode 100644 index 000000000..d7647b257 --- /dev/null +++ b/apps/web/src/build-plan-button.css @@ -0,0 +1,156 @@ +/* Build plan, the build capsule that replaces it, and the glyph they share with the Build tab. */ + +.build-plan[aria-disabled="true"] { + cursor: default; + opacity: 0.4; + box-shadow: none; +} + +.build-capsule-row { + display: inline-flex; + flex: none; + align-items: center; + gap: calc(var(--spacing) * 1.5); + animation: build-fade var(--duration-base) var(--ease-out); +} + +.build-capsule { + display: inline-flex; + align-items: center; + gap: var(--spacing); + height: var(--control-height-compact); + padding-inline: calc(var(--spacing) * 2) calc(var(--spacing) * 2.5); + border-radius: var(--radius-full); + background: var(--color-inset); + font-size: var(--text-xs); + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--color-text-secondary); + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.build-capsule:hover, +.build-capsule[data-hot] { + background: var(--color-selected); + color: var(--color-text-primary); +} + +/* Phone: the glyph alone, without the pill, so it reads as status rather than a control. */ +.build-capsule[data-compact] { + justify-content: center; + width: var(--control-height-compact); + padding: 0; + background: transparent; +} + +.build-capsule-glyph { + display: inline-grid; +} + +.build-capsule-label, +.build-capsule-action { + animation: build-fade var(--duration-base) var(--ease-out); +} + +@keyframes build-fade { + from { + opacity: 0; + } +} + +.build-glyph { + display: grid; + flex: none; + place-items: center; + width: calc(var(--spacing) * 3.5); + height: calc(var(--spacing) * 3.5); + animation: build-fade var(--duration-base) var(--ease-out); +} + +.build-glyph-dot { + width: calc(var(--spacing) * 2.25); + height: calc(var(--spacing) * 2.25); + border-radius: var(--radius-full); + background: var(--color-neutral-graphic); +} + +.build-glyph-dot[data-tone="success"] { + background: var(--color-success-graphic); +} + +.build-glyph-dot[data-tone="warning"] { + background: var(--color-warning-graphic); +} + +.build-glyph > svg { + width: 100%; + height: 100%; +} + +.build-glyph [data-progress-track] { + stroke: var(--color-selected-strong); +} + +.build-glyph [data-progress-arc] { + stroke: var(--color-brand); + transition: stroke-dasharray var(--duration-linger) var(--motion-smooth-out); +} + +.build-glyph-spin { + width: 100%; + height: 100%; + color: var(--color-brand); + animation: button-loader-spin calc(var(--duration-base) * 4) linear infinite; +} + +.build-glyph-warning { + width: 100%; + height: 100%; + color: var(--color-warning-icon); +} + +.build-glyph-quiet { + width: 100%; + height: 100%; + color: var(--color-text-tertiary); +} + +/* The Build tab's twin glyph. It hides while the hand-off glyph lands on it. */ +.build-tab-glyph { + display: inline-grid; + transition: opacity var(--duration-fast) var(--ease-out); +} + +.build-tab-glyph[data-landing] { + opacity: 0; +} + +/* Linked hover: the other end of the link lights up without taking the selection. */ +.build-tab[data-hot]:not([aria-pressed="true"]) { + background: var(--color-hover); + color: var(--color-text-primary); +} + +.build-glyph-flyer { + position: fixed; + inset: 0 auto auto 0; + z-index: 1000; + pointer-events: none; + will-change: transform; +} + +@media (prefers-reduced-motion: reduce) { + .build-capsule-row, + .build-capsule-label, + .build-capsule-action, + .build-glyph { + animation: none; + } + + .build-glyph [data-progress-arc] { + transition: none; + } +} diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx index 7a5846c04..026215eeb 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -1,16 +1,15 @@ -import { CheckIcon, LoaderIcon } from "@chopin/icons"; -import { useEffect, useReducer, useRef, useState } from "react"; +import { ClockIcon, LoaderIcon, ProgressRingIcon, WarningIcon } from "@chopin/icons"; +import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; import { advanceFirstBuild, + buildLabel, + buildLook, buildPhase, + buildTabReady, draftRefusalCopy, firstBuildStep, - startingLabel, - SYNC_LABEL, - syncHint, - syncStatus, - waitingLabel, + hasBuilt, } from "./build-model"; import { cancelBuildRequest, @@ -19,8 +18,10 @@ import { startBuild, } from "./build-start"; +import "./build-plan-button.css"; + import type { Implementation, ImplementationSnapshot } from "@chopin/protocol/implementation"; -import type { FirstBuild } from "./build-model"; +import type { BuildLook, FirstBuild } from "./build-model"; import type { Wire } from "./wire"; const RELOAD_ON = [ @@ -29,49 +30,100 @@ const RELOAD_ON = [ "implementation:readiness", "experiment:changed", ]; +/** Edits only move a built document's change count, so they reload it at most this often. */ +const EDIT_RELOAD_MS = 1000; + +/** The document's build, shared by the header capsule, the Build tab and the Build view. */ +export type BuildPlan = { + snapshot?: ImplementationSnapshot; + loadError?: string; + look: BuildLook; + /** Build has something to show: a build, or a press under way. */ + tabReady: boolean; + /** The last start found no local agent; Build says how to start one. */ + needsAgent: boolean; + /** Build plan, or Retry after a first build stopped. */ + press: () => void; + reload: () => void; +}; /** - * The document's first build in one press: ask the Planner for tasks, then - * start them on the viewer's local agent. The Build view owns every later build. - * Once that build has delivered, the slot quietly reports whether the pull - * requests still match the living document. + * Read the document's build and run Build plan's one press: ask the Planner for + * tasks, then start them on the viewer's local agent. Nothing else sends + * `implementation:draft`, so opening Build never spends a Planner turn. */ -export function BuildPlanButton( - { onNeedsAgent, onShowBuild, onShowDecisions, room, userId, wire }: { - /** No local agent could take the build; the Build view explains how to start one. */ - onNeedsAgent: () => void; - onShowBuild: () => void; - onShowDecisions: () => void; +export function useBuildPlan( + { canEdit, connected, enabled, hasContent, live, planner, room, unanswered, userId, wire }: { + canEdit: boolean; + connected: boolean; + /** A parent document; a child is never built. */ + enabled: boolean; + hasContent: boolean; + live: boolean; + planner: boolean; room: string; + unanswered: number; userId?: string; wire?: Wire; }, -) { +): BuildPlan { let [snapshot, setSnapshot] = useState(); + let [loadError, setLoadError] = useState(); let [refresh, setRefresh] = useState(0); let [state, dispatch] = useReducer(advanceFirstBuild, { stage: "idle" } as FirstBuild); let draftId = useRef(undefined); let endpoint = implementationEndpoint(room); + let reload = () => setRefresh(value => value + 1); let read = async (signal?: AbortSignal) => { let value = await implementationResponse( await fetch(endpoint, { signal, cache: "no-store" }), ); + if (signal?.aborted) return value; setSnapshot(current => !current || value.revision >= current.revision ? value : current); + setLoadError(undefined); return value; }; useEffect(() => { + setSnapshot(undefined); + dispatch({ type: "reset" }); + }, [room]); + + useEffect(() => { + if (!enabled) return; let controller = new AbortController(); - read(controller.signal).catch(() => {}); + read(controller.signal).catch(error => { + if (!controller.signal.aborted) { + setLoadError(error instanceof Error ? error.message : "Connection failed"); + } + }); return () => controller.abort(); - }, [endpoint, refresh]); + }, [enabled, endpoint, refresh]); useEffect(() => { - let reload = () => setRefresh(value => value + 1); + if (!enabled) return; let off = RELOAD_ON.map(kind => wire?.on(kind, reload)); return () => off.forEach(remove => remove?.()); - }, [wire]); + }, [enabled, wire]); + + // A built document counts its edits since the build, so edits reload it, throttled. + let built = !!snapshot && hasBuilt(snapshot); + useEffect(() => { + if (!enabled || !built) return; + let timer: number | undefined; + let edited = () => { + timer ??= window.setTimeout(() => { + timer = undefined; + reload(); + }, EDIT_RELOAD_MS); + }; + let off = ["plan:update", "plan:ack"].map(kind => wire?.on(kind, edited)); + return () => { + off.forEach(remove => remove?.()); + window.clearTimeout(timer); + }; + }, [enabled, built, wire]); // Whether the draft turn left tasks to start is only known from a fresh read. let drafted = async () => { @@ -101,7 +153,7 @@ export function BuildPlanButton( let step = firstBuildStep(snapshot, state, userId); useEffect(() => { - if (!step.next) return; + if (!step.next || !canEdit) return; if (step.next === "reset" || step.next === "resume" || step.next === "wait") { dispatch({ type: step.next }); return; @@ -130,11 +182,10 @@ export function BuildPlanButton( startBuild(room, snapshot).then(result => { if (result === "started") { dispatch({ type: "started" }); - setRefresh(value => value + 1); + reload(); return; } dispatch({ type: "start-refused", agent: true }); - onNeedsAgent(); }, error => { dispatch({ type: "start-refused", @@ -144,92 +195,199 @@ export function BuildPlanButton( : undefined, }); }); - }, [step.next, snapshot?.revision]); + }, [step.next, snapshot?.revision, canEdit]); - let sync = syncStatus(snapshot); - if (sync) { - let hint = syncHint(sync, snapshot, userId); - return ( - - ); - } - if (step.view === "hidden") return null; - if (step.view === "waiting") { - let { label, target } = waitingLabel(snapshot); - let hint = "Building starts once this is resolved"; - return ( - - ); - } - if (step.view === "working") { - let { hint, label, queued } = startingLabel(snapshot); + let look = enabled + ? buildLook(snapshot, { + state, + userId, + live, + canEdit, + connected, + planner, + hasContent, + unanswered, + }) + : { kind: "plan" as const, blocked: "A child document is never built" }; + return { + snapshot, + loadError, + look, + tabReady: enabled && buildTabReady(snapshot, look), + needsAgent: state.stage === "failed" && !!state.agent, + press() { + // The press drafts or starts against the latest revision, never a stale read. + void read().catch(() => {}).finally(() => dispatch({ type: "press" })); + }, + reload, + }; +} + +/** + * The build's one glyph, identical in the header capsule, on the Build tab and in + * Build's headline, so the three read as the same thing. Nothing before a build. + */ +export function BuildGlyph({ look }: { look: BuildLook }) { + let inner = look.kind === "tasks" || look.kind === "rebuilding" + ?