diff --git a/apps/server/src/chat/draft.test.ts b/apps/server/src/chat/draft.test.ts index 90d0d503e..69ba8568f 100644 --- a/apps/server/src/chat/draft.test.ts +++ b/apps/server/src/chat/draft.test.ts @@ -208,6 +208,31 @@ test("a task draft is refused without a Planner or when the notice cannot be sav expect(prompts).toEqual([]); }); +test("a task draft is refused while no remote Planner is attached, and runs once one is", async () => { + let { context, events, prompts, releases, saves, turnOpened } = await setup(); + context.plannerNotAttached = async () => "No Planner is attached."; + expect(await Chat.draftTasks(context, "ana", 1, "Prepare.", "@ana asked.")).toEqual({ + ok: false, + reason: "No Planner is attached.", + code: "planner-not-attached", + }); + expect(context.chat.draft).toBeUndefined(); + expect(context.chat.waiting).toEqual([]); + expect(drafting(events)).toEqual([]); + expect(saves()).toBe(0); + expect(prompts).toEqual([]); + + // Attached: this harness stands in for the attached Planner owning the turn. + context.plannerNotAttached = undefined; + let started = turnOpened(); + expect(await Chat.draftTasks(context, "ana", 1, "Prepare.", "@ana asked.")).toMatchObject({ + ok: true, + }); + await started; + expect(prompts).toHaveLength(1); + releases.forEach(release => release()); +}); + test("a task draft takes the same limits as a Chat message, even with the Planner idle", async () => { let { context, prompts } = await setup(); context.chat.pendingSends = 20; diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index f459bb1d6..ff12d805a 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -1075,7 +1075,7 @@ export type TaskDraftResult = state: "queued" | "running" | "ended"; existing: boolean; } - | { ok: false; reason: string }; + | { ok: false; reason: string; code?: "planner-not-attached" }; /** * Ask the Planner for implementation tasks without a member message. @@ -1116,6 +1116,9 @@ async function postDraft( let { chat, config, room, server } = context; if (!config.agent) return { ok: false, reason: "the Planner is not running" }; if (chat.closed) return { ok: false, reason: "chat is closed" }; + // Under HARNESS=remote a draft queued with no Planner attached would never run. + let unattached = await context.plannerNotAttached?.(); + if (unattached) return { ok: false, reason: unattached, code: "planner-not-attached" }; let existing = liveDraft(chat); if (existing) return { ok: true, draft: existing, state: existing.state, existing: true }; // The draft waits in the queue even when the Planner is idle, so it counts against it. diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 6c3896488..08b670b8f 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -833,7 +833,7 @@ async function receive(ws: Socket, raw: string): Promise { said, ); if (!result.ok) { - fail(ws, frame.rid, result.reason); + fail(ws, frame.rid, result.reason, result.code); return; } if (frame.build) requestBuild(opened, ws.data.principalId); @@ -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..c772f7054 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 { @@ -880,6 +881,17 @@ export function reportRebuild( }); } +/** The last change count per open document, so repeated reads between edits parse nothing. */ +let counted = new WeakMap(); + +function changeCount(plan: Plan, base: string, current: string): number { + let cached = counted.get(plan); + if (cached?.revision === plan.revision && cached.base === base) return cached.changes; + let changes = changedBlocks(base, current); + counted.set(plan, { revision: plan.revision, base, changes }); + return changes; +} + /** What the browser needs to show whether a living document matches its pull requests. */ export function liveSnapshot( plan: Plan, @@ -893,6 +905,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 +930,8 @@ export function liveSnapshot( tasks, ...(unfinished.length ? { outstandingTasks: unfinished } : {}), ...(rebuild ? { rebuild } : {}), - outOfSync: source(plan) !== baseSource, + outOfSync: current !== baseSource, + changes: current === baseSource ? 0 : changeCount(plan, 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..b931b8571 --- /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 reorder of any size only through the floor of one change", () => { + 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..c80cf1d8f --- /dev/null +++ b/apps/server/src/tasks/changes.ts @@ -0,0 +1,31 @@ +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. Blocks compare as a multiset, so a reorder of any size + * adds and removes nothing; the floor of one change is what counts it, as for any other difference + * that serializes the same. + */ +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..6d07526bd 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; @@ -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..3573241bf 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -1,25 +1,24 @@ import { describe, expect, it } from "bun:test"; import { - advanceDraft, advanceFirstBuild, ago, attentionHint, + buildLabel, + buildLook, buildPhase, - draftKey, + buildRefusalCopy, + buildTabReady, draftRefusalCopy, elapsed, firstBuildStep, pullRequestCommits, pullRequestNumber, - shouldAutoDraft, startedBy, startingHint, - startingLabel, syncHint, syncStatus, taskStartsOpen, - waitingLabel, } from "./build-model"; import type { @@ -166,13 +165,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 +190,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 +217,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" }); @@ -331,6 +253,51 @@ describe("first build", () => { expect(firstBuildStep(snapshot(), state).next).toBe("start"); }); + it("drafts once more when edits made the first draft stale, then says the document changed", () => { + let sent = advanceFirstBuild(advanceFirstBuild(idle, { type: "press" }), { + type: "draft-sent", + }); + let again = advanceFirstBuild(sent, { type: "draft-ended", drafted: false, changed: true }); + expect(again).toEqual({ stage: "drafting", sent: false, redrafted: true }); + let resent = advanceFirstBuild(again, { type: "draft-sent" }); + expect(resent).toEqual({ stage: "drafting", sent: true, redrafted: true }); + expect(advanceFirstBuild(resent, { type: "draft-ended", drafted: false, changed: true })) + .toEqual({ + stage: "failed", + message: "The document changed while Chopin was breaking it into tasks.", + }); + }); + + it("keeps Build plan inert while no Planner is attached to draft its tasks", () => { + let context = { + state: idle, + live: false, + canEdit: true, + connected: true, + planner: true, + hasContent: true, + unanswered: 0, + }; + expect(buildLook(fresh, { ...context, plannerDetached: true })).toEqual({ + kind: "plan", + blocked: "Connect a Planner to this document to build", + }); + expect(buildLook(fresh, context)).not.toMatchObject({ + blocked: "Connect a Planner to this document to build", + }); + }); + + it("maps refused starts and rebuilds to plain copy", () => { + expect(buildRefusalCopy("no-workspace")).toBe("Start your local agent, then retry"); + expect(buildRefusalCopy("reviewed graph has changed")).toBe("The tasks changed. Try again"); + expect(buildRefusalCopy("unanswered questionnaires")).toBe("Answer the open decisions first"); + expect(buildRefusalCopy("something internal")).toBe("The build couldn’t start. Try again"); + expect(draftRefusalCopy("No Planner is attached to this document. To attach one, …")) + .toBe("Connect a Planner to this document to build."); + expect(draftRefusalCopy("anything", "planner-not-attached")) + .toBe("Connect a Planner to this document to build."); + }); + it("returns to the button when drafting or starting fails", () => { let sent = advanceFirstBuild(advanceFirstBuild(idle, { type: "press" }), { type: "draft-sent", @@ -338,7 +305,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 +314,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 +329,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 +345,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 +361,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 +410,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 +419,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 +521,131 @@ 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" }); + // An unknown builder is left unnamed rather than called “the builder”. + expect(look(live(), { userId: "other" })) + .toMatchObject({ tip: "Every pull request matches the document" }); + expect(look(live({ outOfSync: true }), { userId: "other" })) + .toMatchObject({ tip: "Edits since someone else’s build" }); + }); + + 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..2bd447d44 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,87 +212,14 @@ 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. */ -export function draftRefusalCopy(message: string): string | undefined { +export function draftRefusalCopy(message: string, code?: string): string | undefined { + if (code === "planner-not-attached" || /No Planner is attached/.test(message)) { + return "Connect a Planner to this document to build."; + } if (/unanswered questionnaires/.test(message)) return "Answer the open decisions first."; if (/accepted comments/.test(message)) { return "Update the document for accepted comments first."; @@ -335,6 +235,33 @@ export function draftRefusalCopy(message: string): string | undefined { if (/cannot be built/.test(message)) return "This document can’t be built."; } +/** + * Plain copy for a refused build start or rebuild. The server answers with terse + * internal reasons; a person sees what happened and what to do. Anything unknown + * reads as a generic failure rather than the raw reason. + */ +export function buildRefusalCopy(message: string): string { + let draft = draftRefusalCopy(message); + if (draft) return draft.replace(/\.$/, ""); + if (/^no-workspace$/.test(message)) return "Start your local agent, then retry"; + if (/offline or busy/.test(message)) return "Your local agent is offline or busy with other work"; + if (/busy/i.test(message)) return "Your local agent is busy with another document"; + if (/reviewed graph has changed|reviewed build has changed|stale/.test(message)) { + return "The tasks changed. Try again"; + } + if (/review the existing build/.test(message)) { + return "The last build needs attention in Build first"; + } + if (/build history is full/.test(message)) return "This document has built too many times"; + if (/archived/.test(message)) return "This document is archived"; + if (/origin is not allowed|authentication required/.test(message)) { + return "Sign in again to build"; + } + if (/not found/.test(message)) return "This document is no longer available"; + if (/no tasks/i.test(message)) return "There are no tasks to build yet"; + return "The build couldn’t start. Try again"; +} + /** Whether this document has ever been handed to a coding agent. */ export function hasBuilt(snapshot: Snapshot): boolean { return !!snapshot.build || snapshot.lifecycle.execution.state === "active" @@ -348,7 +275,8 @@ export function hasBuilt(snapshot: Snapshot): boolean { */ export type FirstBuild = | { stage: "idle" } - | { stage: "drafting"; sent: boolean } + /** `redrafted` once an edit during the first draft made it stale and it was asked again. */ + | { stage: "drafting"; sent: boolean; redrafted?: boolean } /** The draft raised a blocker, or a reload found the request: continue once it clears. */ | { stage: "waiting" } | { stage: "starting" } @@ -362,8 +290,11 @@ export type FirstBuildEvent = /** The draft left the document blocked; keep the intent until the blockers clear. */ | { type: "wait" } | { type: "draft-sent" } - /** The draft turn ended; `drafted` when a fresh read then had tasks to start. */ - | { type: "draft-ended"; drafted: boolean } + /** + * The draft turn ended; `drafted` when a fresh read then had tasks to start, `changed` when + * it had tasks for an older revision because the document was edited while they were drafted. + */ + | { type: "draft-ended"; drafted: boolean; changed?: boolean } | { type: "draft-refused"; message?: string } | { type: "start" } | { type: "started" } @@ -382,12 +313,24 @@ export function advanceFirstBuild(state: FirstBuild, event: FirstBuildEvent): Fi return state.stage === "drafting" ? { stage: "waiting" } : state; case "draft-sent": return state.stage === "drafting" || state.stage === "waiting" - ? { stage: "drafting", sent: true } + ? { + stage: "drafting", + sent: true, + ...(state.stage === "drafting" && state.redrafted ? { redrafted: true } : {}), + } : state; case "draft-ended": - return state.stage === "drafting" && state.sent && !event.drafted - ? { stage: "failed", message: "Chopin couldn’t break the plan into tasks." } - : state; + if (state.stage !== "drafting" || !state.sent || event.drafted) return state; + // Edits made while the tasks were drafted left them stale: draft once more, then say so. + if (event.changed && !state.redrafted) { + return { stage: "drafting", sent: false, redrafted: true }; + } + return { + stage: "failed", + message: event.changed + ? "The document changed while Chopin was breaking it into tasks." + : "Chopin couldn’t break the plan into tasks.", + }; case "draft-refused": return state.stage === "drafting" ? { stage: "failed", message: event.message } : state; case "start": @@ -406,25 +349,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 +379,224 @@ 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; + /** + * Under the remote Planner harness, no Planner client is attached to draft tasks. Only + * drafting needs one: starting drafted tasks, Retry and rebuilds do not. + */ + plannerDetached?: 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 (phase.kind === "drafting" && context.plannerDetached) { + return "Connect a Planner to this document to build"; + } + if (!snapshot.buildReady) return "Add more detail before building"; +} + +/** Who else built a living document: their login, `""` when it is unknown, or nothing for you. */ +function builder(snapshot: Snapshot, userId: string | undefined): string | undefined { + if (!snapshot.live || snapshot.live.user === userId) return; + return snapshot.builtBy ? `@${snapshot.builtBy}` : ""; +} + +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` + : who === "" + ? "Waiting for the agent that built it" + : "Waiting for your agent" + : who + ? `Builds on ${who}’s agent when editing settles` + : "Builds when editing settles" + : who + ? `Edits since ${who} built it` + : who === "" + ? "Edits since someone else’s build" + : "Edits since the last build"; + return { kind: "stale", changes, live: context.live, tip, build: false }; + } + if (!snapshot) { + return step.view === "idle" ? { kind: "plan", blocked: "Chopin isn’t connected" } : { + kind: "tasks", + tip: "Breaking the document into tasks", + }; + } + 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 (phase?.kind === "blocked" && phase.comments) { - return { label: "Waiting on comments", target: "build" }; + 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" }; } - return { label: "Waiting to build", target: "build" }; + 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..2cef503d3 --- /dev/null +++ b/apps/web/src/build-plan-button.css @@ -0,0 +1,177 @@ +/* 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); +} + +/* A coarse pointer gets the shared invisible 44px target around the small capsule. */ +:root[data-plan-coarse-pointer] .build-capsule { + position: relative; +} + +:root[data-plan-coarse-pointer] .build-capsule::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: max(100%, var(--control-target-coarse)); + height: max(100%, var(--control-target-coarse)); + transform: translate(-50%, -50%); +} + +/* 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); +} + +/* Reduced motion keeps the still loader glyph; its words say it is working. */ +@media (prefers-reduced-motion: no-preference) { + .build-glyph-spin { + 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..752f8edc5 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -1,16 +1,16 @@ -import { CheckIcon, LoaderIcon } from "@chopin/icons"; -import { useEffect, useReducer, useRef, useState } from "react"; +import { ClockIcon, LoaderIcon, ProgressRingIcon, WarningIcon, WrenchIcon } from "@chopin/icons"; +import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; import { advanceFirstBuild, + buildLabel, + buildLook, buildPhase, + buildRefusalCopy, + buildTabReady, draftRefusalCopy, firstBuildStep, - startingLabel, - SYNC_LABEL, - syncHint, - syncStatus, - waitingLabel, + hasBuilt, } from "./build-model"; import { cancelBuildRequest, @@ -19,8 +19,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,56 +31,128 @@ 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; + /** A press is drafting or starting the build, so another press would do nothing. */ + busy: 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, + plannerDetached, + room, + unanswered, + userId, + wire, + }: { + canEdit: boolean; + connected: boolean; + /** A parent document; a child is never built. */ + enabled: boolean; + hasContent: boolean; + live: boolean; + planner: boolean; + /** `session:planner` reports no Planner client attached (remote harness only). */ + plannerDetached?: 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 () => { draftId.current = undefined; try { let fresh = await read(); - dispatch({ type: "draft-ended", drafted: buildPhase(fresh).kind !== "drafting" }); + let phase = buildPhase(fresh); + dispatch({ + type: "draft-ended", + drafted: phase.kind !== "drafting", + changed: phase.kind === "drafting" && phase.draft === "revise", + }); } catch { dispatch({ type: "draft-ended", drafted: false }); } @@ -101,7 +175,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; @@ -120,7 +194,9 @@ export function BuildPlanButton( draftId.current = answer.id; if (answer.state === "ended") void drafted(); }, error => { - let copy = error instanceof Error ? draftRefusalCopy(error.message) : undefined; + let copy = error instanceof Error + ? draftRefusalCopy(error.message, (error as { code?: string }).code) + : undefined; dispatch({ type: "draft-refused", message: copy }); }); return; @@ -130,106 +206,256 @@ 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", agent: false, - message: error instanceof Error - ? error.message.replace(/^[a-z]/, letter => letter.toUpperCase()) - : undefined, + message: error instanceof Error ? buildRefusalCopy(error.message) : 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 ( - + let look = enabled + ? buildLook(snapshot, { + state, + userId, + live, + canEdit, + connected, + planner, + plannerDetached, + 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, + busy: state.stage === "drafting" || state.stage === "starting", + 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" + ?