From bc6b988762ac6ae4d97db1d04196f847f6536996 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:15:32 +0100 Subject: [PATCH 01/13] Offer a one-click first build once the document reads as ready The server judges, after edits settle, whether a document is complete enough to build (Jev, else a word and heading heuristic) and exposes it as buildReady on the implementation snapshot. A Build plan button in the document toolbar drafts tasks and starts them with no review step. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/main.ts | 17 +++ apps/server/src/tasks/readiness.test.ts | 133 ++++++++++++++++++++ apps/server/src/tasks/readiness.ts | 136 +++++++++++++++++++++ apps/server/src/tasks/routes.ts | 3 + apps/web/src/build-model.test.ts | 69 +++++++++++ apps/web/src/build-model.ts | 90 ++++++++++++++ apps/web/src/build-plan-button.tsx | 153 ++++++++++++++++++++++++ apps/web/src/build-start.ts | 52 ++++++++ apps/web/src/build-view.tsx | 45 +++---- apps/web/src/room-workspace.tsx | 14 +++ e2e/jev.ts | 9 ++ packages/protocol/implementation.d.ts | 13 +- 12 files changed, 703 insertions(+), 31 deletions(-) create mode 100644 apps/server/src/tasks/readiness.test.ts create mode 100644 apps/server/src/tasks/readiness.ts create mode 100644 apps/web/src/build-plan-button.tsx create mode 100644 apps/web/src/build-start.ts diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 22b1f3340..5ba4b5bad 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -62,6 +62,8 @@ import { liveClassifier } from "./tasks/live-gate"; import { LiveSyncCoordinator } from "./tasks/live-sync"; import { locksEditing } from "./tasks/builds"; import { pendingLinks, relinkInstruction } from "./tasks/relink"; +import { BuildReadiness } from "./tasks/readiness"; +import { askJev } from "./conversation-plan/jev"; import { registerMcpRoutes } from "./mcp/routes"; import { registerNavigationRoutes } from "./navigation/routes"; import * as Service from "./plan/service"; @@ -152,6 +154,7 @@ let recoveringResearch: Promise | undefined; let referenceService: ReferenceService | undefined; let summaryCoordinator: DocumentSummaryCoordinator | undefined; let liveSync: LiveSyncCoordinator | undefined; +let buildReadiness: BuildReadiness | undefined; let descriptionProjector: DocumentDescriptionProjector | undefined; let researchBriefJobs: JobService | undefined; let documentLocks = new Map>(); @@ -235,6 +238,7 @@ function documentBackend(): Service.Backend { onDocumentPersisted: target => { summaryCoordinator?.schedule(target); liveSync?.schedule(target); + buildReadiness?.schedule(target); }, liveBuild: !!config.liveBuild, onBuildStopped: id => liveSync?.stopped(id), @@ -1475,6 +1479,7 @@ function drain(): Promise { if (summaryCoordinator) await attempt(() => summaryCoordinator!.flush()); summaryCoordinator?.close(); liveSync?.close(); + buildReadiness?.close(); let stoppingJobs = jobRunner?.shutdown(); ownerBindings?.revokeAll(); plannerAttachments?.close(); @@ -1979,6 +1984,15 @@ referenceService = new ReferenceService({ research: researchService, id: ulid, }); +buildReadiness = new BuildReadiness({ + current: currentDocumentTarget, + 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), +}); if (config.agent && config.backgroundJobs) { summaryCoordinator = new DocumentSummaryCoordinator({ service: jobService, @@ -2067,12 +2081,14 @@ registerMcpRoutes(router, hostedAuth, { onDocumentPersisted: target => { if (summaryCoordinator) void summaryCoordinator.enqueueNow(target).catch(() => {}); liveSync?.schedule(target); + buildReadiness?.schedule(target); }, restoreChannel, serializeDocument: (channelId, action) => withDocumentTransition(channelId, () => withDocumentLock(channelId, action)), }); implementations = registerImplementationRoutes(router, hostedAuth, { + buildable: (id, planRevision) => buildReadiness?.ready(id, planRevision) ?? false, connections: experiments.connections, busy: async connectionId => (await experiments!.service.store.active()).some(item => @@ -2192,6 +2208,7 @@ try { await stoppingLease; summaryCoordinator?.close(); liveSync?.close(); + buildReadiness?.close(); let stoppingJobs = jobRunner.shutdown(); ownerBindings.revokeAll(); await stoppingJobs.catch(() => {}); diff --git a/apps/server/src/tasks/readiness.test.ts b/apps/server/src/tasks/readiness.test.ts new file mode 100644 index 000000000..424912bbf --- /dev/null +++ b/apps/server/src/tasks/readiness.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { BuildReadiness, judgeBuildable, looksBuildable } from "./readiness"; + +import type { JevRequest, JevResult } from "../conversation-plan/jev"; +import type { DocumentTarget } from "../plan/service"; + +let long = `# Title\n\n## Goal\n\n${"word ".repeat(130)}\n\n## Steps\n\nDo it.`; + +function target(source: string, revision = 1): DocumentTarget { + return { channelId: "doc", revision, source, sourceHash: String(revision) }; +} + +function jev(noul: number, seen: JevRequest[] = []) { + return async (request: JevRequest): Promise => { + seen.push(request); + return { + model: "test", + answers: { build_ready: { type: "noul", noul } }, + usage: { input_tokens: 1, output_tokens: 1 }, + latencyMs: 0, + }; + }; +} + +function timers() { + let queue: Array<{ delay: number; action: () => void; cancelled: boolean }> = []; + return { + queue, + after(delay: number, action: () => void) { + let entry = { delay, action, cancelled: false }; + queue.push(entry); + return () => { + entry.cancelled = true; + }; + }, + fire() { + for (let entry of queue.splice(0)) if (!entry.cancelled) entry.action(); + }, + }; +} + +describe("looksBuildable", () => { + test("needs enough words and two headings", () => { + expect(looksBuildable(long)).toBe(true); + expect(looksBuildable(`# One\n\n${"word ".repeat(200)}`)).toBe(false); + expect(looksBuildable("# One\n\n## Two\n\nShort.")).toBe(false); + expect(looksBuildable("")).toBe(false); + }); +}); + +describe("judgeBuildable", () => { + test("asks Jev one noul question about the document", async () => { + let seen: JevRequest[] = []; + expect(await judgeBuildable("tiny", jev(0.9, seen))).toBe(true); + expect(Object.keys(seen[0]!.questions)).toEqual(["build_ready"]); + expect(seen[0]!.questions.build_ready!.type).toBe("noul"); + expect(await judgeBuildable(long, jev(0.2))).toBe(false); + }); + + test("falls back to the heuristic when Jev fails or is absent", async () => { + let failing = async () => { + throw new Error("Jev API key is not configured"); + }; + expect(await judgeBuildable(long, failing)).toBe(true); + expect(await judgeBuildable("tiny", failing)).toBe(false); + expect(await judgeBuildable(long)).toBe(true); + }); +}); + +describe("BuildReadiness", () => { + test("judges once edits settle and reports a change", async () => { + let clock = timers(); + let source = "tiny"; + let revision = 1; + let changes: boolean[] = []; + let asks: JevRequest[] = []; + let readiness = new BuildReadiness({ + current: async () => target(source, revision), + ask: jev(0, asks), + after: clock.after, + changed: (_id, _revision, ready) => changes.push(ready), + }); + readiness.schedule(target(source, 1)); + readiness.schedule(target(source, 1)); + expect(clock.queue.map(entry => entry.delay)).toEqual([10_000, 10_000]); + expect(clock.queue[0]!.cancelled).toBe(true); + clock.fire(); + await Bun.sleep(0); + expect(asks.length).toBe(1); + expect(readiness.ready("doc", 1)).toBe(false); + expect(changes).toEqual([false]); + }); + + test("reads trigger a judgement and keep the last one until the next arrives", async () => { + let clock = timers(); + let noul = 0.9; + let revision = 1; + let changes: boolean[] = []; + let readiness = new BuildReadiness({ + current: async () => target(long, revision), + ask: request => jev(noul)(request), + after: clock.after, + changed: (_id, _revision, ready) => changes.push(ready), + }); + expect(readiness.ready("doc", 1)).toBe(false); + await Bun.sleep(0); + expect(readiness.ready("doc", 1)).toBe(true); + revision = 2; + noul = 0.1; + readiness.schedule(target(long, 2)); + // Pending: the previous judgement stands and no new one starts early. + expect(readiness.ready("doc", 2)).toBe(true); + clock.fire(); + await Bun.sleep(0); + expect(readiness.ready("doc", 2)).toBe(false); + expect(changes).toEqual([true, false]); + }); + + test("without Jev, the heuristic decides", async () => { + let readiness = new BuildReadiness({ current: async () => target(long) }); + readiness.ready("doc", 1); + await Bun.sleep(0); + expect(readiness.ready("doc", 1)).toBe(true); + }); + + test("close cancels a pending judgement", () => { + let clock = timers(); + let readiness = new BuildReadiness({ current: async () => target(long), after: clock.after }); + readiness.schedule(target(long)); + readiness.close(); + expect(clock.queue[0]!.cancelled).toBe(true); + }); +}); diff --git a/apps/server/src/tasks/readiness.ts b/apps/server/src/tasks/readiness.ts new file mode 100644 index 000000000..16c65acf4 --- /dev/null +++ b/apps/server/src/tasks/readiness.ts @@ -0,0 +1,136 @@ +import type { JevRequest, JevResult } from "../conversation-plan/jev"; +import type { DocumentTarget } from "../plan/service"; + +export type ReadinessAsk = (request: JevRequest) => Promise; + +export type ReadinessOptions = { + current: (channelId: string) => Promise; + /** Jev, when configured; otherwise, or when it fails, the heuristic decides. */ + ask?: ReadinessAsk; + /** Called after a document's judgement changes. */ + changed?: (channelId: string, revision: number, ready: boolean) => void; + debounceMs?: number; + after?: (delayMs: number, action: () => void) => () => void; + error?: (err: unknown) => void; +}; + +const QUESTION = + "Is this document a complete enough plan that a coding agent could build a first version now?"; +/** Jev state is bounded; the opening of a long document carries the judgement. */ +const SOURCE_LIMIT = 20_000; + +/** Enough prose and structure to be worth building, for when Jev cannot say. */ +export function looksBuildable(source: string): boolean { + let lines = source.split("\n"); + let headings = lines.filter(line => /^#{1,6}\s+\S/.test(line)).length; + let words = source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)?.length ?? 0; + return words >= 120 && headings >= 2; +} + +export async function judgeBuildable(source: string, ask?: ReadinessAsk): Promise { + if (!ask) return looksBuildable(source); + try { + let result = await ask({ + state: { document: source.slice(0, SOURCE_LIMIT) }, + questions: { + build_ready: { + type: "noul", + instructions: QUESTION, + criteria: { + true: + "The document states what to build and enough of how for a first working version.", + false: "The document is a sketch, a list of open questions, or too thin to build from.", + }, + }, + }, + }); + let answer = result.answers.build_ready; + if (answer?.type !== "noul") throw new Error("invalid Jev readiness answer"); + return answer.noul >= 0.5; + } catch { + return looksBuildable(source); + } +} + +/** + * Whether each document reads as ready to build, judged once its edits settle. + * + * In memory only: a restart forgets every judgement and the next read asks again. + */ +export class BuildReadiness { + #options: ReadinessOptions; + #debounceMs: number; + #judged = new Map(); + #pending = new Map void>(); + #running = new Map>(); + #closed = false; + + constructor(options: ReadinessOptions) { + this.#options = options; + this.#debounceMs = options.debounceMs ?? 10_000; + } + + /** + * The latest judgement, or `false` before the first. A stale one stands until + * its successor arrives, so a small edit does not hide a ready document. + */ + ready(channelId: string, revision: number): boolean { + let judged = this.#judged.get(channelId); + if (judged?.revision !== revision && !this.#pending.has(channelId)) { + void this.#judge(channelId); + } + return judged?.ready ?? false; + } + + /** A committed edit: judge again once edits stop for the debounce window. */ + schedule(target: DocumentTarget): void { + if (this.#closed) return; + this.#pending.get(target.channelId)?.(); + let after = this.#options.after ?? ((delay, action) => { + let timer = setTimeout(action, delay); + return () => clearTimeout(timer); + }); + let cancel = after(this.#debounceMs, () => { + if (this.#pending.get(target.channelId) !== cancel) return; + this.#pending.delete(target.channelId); + void this.#judge(target.channelId); + }); + this.#pending.set(target.channelId, cancel); + } + + /** Judge the document as it stands now; concurrent calls share one judgement. */ + #judge(channelId: string): Promise { + let running = this.#running.get(channelId); + if (running) return running; + let work = (async () => { + try { + let target = await this.#options.current(channelId); + if (!target || this.#closed) return; + let ready = await judgeBuildable(target.source, this.#options.ask); + if (this.#closed) return; + let previous = this.#judged.get(channelId); + if (previous && previous.revision > target.revision) return; + this.#judged.set(channelId, { revision: target.revision, ready }); + if (previous?.ready !== ready) this.#options.changed?.(channelId, target.revision, ready); + } catch (err) { + this.#options.error?.(err); + } finally { + this.#running.delete(channelId); + } + })(); + this.#running.set(channelId, work); + return work; + } + + forget(channelId: string): void { + this.#pending.get(channelId)?.(); + this.#pending.delete(channelId); + this.#judged.delete(channelId); + } + + close(): void { + this.#closed = true; + for (let cancel of this.#pending.values()) cancel(); + this.#pending.clear(); + } +} diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index b540c2f2d..49a774e6a 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -18,6 +18,8 @@ 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. */ + buildable?: (id: string, planRevision: number) => boolean; }; let buildSchema = z.object({ retryOf: z.string().uuid().optional(), @@ -141,6 +143,7 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o }), blockers: ready.ok ? [] : ready.blockers, localAgent, + buildReady: ready.ok && (options.buildable?.(id, plan.revision) ?? false), lifecycle: plan.graph ? implementationLifecycle({ graph: plan.graph, diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index 0144a36d5..1cade9aeb 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "bun:test"; import { advanceDraft, + advanceFirstBuild, buildPhase, draftKey, draftRefusalCopy, elapsed, + firstBuildStep, pullRequestNumber, shouldAutoDraft, startedBy, @@ -38,6 +40,7 @@ function snapshot(change: Partial = {}): ImplementationS }, localAgent: false, blockers: [], + buildReady: true, lifecycle: { execution: { state: "idle" }, history: [] }, ...change, }; @@ -276,3 +279,69 @@ describe("refusals", () => { expect(draftRefusalCopy("could not save the request")).toBeUndefined(); }); }); + +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"); + expect(firstBuildStep(snapshot({ build: build("running") }), idle).view).toBe("working"); + }); + + 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" }); + state = advanceFirstBuild(state, { type: "draft-sent" }); + expect(firstBuildStep(fresh, state)).toEqual({ view: "working" }); + // Tasks for an older revision are not the drafted ones. + let stale = snapshot({ planRevision: 5 }); + expect(firstBuildStep(stale, state)).toEqual({ view: "working" }); + expect(firstBuildStep(snapshot(), state)).toEqual({ view: "working", next: "start" }); + state = advanceFirstBuild(state, { type: "start" }); + expect(state).toEqual({ stage: "starting" }); + expect(firstBuildStep(snapshot(), state)).toEqual({ view: "working" }); + state = advanceFirstBuild(state, { type: "started" }); + expect(firstBuildStep(snapshot(), state)).toEqual({ view: "working" }); + expect(firstBuildStep(snapshot({ build: build("queued") }), state)) + .toEqual({ view: "working", next: "reset" }); + }); + + it("starts existing tasks for this revision at once", () => { + let state = advanceFirstBuild(idle, { type: "press" }); + expect(firstBuildStep(snapshot(), state).next).toBe("start"); + }); + + it("returns to the button when drafting or starting fails", () => { + let sent = advanceFirstBuild(advanceFirstBuild(idle, { type: "press" }), { + type: "draft-sent", + }); + 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(advanceFirstBuild(failed, { type: "press" })).toEqual({ + stage: "drafting", + sent: false, + }); + + 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" }); + }); + + it("hands a blocked attempt back to the Build view", () => { + let state = advanceFirstBuild(idle, { type: "press" }); + let blocked = snapshot({ blockers: ["unanswered questionnaires"] }); + expect(firstBuildStep(blocked, state)).toEqual({ view: "hidden", next: "reset" }); + expect(advanceFirstBuild(state, { type: "reset" })).toEqual(idle); + }); +}); diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index 42083b5be..654fd70e9 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -207,3 +207,93 @@ export function draftRefusalCopy(message: string): string | undefined { } if (/cannot be built/.test(message)) return "This document can’t be built."; } + +/** 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" + || snapshot.lifecycle.history.length > 0; +} + +/** + * The header's one-click first build: draft the tasks, then start them, with + * no review in between. `failed` keeps the button so a person can try again; + * `agent` says the last start found no local agent to run it. + */ +export type FirstBuild = + | { stage: "idle" } + | { stage: "drafting"; sent: boolean } + | { stage: "starting" } + | { stage: "started" } + | { stage: "failed"; agent?: boolean; message?: string }; + +export type FirstBuildEvent = + | { type: "press" } + | { type: "draft-sent" } + /** The draft turn ended; `drafted` when a fresh read then had tasks to start. */ + | { type: "draft-ended"; drafted: boolean } + | { type: "draft-refused"; message?: string } + | { type: "start" } + | { type: "started" } + | { type: "start-refused"; agent: boolean; message?: string } + | { type: "reset" }; + +export function advanceFirstBuild(state: FirstBuild, event: FirstBuildEvent): FirstBuild { + switch (event.type) { + case "press": + return state.stage === "idle" || state.stage === "failed" + ? { stage: "drafting", sent: false } + : state; + case "draft-sent": + return state.stage === "drafting" ? { stage: "drafting", sent: 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; + case "draft-refused": + return state.stage === "drafting" ? { stage: "failed", message: event.message } : state; + case "start": + return state.stage === "drafting" || state.stage === "failed" + ? { stage: "starting" } + : state; + case "started": + return state.stage === "starting" ? { stage: "started" } : state; + case "start-refused": + return state.stage === "starting" + ? { stage: "failed", agent: event.agent, message: event.message } + : state; + case "reset": + return { stage: "idle" }; + } +} + +/** + * What the header shows for a snapshot and the first build's stage, 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. + */ +export function firstBuildStep( + snapshot: Snapshot | undefined, + state: FirstBuild, +): { view: "hidden" | "ready" | "working"; next?: "draft" | "start" | "reset" } { + if (!snapshot) return { view: state.stage === "idle" ? "hidden" : "working" }; + let phase = buildPhase(snapshot); + if (phase.kind === "building") { + return { view: "working", ...(state.stage === "idle" ? {} : { next: "reset" as const }) }; + } + 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 === "starting") return { view: "working" }; + if (state.stage === "started") { + return phase.kind === "review" ? { view: "working" } : { view: "hidden", next: "reset" }; + } + if (phase.kind === "review") return { view: "working", next: "start" }; + if (phase.kind === "drafting") { + return { view: "working", ...(state.sent ? {} : { next: "draft" }) }; + } + return { view: "hidden", next: "reset" }; +} diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx new file mode 100644 index 000000000..6780f8347 --- /dev/null +++ b/apps/web/src/build-plan-button.tsx @@ -0,0 +1,153 @@ +import { LoaderIcon } from "@chopin/icons"; +import { useEffect, useReducer, useRef, useState } from "react"; + +import { advanceFirstBuild, buildPhase, draftRefusalCopy, firstBuildStep } from "./build-model"; +import { implementationEndpoint, implementationResponse, startBuild } from "./build-start"; + +import type { Implementation, ImplementationSnapshot } from "@chopin/protocol/implementation"; +import type { FirstBuild } from "./build-model"; +import type { Wire } from "./wire"; + +const RELOAD_ON = [ + "plan:implementation", + "plan:open", + "implementation:readiness", + "experiment:changed", +]; + +/** + * 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. + */ +export function BuildPlanButton( + { onNeedsAgent, onShowBuild, room, wire }: { + /** No local agent could take the build; the Build view explains how to start one. */ + onNeedsAgent: () => void; + onShowBuild: () => void; + room: string; + wire?: Wire; + }, +) { + let [snapshot, setSnapshot] = useState(); + let [refresh, setRefresh] = useState(0); + let [state, dispatch] = useReducer(advanceFirstBuild, { stage: "idle" } as FirstBuild); + let draftId = useRef(undefined); + let endpoint = implementationEndpoint(room); + + let read = async (signal?: AbortSignal) => { + let value = await implementationResponse( + await fetch(endpoint, { signal, cache: "no-store" }), + ); + setSnapshot(current => !current || value.revision >= current.revision ? value : current); + return value; + }; + + useEffect(() => { + let controller = new AbortController(); + read(controller.signal).catch(() => {}); + return () => controller.abort(); + }, [endpoint, refresh]); + + useEffect(() => { + let reload = () => setRefresh(value => value + 1); + let off = RELOAD_ON.map(kind => wire?.on(kind, reload)); + return () => off.forEach(remove => remove?.()); + }, [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" }); + } catch { + dispatch({ type: "draft-ended", drafted: false }); + } + }; + + useEffect( + () => + wire?.on("implementation:drafting", frame => { + if (frame.id === draftId.current && frame.state === "ended") void drafted(); + }), + [wire], + ); + + let step = firstBuildStep(snapshot, state); + useEffect(() => { + if (!step.next) return; + if (step.next === "reset") { + dispatch({ type: "reset" }); + return; + } + if (step.next === "draft") { + if (!snapshot || !wire?.connected) { + dispatch({ type: "draft-refused", message: "Chopin isn’t connected." }); + return; + } + dispatch({ type: "draft-sent" }); + void wire.ask("implementation:draft", { + requestId: crypto.randomUUID(), + planRevision: snapshot.planRevision, + }).then(answer => { + draftId.current = answer.id; + if (answer.state === "ended") void drafted(); + }, error => { + let copy = error instanceof Error ? draftRefusalCopy(error.message) : undefined; + dispatch({ type: "draft-refused", message: copy }); + }); + return; + } + if (!snapshot) return; + dispatch({ type: "start" }); + startBuild(room, snapshot).then(result => { + if (result === "started") { + dispatch({ type: "started" }); + setRefresh(value => value + 1); + 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, + }); + }); + }, [step.next, snapshot?.revision]); + + if (step.view === "hidden") return null; + if (step.view === "working") { + return ( + + ); + } + let failure = state.stage === "failed" && !state.agent + ? state.message ?? "Chopin couldn’t start the build." + : undefined; + return ( + <> + + {failure && {failure}} + + ); +} diff --git a/apps/web/src/build-start.ts b/apps/web/src/build-start.ts new file mode 100644 index 000000000..170eeddd5 --- /dev/null +++ b/apps/web/src/build-start.ts @@ -0,0 +1,52 @@ +import { ApiError } from "./api"; + +import type { ImplementationSnapshot } from "@chopin/protocol/implementation"; + +export function implementationEndpoint(room: string): string { + return `/api/channels/${encodeURIComponent(room)}/implementation`; +} + +export async function implementationResponse(result: Response): Promise { + let value = await result.json(); + if (!result.ok) throw new ApiError(value.error ?? "Build is unavailable", result.status); + return value; +} + +/** Refusals that mean no local agent is connected, including the planned `no-workspace` 409. */ +function offline(error: unknown): boolean { + return error instanceof ApiError && error.status === 409 + && /^no-workspace$|offline or busy/.test(error.message); +} + +/** + * Approve the snapshot's tasks and start building them on the viewer's local agent. + * + * The one place the browser starts a build. `needs-agent` means no connected + * agent could take it; any other refusal throws. + */ +export async function startBuild( + room: string, + snapshot: ImplementationSnapshot, +): Promise<"started" | "needs-agent"> { + let graph = snapshot.graph; + if (!graph) throw new ApiError("There are no tasks to build", 409); + // The server picks the clicker's own connection; a snapshot can predate connecting. + try { + await implementationResponse( + await fetch(implementationEndpoint(room), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...(snapshot.build ? { retryOf: snapshot.build.id } : {}), + planRevision: snapshot.planRevision, + graphVersion: graph.number, + graphRevision: graph.revision, + }), + }), + ); + return "started"; + } catch (error) { + if (offline(error)) return "needs-agent"; + throw error; + } +} diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index c4b92ac31..deb67b939 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -1,6 +1,5 @@ import { useEffect, useId, useRef, useState } from "react"; -import { ApiError } from "./api"; import { advanceDraft, buildPhase, @@ -16,6 +15,11 @@ import { TASK_STATE_LABEL, taskStartsOpen, } from "./build-model"; +import { + implementationEndpoint, + implementationResponse as response, + startBuild, +} from "./build-start"; import type { Implementation, ImplementationSnapshot } from "@chopin/protocol/implementation"; import type { ReactNode } from "react"; @@ -29,28 +33,17 @@ const requests = new Map(); /** How long a request may wait for the server's answer before it counts as failed. */ const ANSWER_WINDOW_MS = 10_000; -async function response(result: Response): Promise { - let value = await result.json(); - if (!result.ok) throw new ApiError(value.error ?? "Build is unavailable", result.status); - return value; -} - function sentence(text: string): string { return text.replace(/^[a-z]/, letter => letter.toUpperCase()); } -/** The server's refusals for a connection it cannot reach mean the local agent is not running. */ -function offline(error: unknown): boolean { - return error instanceof ApiError && error.status === 409 - && /^no-workspace$|offline or busy/.test(error.message); -} - export function BuildView( { active, canEdit, chatLoaded, comments, + needsAgentSignal = 0, onShowDecisions, onShowDocument, planner, @@ -66,6 +59,8 @@ export function BuildView( chatLoaded: boolean; /** Accepted comments the document has not yet taken in. */ comments: number; + /** Increments when the header's Build plan found no local agent, to show how to start one. */ + needsAgentSignal?: number; onShowDecisions: () => void; onShowDocument: () => void; planner: boolean; @@ -105,7 +100,7 @@ export function BuildView( */ let activation = useRef({ id: 0, decided: false, eligible: undefined as string | undefined }); let [autoSent, setAutoSent] = useState(0); - let endpoint = `/api/channels/${encodeURIComponent(room)}/implementation`; + let endpoint = implementationEndpoint(room); let phase = buildPhase(snapshot); let progress = buildProgress(snapshot); let draft = phase.kind === "drafting" ? phase.draft : undefined; @@ -231,6 +226,9 @@ export function BuildView( // The local-agent note answers one press of one button; a new phase clears it. useEffect(() => setNeedsAgent(false), [phase.kind]); + useEffect(() => { + if (needsAgentSignal > 0) setNeedsAgent(true); + }, [needsAgentSignal]); useEffect(() => { let follow = () => { @@ -255,26 +253,13 @@ export function BuildView( let start = async () => { if (!snapshot?.graph || busy) return; setActionError(undefined); - // The server picks the clicker's own connection; a snapshot can predate connecting. setNeedsAgent(false); setBusy(true); try { - await response( - await fetch(endpoint, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ...(snapshot.build ? { retryOf: snapshot.build.id } : {}), - planRevision: snapshot.planRevision, - graphVersion: snapshot.graph.number, - graphRevision: snapshot.graph.revision, - }), - }), - ); - setRefresh(value => value + 1); + if (await startBuild(room, snapshot) === "needs-agent") setNeedsAgent(true); + else setRefresh(value => value + 1); } catch (error) { - if (offline(error)) setNeedsAgent(true); - else setActionError(sentence(error instanceof Error ? error.message : "Build failed")); + setActionError(sentence(error instanceof Error ? error.message : "Build failed")); } finally { setBusy(false); } diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 40f87eded..b519b1dc1 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -47,6 +47,7 @@ import { visibleDecisionView, } from "@chopin/editor"; +import { BuildPlanButton } from "./build-plan-button"; import { BuildView } from "./build-view"; import { Chat } from "./chat/chat"; import { ChildPhoneChromeContext, sameEditState } from "./child-phone-chrome"; @@ -513,6 +514,7 @@ export function RoomWorkspace( locked: false, }); let [showImplementation, setShowImplementation] = useState(false); + let [needsAgent, setNeedsAgent] = useState(0); useEffect(() => { setImplementation({ revision: -1, locked: false }); let update = (value: Plan.ImplementationStatus) => { @@ -1306,6 +1308,17 @@ export function RoomWorkspace( : undefined} synced={planState.synced} /> + {profile.implementation && workspaceCanEdit && status === "connected" && ( + { + setNeedsAgent(count => count + 1); + selectDestination("build"); + }} + onShowBuild={() => selectDestination("build")} + room={room} + wire={wire} + /> + )} ); let plan = ( @@ -1588,6 +1601,7 @@ export function RoomWorkspace( canEdit={!!workspaceCanEdit && status === "connected"} chatLoaded={chatActivity.loaded} comments={acceptedComments} + needsAgentSignal={needsAgent} onShowDecisions={() => selectDestination("decisions")} onShowDocument={() => selectDestination("plan")} planner={agent} diff --git a/e2e/jev.ts b/e2e/jev.ts index c374f6458..475237904 100644 --- a/e2e/jev.ts +++ b/e2e/jev.ts @@ -429,6 +429,15 @@ let fake = async (input: Parameters[0], init?: Parameters= 120 ? 0.9 : 0.1 } }, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + } let scenario = current?.text && SCENARIOS[current.text]; if (!current?.id || !scenario) { return Response.json({ error: "unknown Jev fixture scenario" }, { status: 501 }); diff --git a/packages/protocol/implementation.d.ts b/packages/protocol/implementation.d.ts index d6612c392..a68039196 100644 --- a/packages/protocol/implementation.d.ts +++ b/packages/protocol/implementation.d.ts @@ -95,6 +95,11 @@ export type ImplementationSnapshot = { /** Whether the viewer has a connected local agent for this repository; it may be busy. */ localAgent: boolean; blockers: string[]; + /** + * The document reads as complete enough to build and nothing blocks it. + * Judged in memory once edits settle, so it may lag the latest revision. + */ + buildReady: boolean; lifecycle: Pick; live?: LiveSnapshot; }; @@ -109,7 +114,7 @@ export type ImplementationSnapshot = { */ export declare namespace Implementation { export type Incoming = Request; - export type Outgoing = Drafted | Drafting; + export type Outgoing = Drafted | Drafting | Readiness; /** Draft or revise the tasks for the document as it now stands. */ export type Draft = KIND<"implementation:draft"> & { @@ -133,4 +138,10 @@ export declare namespace Implementation { planRevision: number; state: "queued" | "running" | "ended"; }; + + /** Broadcast when a document's build readiness judgement changes; read the snapshot again. */ + export type Readiness = KIND<"implementation:readiness"> & { + planRevision: number; + ready: boolean; + }; } From d9e562caa35020bdbdd4b2ecbfa7205bb77b5cdf Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:57:08 +0100 Subject: [PATCH 02/13] Show a living document's sync status and its commits in Build Once the first build has delivered, the header's Build plan slot becomes a quiet status: Building while a rebuild runs, In sync, or Out of sync with a hint saying whether the edits will build shortly, are waiting for the builder's agent, or failed to rebuild. Build lists every rebuild's tasks after the first build's and each pull request's commits, newest first. The server announces the implementation when an edit flips the document in or out of sync, and the snapshot carries the builder's login and the live task list. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/plan/service.ts | 7 +- apps/server/src/tasks/builds.ts | 13 +++ apps/server/src/tasks/live-sync.test.ts | 34 ++++++++ apps/server/src/tasks/routes.ts | 7 +- apps/web/src/build-model.test.ts | 104 +++++++++++++++++++++++- apps/web/src/build-model.ts | 76 ++++++++++++++++- apps/web/src/build-plan-button.tsx | 45 +++++++++- apps/web/src/build-view.css | 29 +++++++ apps/web/src/build-view.tsx | 69 ++++++++++++++-- apps/web/src/room-workspace.tsx | 1 + packages/protocol/implementation.d.ts | 8 ++ 11 files changed, 377 insertions(+), 16 deletions(-) diff --git a/apps/server/src/plan/service.ts b/apps/server/src/plan/service.ts index 9516386f3..97865eb51 100644 --- a/apps/server/src/plan/service.ts +++ b/apps/server/src/plan/service.ts @@ -1,5 +1,5 @@ import { locksEditing, restoreBuilds, restoreLive } from "../tasks/builds"; -import { implementationStatus } from "../tasks/notifications"; +import { announceImplementation, implementationStatus } from "../tasks/notifications"; import type { BuildRequest } from "@chopin/protocol/implementation"; /** * The plan, as a room offers it. @@ -886,6 +886,7 @@ async function commitHosted( } try { let sourceChanged = captured.source !== durable.committedSource; + let wasInSync = durable.committedSource === plan.live?.baseSource; let result = await durable.storage.collaboration.commit({ channelId: durable.channelId, lease: durable.lease(), @@ -924,6 +925,10 @@ async function commitHosted( durable.committedDocument = captured.document; durable.committedSidecar = captured.sidecar; announceUnanswered(plan); + // A living document's sync status flips only here, between rebuilds. + if (plan.live && wasInSync !== (captured.source === plan.live.baseSource)) { + announceImplementation(plan); + } if (plan.document.epoch === captured.epoch) { plan.document.checkpoint = new Uint8Array(captured.document); } diff --git a/apps/server/src/tasks/builds.ts b/apps/server/src/tasks/builds.ts index 72b0980c4..ef234c631 100644 --- a/apps/server/src/tasks/builds.ts +++ b/apps/server/src/tasks/builds.ts @@ -868,6 +868,18 @@ 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)); + // 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) : []; + let tasks = (plan.graph?.versions ?? []).filter(version => + first !== undefined && version.number >= first + ).flatMap(version => { + let progress = history.findLast(item => item.run.graphVersion === version.number)?.progress; + return version.definition.tasks.map(task => { + let report = progress?.tasks.find(item => item.id === task.id); + return { ...structuredClone(task), ...(report ? { progress: structuredClone(report) } : {}) }; + }); + }); let unfinished = outstandingTasks(plan).map(({ id, title, state, blocker }) => ({ id, title, @@ -877,6 +889,7 @@ export function liveSnapshot( return { ...structuredClone(rest), commits: structuredClone(commits ?? []), + tasks, ...(unfinished.length ? { outstandingTasks: unfinished } : {}), ...(rebuild ? { rebuild } : {}), outOfSync: source(plan) !== baseSource, diff --git a/apps/server/src/tasks/live-sync.test.ts b/apps/server/src/tasks/live-sync.test.ts index e8b6270c3..ceb1fef29 100644 --- a/apps/server/src/tasks/live-sync.test.ts +++ b/apps/server/src/tasks/live-sync.test.ts @@ -165,6 +165,40 @@ async function settle(plan: Plan) { } describe("living-document sync", () => { + it("tells viewers only when an edit flips the document in or out of sync", async () => { + let { plan } = await harness(); + let frames: string[] = []; + spyOn(plan.server, "publish").mockImplementation( + ((_topic: string, data: string) => { + if (data.includes('"plan:implementation"')) frames.push(data); + return 0; + }) as never, + ); + let write = async (text: string, key: string) => { + let result = await rewrite(plan, text, (value, revision) => ({ + idempotencyKey: key, + fingerprint: key, + fromRevision: plan.revision, + client: { name: "test", version: "1" }, + document: { source: value, revision, title: "Living document", url: "https://chopin.test" }, + })); + expect(result.ok).toBe(true); + await settle(plan); + }; + await write("The built paragraph.\n", "built"); + expect(frames).toHaveLength(0); + // As a landed rebuild leaves it: the live base is the current source. + let built = source(plan); + plan.live = { ...plan.live!, baseSource: built }; + await write(`${built}\nOne edit.\n`, "out-1"); + expect(frames).toHaveLength(1); + await write(`${built}\nTwo edits.\n`, "out-2"); + expect(frames).toHaveLength(1); + await write(built, "back"); + expect(frames).toHaveLength(2); + await close(plan); + }); + it("waits for 45 seconds of quiet before rebuilding", async () => { let { plan, queued, edit, advance } = await harness(); edit(); diff --git a/apps/server/src/tasks/routes.ts b/apps/server/src/tasks/routes.ts index 49a774e6a..aaa32657b 100644 --- a/apps/server/src/tasks/routes.ts +++ b/apps/server/src/tasks/routes.ts @@ -157,7 +157,12 @@ export function registerImplementationRoutes(router: Router, auth: HostedAuth, o })); // Read outside the plan lock; a missing user only loses the attribution. let login = current.build && (await auth.storage.users.get(current.build.user))?.login; - return json(login ? { ...current, startedBy: login } : current); + let builder = current.live && (await auth.storage.users.get(current.live.user))?.login; + return json({ + ...current, + ...(login ? { startedBy: login } : {}), + ...(builder ? { builtBy: builder } : {}), + }); }), ); router.on( diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index 1cade9aeb..cb336cf75 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -3,18 +3,26 @@ import { describe, expect, it } from "bun:test"; import { advanceDraft, advanceFirstBuild, + ago, buildPhase, draftKey, draftRefusalCopy, elapsed, firstBuildStep, + pullRequestCommits, pullRequestNumber, shouldAutoDraft, startedBy, + syncHint, + syncStatus, taskStartsOpen, } from "./build-model"; -import type { BuildRequest, ImplementationSnapshot } from "@chopin/protocol/implementation"; +import type { + BuildRequest, + ImplementationSnapshot, + LiveSnapshot, +} from "@chopin/protocol/implementation"; type History = ImplementationSnapshot["lifecycle"]["history"][number]; @@ -345,3 +353,97 @@ describe("first build", () => { expect(advanceFirstBuild(state, { type: "reset" })).toEqual(idle); }); }); + +const PR = "https://github.com/o/r/pull/7"; + +function live(change: Partial = {}): ImplementationSnapshot { + return snapshot({ + live: { + buildId: "b", + user: "u", + repositoryId: "r", + checkout: { repository: "o/r", commit: "0".repeat(40) }, + baseRevision: 4, + pullRequests: [PR], + commits: [], + tasks: [], + outOfSync: false, + builderConnected: true, + ...change, + }, + }); +} + +function rebuild(state: BuildRequest["state"]): BuildRequest { + return { ...build(state), id: "r1", kind: "rebuild", baseRevision: 4, targetRevision: 6 }; +} + +describe("living document sync", () => { + it("is absent until the first build has delivered", () => { + expect(syncStatus(snapshot())).toBeUndefined(); + expect(syncStatus(undefined)).toBeUndefined(); + }); + + it("reads as in sync when the pull requests match the document", () => { + expect(syncStatus(live())).toEqual({ kind: "in-sync" }); + expect(buildPhase(live())).toEqual({ kind: "live", sync: { kind: "in-sync" } }); + }); + + it("reads as building while a rebuild or the first build runs", () => { + for (let state of ["queued", "starting", "running"] as const) { + expect(syncStatus(live({ outOfSync: true, rebuild: rebuild(state) }))) + .toEqual({ kind: "building" }); + } + expect(syncStatus({ ...live(), build: build("running") })).toEqual({ kind: "building" }); + }); + + it("explains why it is out of sync", () => { + let pending = live({ outOfSync: true, rebuild: rebuild("stopped") }); + expect(syncStatus(pending)).toEqual({ kind: "out-of-sync", reason: "pending" }); + expect(syncHint(syncStatus(pending), pending, "me")).toBe("Changes will build shortly"); + let waiting = { ...live({ outOfSync: true, builderConnected: false }), builtBy: "jev" }; + expect(syncStatus(waiting)).toEqual({ kind: "out-of-sync", reason: "waiting" }); + expect(syncHint(syncStatus(waiting), waiting, "me")).toBe("Waiting for @jev’s agent"); + expect(syncHint(syncStatus(waiting), waiting, "u")).toBe("Waiting for your agent"); + let failed = live({ outOfSync: true, rebuild: rebuild("failed") }); + expect(syncStatus(failed)).toEqual({ kind: "out-of-sync", reason: "failed" }); + expect(syncHint(syncStatus(failed), failed, "me")).toBe("The last rebuild failed"); + }); + + it("does not call a failed rebuild out of sync once later edits match", () => { + expect(syncStatus(live({ rebuild: rebuild("failed") }))).toEqual({ kind: "in-sync" }); + expect(syncHint({ kind: "in-sync" }, live(), "me")).toBeUndefined(); + }); +}); + +describe("living document commits", () => { + let commit = (sha: string, at: string, pullRequest = PR) => ({ + pullRequest, + sha, + message: sha, + revision: 5, + at, + }); + + it("groups one pull request's commits, newest first", () => { + let value = live({ + commits: [ + commit("a", "2026-10-10T10:00:00.000Z"), + commit("b", "2026-10-10T11:00:00.000Z", "https://github.com/o/r/pull/8"), + commit("c", "2026-10-10T12:00:00.000Z"), + commit("d", "2026-10-10T12:00:00.000Z"), + ], + }); + expect(pullRequestCommits(value, PR).map(item => item.sha)).toEqual(["d", "c", "a"]); + expect(pullRequestCommits(snapshot(), PR)).toEqual([]); + }); + + it("says how long ago a commit landed", () => { + let now = Date.parse("2026-10-10T12:00:00.000Z"); + expect(ago("2026-10-10T11:59:40.000Z", now)).toBe("just now"); + expect(ago("2026-10-10T11:55:00.000Z", now)).toBe("5m ago"); + expect(ago("2026-10-10T09:00:00.000Z", now)).toBe("3h ago"); + expect(ago("2026-10-08T12:00:00.000Z", now)).toBe("2d ago"); + expect(ago("nonsense", now)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index 654fd70e9..970e4d95d 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -17,7 +17,79 @@ export type BuildPhase = | { kind: "failed" } /** The build ended while its run still holds the tasks and the document lock. */ | { kind: "stopped" } - | { kind: "done"; pullRequests: number }; + | { kind: "done"; pullRequests: number } + /** A living document: its pull requests follow later edits. */ + | { kind: "live"; sync: SyncStatus }; + +/** + * Whether a living document's pull requests match it. `out-of-sync` says why: + * `pending` waits for the builder's connected agent to pick the edits up, + * `waiting` for that agent to connect, and `failed` that the last rebuild failed. + */ +export type SyncStatus = + | { kind: "building" } + | { kind: "in-sync" } + | { kind: "out-of-sync"; reason: "pending" | "waiting" | "failed" }; + +const RUNNING = ["queued", "starting", "running"]; + +/** + * The living document's sync status, or `undefined` before its first build has delivered. + * The snapshot's `build` is the first build, or nothing once a rebuild adds a task version, + * so a running rebuild is read from `live.rebuild`. + */ +export function syncStatus(snapshot: Snapshot | undefined): SyncStatus | undefined { + let live = snapshot?.live; + if (!snapshot || !live) return; + if ( + live.rebuild && RUNNING.includes(live.rebuild.state) + || snapshot.build && RUNNING.includes(snapshot.build.state) + || snapshot.lifecycle.execution.state === "active" + ) return { kind: "building" }; + if (!live.outOfSync) return { kind: "in-sync" }; + if (live.rebuild?.state === "failed") return { kind: "out-of-sync", reason: "failed" }; + return { kind: "out-of-sync", reason: live.builderConnected ? "pending" : "waiting" }; +} + +export const SYNC_LABEL: Record = { + building: "Building…", + "in-sync": "In sync", + "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, + snapshot: Snapshot | undefined, + userId: string | undefined, +): string | undefined { + if (status?.kind !== "out-of-sync") return; + if (status.reason === "pending") return "Changes will build shortly"; + if (status.reason === "failed") return "The last rebuild failed"; + if (snapshot?.live?.user === userId) return "Waiting for your agent"; + return snapshot?.builtBy + ? `Waiting for @${snapshot.builtBy}’s agent` + : "Waiting for the builder’s agent"; +} + +/** 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 })) + .filter(item => item.commit.pullRequest === url) + .sort((a, b) => Date.parse(b.commit.at) - Date.parse(a.commit.at) || b.index - a.index) + .map(item => item.commit); +} + +/** "just now", "5m ago", "3h ago" or "2d ago". */ +export function ago(at: string, now: number): string | undefined { + let start = Date.parse(at); + if (!Number.isFinite(start)) return; + let minutes = Math.max(0, Math.floor((now - start) / 60_000)); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + let hours = Math.floor(minutes / 60); + return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`; +} function latestRun(snapshot: Snapshot) { let graph = snapshot.graph; @@ -34,6 +106,8 @@ export function buildProgress(snapshot: Snapshot | undefined): Progress | undefi export function buildPhase(snapshot: Snapshot | undefined): BuildPhase { if (!snapshot) return { kind: "loading" }; + let sync = syncStatus(snapshot); + if (sync) return { kind: "live", sync }; let run = latestRun(snapshot); let active = snapshot.lifecycle.execution.state === "active"; let build = snapshot.build; diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx index 6780f8347..8924c208c 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -1,7 +1,15 @@ -import { LoaderIcon } from "@chopin/icons"; +import { CheckIcon, LoaderIcon } from "@chopin/icons"; import { useEffect, useReducer, useRef, useState } from "react"; -import { advanceFirstBuild, buildPhase, draftRefusalCopy, firstBuildStep } from "./build-model"; +import { + advanceFirstBuild, + buildPhase, + draftRefusalCopy, + firstBuildStep, + SYNC_LABEL, + syncHint, + syncStatus, +} from "./build-model"; import { implementationEndpoint, implementationResponse, startBuild } from "./build-start"; import type { Implementation, ImplementationSnapshot } from "@chopin/protocol/implementation"; @@ -18,13 +26,16 @@ const RELOAD_ON = [ /** * 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. */ export function BuildPlanButton( - { onNeedsAgent, onShowBuild, room, wire }: { + { onNeedsAgent, onShowBuild, room, userId, wire }: { /** No local agent could take the build; the Build view explains how to start one. */ onNeedsAgent: () => void; onShowBuild: () => void; room: string; + userId?: string; wire?: Wire; }, ) { @@ -119,6 +130,34 @@ export function BuildPlanButton( }); }, [step.next, snapshot?.revision]); + let sync = syncStatus(snapshot); + if (sync) { + let hint = syncHint(sync, snapshot, userId); + return ( + + ); + } if (step.view === "hidden") return null; if (step.view === "working") { return ( diff --git a/apps/web/src/build-view.css b/apps/web/src/build-view.css index 396004643..b5d4c6664 100644 --- a/apps/web/src/build-view.css +++ b/apps/web/src/build-view.css @@ -300,6 +300,35 @@ color: var(--color-warning-ink); } +/* A living document's commits on a task's pull request, newest first. */ +.build-task-commits { + display: grid; + gap: var(--spacing); + margin: 0; + padding: 0; + list-style: none; +} + +.build-task-commits > li { + display: flex; + align-items: baseline; + gap: calc(var(--spacing) * 2); + min-width: 0; +} + +.build-task-sha { + flex: none; + color: var(--color-text-tertiary); + font-family: var(--font-mono); + font-size: var(--text-xs); + text-decoration: none; +} + +.build-task-sha:hover { + color: var(--color-brand); + text-decoration: underline; +} + .build-hint { margin: calc(var(--spacing) * 3) 0 0; color: var(--color-text-tertiary); diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index deb67b939..031698c40 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -2,6 +2,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { advanceDraft, + ago, buildPhase, buildProgress, draftInFlight, @@ -9,9 +10,12 @@ import { draftRefusalCopy, elapsed, plural, + pullRequestCommits, pullRequestNumber, shouldAutoDraft, startedBy, + SYNC_LABEL, + syncHint, TASK_STATE_LABEL, taskStartsOpen, } from "./build-model"; @@ -216,7 +220,7 @@ export function BuildView( return () => window.clearTimeout(timer); }, [request?.state, request?.key]); - let pending = phase.kind === "building"; + let pending = phase.kind === "building" || phase.kind === "live"; useEffect(() => { if (!pending || !active) return; setNow(Date.now()); @@ -287,7 +291,10 @@ export function BuildView( } }; - let tasks = snapshot?.graph?.definition.tasks ?? []; + // A living document lists its first build's tasks and every rebuild's after them. + let tasks = phase.kind === "live" + ? snapshot?.live?.tasks ?? [] + : snapshot?.graph?.definition.tasks ?? []; // Drafting: our turn is under way, or Build is about to ask, or it failed, or a person decides. let awaitingAuto = !!key && key === activation.current.eligible && autoSent !== activation.current.id && !drafted.has(key); @@ -408,9 +415,26 @@ export function BuildView( ); } + } else if (phase.kind === "live") { + let commits = snapshot?.live?.commits.length ?? 0; + let hint = syncHint(phase.sync, snapshot, userId); + status = ( + <> + {phase.sync.kind === "building" &&