From b87e897d7710ea517d42bc91927ead8df5493fe3 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Fri, 16 Oct 2026 14:38:16 -0700 Subject: [PATCH 1/4] Date the Build card from when a build finished, not when it was asked for The Build card's footer counted a finished build's age from createdAt, the moment Build plan was pressed, so a build that ran for an hour read "built 1h ago" as soon as it finished. A build now records finishedAt when it stops or fails, including a landed rebuild, and the card dates a finished build from it. A running build is still dated from its start; a finished build stored before finishedAt was recorded shows no time rather than a wrong one. Fixes #659 Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline (fast subagents) Assistant-verification: red-before-fix: builds.test.ts "a build records when it finished" failed (finishedAt undefined); plan-graphs.test.ts landed-rebuild assertion failed; build-model.test.ts buildAgo missing Assistant-verification: red-before-fix: e2e implementation-launcher "a build that ran for an hour ..." failed on the unfixed client with "built 1h ago" (expected "built 2m ago") Assistant-verification: after fix: those unit tests, implementation-launcher.e2e.ts (all) and the full bun test run passed Co-authored-by: Alex Lavaee --- apps/server/src/tasks/builds.test.ts | 35 ++++++++++ apps/server/src/tasks/builds.ts | 11 +++- apps/server/src/tasks/plan-graphs.test.ts | 2 + apps/server/src/testing/plan.ts | 2 + apps/web/src/build-model.test.ts | 20 ++++++ apps/web/src/build-model.ts | 18 +++++- apps/web/src/build-view.tsx | 9 +-- e2e/build-seed.ts | 79 ++++++++++++++++++++++- e2e/database.ts | 1 + e2e/implementation-launcher.e2e.ts | 16 ++++- packages/protocol/implementation.d.ts | 2 + 11 files changed, 187 insertions(+), 8 deletions(-) diff --git a/apps/server/src/tasks/builds.test.ts b/apps/server/src/tasks/builds.test.ts index 25cd71ecf..14308bbcf 100644 --- a/apps/server/src/tasks/builds.test.ts +++ b/apps/server/src/tasks/builds.test.ts @@ -163,6 +163,41 @@ test("a duplicate review cannot silently select another owner or checkout", asyn } }); +test("a build records when it finished, apart from when it was asked for", async () => { + let { plan } = await prepared(); + let input = { + planRevision: 0, + graphVersion: 1, + graphRevision: 1, + user: "U_test", + connectionId: "laptop", + machineId: "M_laptop", + repositoryId: "R_test", + checkout, + }; + try { + let first = await queueBuild(plan, input); + // Asked for an hour before it finishes. + plan.builds[0] = { + ...plan.builds[0]!, + createdAt: new Date(Date.now() - 3_600_000).toISOString(), + }; + await pickBuild(plan, input.user, input.connectionId); + await reportBuild(plan, input.user, input.connectionId, first.id, { state: "running" }); + expect(plan.builds[0]?.finishedAt).toBeUndefined(); + let before = Date.now(); + await reportBuild(plan, input.user, input.connectionId, first.id, { state: "stopped" }); + let finishedAt = plan.builds[0]?.finishedAt; + expect(finishedAt).toBeDefined(); + expect(Date.parse(finishedAt!)).toBeGreaterThanOrEqual(before - 1); + // The same report again, as a retried connector request, keeps the first time. + await reportBuild(plan, input.user, input.connectionId, first.id, { state: "stopped" }); + expect(plan.builds[0]?.finishedAt).toBe(finishedAt); + } finally { + await Plan.close(plan); + } +}); + test("a build stored before machines were recorded still restores, with no machine", () => { let legacy = { id: crypto.randomUUID(), diff --git a/apps/server/src/tasks/builds.ts b/apps/server/src/tasks/builds.ts index d5878dd0a..8c188138f 100644 --- a/apps/server/src/tasks/builds.ts +++ b/apps/server/src/tasks/builds.ts @@ -45,6 +45,7 @@ let buildSchema = z.object({ graphVersion: z.number().int().positive(), graphRevision: z.number().int().positive(), createdAt: z.string().datetime(), + finishedAt: z.string().datetime().optional(), expiresAt: z.number().int().nonnegative(), state: z.enum(["queued", "starting", "running", "stopped", "failed"]), session: z.string().min(1).max(128).optional(), @@ -523,6 +524,11 @@ export function reportBuild( ...current, ...fields, ...(stoppedByUser ? { stoppedAtRevision: plan.revision } : {}), + // A build's age is counted from when it finished, not from when it was asked for. + ...(current.state !== report.state + && (report.state === "stopped" || report.state === "failed") + ? { finishedAt: new Date().toISOString() } + : {}), ...(progress && progress !== current.activity?.text ? { activity: { text: progress, at: new Date().toISOString() } } : {}), @@ -943,7 +949,10 @@ export function reportRebuild( } : {}), }; - plan.builds = [...plan.builds.slice(0, -1), buildSchema.parse({ ...build, state: "stopped" })]; + plan.builds = [ + ...plan.builds.slice(0, -1), + buildSchema.parse({ ...build, state: "stopped", finishedAt: at }), + ]; try { await persistExclusive(plan, true); } catch { diff --git a/apps/server/src/tasks/plan-graphs.test.ts b/apps/server/src/tasks/plan-graphs.test.ts index c628bfa4d..ecac8ec44 100644 --- a/apps/server/src/tasks/plan-graphs.test.ts +++ b/apps/server/src/tasks/plan-graphs.test.ts @@ -1016,6 +1016,8 @@ describe("living-document builds", () => { }), ).toEqual({ kind: "accepted" }); expect(plan.live?.outstanding).toBeUndefined(); + // The landed rebuild is dated from when it landed, not from when it was asked for. + expect(plan.builds.at(-1)).toMatchObject({ state: "stopped", finishedAt: expect.any(String) }); expect(historyFor(plan.graph!, plan.lifecycle)[0]!.progress.tasks[0]).toMatchObject({ id: "model", state: "completed", diff --git a/apps/server/src/testing/plan.ts b/apps/server/src/testing/plan.ts index 9b82352d5..861b8a87c 100644 --- a/apps/server/src/testing/plan.ts +++ b/apps/server/src/testing/plan.ts @@ -16,6 +16,7 @@ import type { SocketData } from "../wire"; export type SeedState = { graph?: import("../tasks/graphs").Graph; builds?: import("@chopin/protocol/implementation").BuildRequest[]; + lifecycle?: import("../tasks/lifecycle").Lifecycle; revision?: number; questions?: unknown[]; openQuestions?: unknown[]; @@ -187,6 +188,7 @@ export async function checkpointSource( transcript: state.transcript ?? [], ...(state.graph ? { graph: state.graph } : {}), ...(state.builds ? { builds: state.builds } : {}), + ...(state.lifecycle ? { lifecycle: state.lifecycle } : {}), } as JsonValue; let document = await Room.create(source); let canonical = Room.project(document); diff --git a/apps/web/src/build-model.test.ts b/apps/web/src/build-model.test.ts index 10c53fad1..314eaf844 100644 --- a/apps/web/src/build-model.test.ts +++ b/apps/web/src/build-model.test.ts @@ -6,6 +6,7 @@ import { attentionHint, blockerLabelled, blockerText, + buildAgo, buildLabel, buildLook, buildPhase, @@ -648,6 +649,25 @@ describe("living document commits", () => { expect(ago("2026-10-08T12:00:00.000Z", now)).toBe("2d ago"); expect(ago("nonsense", now)).toBeUndefined(); }); + + it("dates a finished build from when it finished, not when it was asked for", () => { + let now = Date.parse("2026-10-10T12:00:00.000Z"); + let asked = "2026-10-10T11:00:00.000Z"; + expect( + buildAgo({ state: "stopped", createdAt: asked, finishedAt: "2026-10-10T11:59:50.000Z" }, now), + ) + .toBe("just now"); + expect( + buildAgo({ state: "failed", createdAt: asked, finishedAt: "2026-10-10T11:55:00.000Z" }, now), + ) + .toBe("5m ago"); + // A running build is dated from when it was asked for; a queued one has not started. + expect(buildAgo({ state: "running", createdAt: asked }, now)).toBe("1h ago"); + expect(buildAgo({ state: "queued", createdAt: asked }, now)).toBeUndefined(); + // A build that finished before its finish was recorded has no time to show, not a wrong one. + expect(buildAgo({ state: "stopped", createdAt: asked }, now)).toBeUndefined(); + expect(buildAgo(undefined, now)).toBeUndefined(); + }); }); describe("build status", () => { diff --git a/apps/web/src/build-model.ts b/apps/web/src/build-model.ts index 1c2bdcc6b..23a982d9f 100644 --- a/apps/web/src/build-model.ts +++ b/apps/web/src/build-model.ts @@ -1,4 +1,4 @@ -import type { ImplementationSnapshot } from "@chopin/protocol/implementation"; +import type { BuildRequest, ImplementationSnapshot } from "@chopin/protocol/implementation"; type Snapshot = ImplementationSnapshot; type Progress = NonNullable; @@ -243,6 +243,22 @@ export function ago(at: string, now: number): string | undefined { return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`; } +/** + * How long ago a build ran: a running one from when it was asked for, a finished one from when it + * finished. A queued build has not started, and one that finished before its finish was recorded + * has no time to show rather than a wrong one. + */ +export function buildAgo( + build: { state: BuildRequest["state"]; createdAt?: string; finishedAt?: string } | undefined, + now: number, +): string | undefined { + if (!build || build.state === "queued") return; + let at = build.state === "stopped" || build.state === "failed" + ? build.finishedAt + : build.createdAt; + return at ? ago(at, now) : undefined; +} + function latestRun(snapshot: Snapshot) { let graph = snapshot.graph; return snapshot.lifecycle.history.findLast(item => diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx index 1a21ddeb7..40db1b294 100644 --- a/apps/web/src/build-view.tsx +++ b/apps/web/src/build-view.tsx @@ -15,6 +15,7 @@ import { attentionHint, blockerLabelled, blockerText, + buildAgo, buildLabel, buildPhase, buildProgress, @@ -331,8 +332,9 @@ export function BuildView( let card = phase.kind === "loading" ? undefined : statusCard(); // The build the card's footer names: the one running, else the last that ran. - let shown: Pick & { createdAt?: string } | undefined = - look.kind === "plan" || look.kind === "tasks" + let shown: + | Pick & { createdAt?: string; finishedAt?: string } + | undefined = look.kind === "plan" || look.kind === "tasks" ? undefined : activeBuild ?? (phase.kind === "live" && snapshot?.live ? snapshot.live.rebuild @@ -348,8 +350,7 @@ export function BuildView( : undefined ); let machine = shown && snapshot?.buildMachines?.[shown.id]; - // A queued build has not started, so it has no time to show. - let when = shown?.createdAt && shown.state !== "queued" ? ago(shown.createdAt, now) : undefined; + let when = buildAgo(shown, now); if (phase.kind === "stopped" && canEdit && !returning) { action = ( diff --git a/e2e/build-seed.ts b/e2e/build-seed.ts index ac252d997..5bbbc2365 100644 --- a/e2e/build-seed.ts +++ b/e2e/build-seed.ts @@ -1,5 +1,6 @@ import type { BuildRequest } from "../packages/protocol/implementation"; -import type { Graph } from "../apps/server/src/tasks/graphs"; +import type { Graph, Run } from "../apps/server/src/tasks/graphs"; +import { type Lifecycle, transition } from "../apps/server/src/tasks/lifecycle"; /** A first build of the graph's latest tasks that failed before it claimed them, so Build is on. */ export function failedBuild(graph: Graph): BuildRequest { @@ -19,3 +20,79 @@ export function failedBuild(graph: Graph): BuildRequest { error: "The agent did not start.", }; } + +/** + * A first build of the graph's latest tasks that delivered a pull request for each and passed + * review, asked for at `asked` and finished at `finished`: the document reads as built. + */ +export function builtBuild( + draft: Graph, + asked: Date, + finished: Date, +): { graph: Graph; builds: BuildRequest[]; lifecycle: Lifecycle } { + let { error: _error, ...build } = failedBuild(draft); + let version = draft.versions.at(-1)!; + let run: Run = { + id: build.id, + user: build.user, + client: { name: "Fake implementer", version: "1.0.0" }, + session: "seeded", + planRevision: version.planRevision, + graphVersion: version.number, + graphRevision: version.revision, + repository: build.checkout.repository, + branch: "main", + commit: build.checkout.commit, + startedAt: asked.toISOString(), + }; + let state: Parameters[0] = { + graph: { versions: [...draft.versions.slice(0, -1), { ...version, state: "locked" }] }, + execution: run, + lifecycle: { history: [] }, + }; + let apply = (input: Parameters[1]) => { + let result = transition(state, input); + if (result.kind === "refused") { + throw new Error(`The seeded build was refused: ${result.reason}`); + } + state = result.state; + }; + for (let [index, task] of version.definition.tasks.entries()) { + apply({ kind: "start", runId: run.id, taskId: task.id, idempotencyKey: `start-${task.id}` }); + apply({ + kind: "report_pr", + runId: run.id, + taskId: task.id, + url: `https://github.com/octo-org/score/pull/${index + 1}`, + state: "open", + idempotencyKey: `pr-${task.id}`, + }); + apply({ + kind: "complete", + runId: run.id, + taskId: task.id, + summary: `Completed ${task.id}.`, + idempotencyKey: `complete-${task.id}`, + }); + } + apply({ + kind: "report_verification", + runId: run.id, + passed: true, + summary: "Every acceptance criterion passed.", + reviewerMethod: "Read the pull requests.", + evidence: version.definition.tasks.map(task => ({ taskId: task.id, evidence: ["Seeded."] })), + tasksNeedingWork: [], + idempotencyKey: "verified", + }); + return { + graph: state.graph, + builds: [{ + ...build, + createdAt: asked.toISOString(), + finishedAt: finished.toISOString(), + state: "stopped", + }], + lifecycle: state.lifecycle, + }; +} diff --git a/e2e/database.ts b/e2e/database.ts index 77ba5f61d..8748b31c5 100644 --- a/e2e/database.ts +++ b/e2e/database.ts @@ -197,6 +197,7 @@ async function seed( transcript: state.transcript ?? [], ...(state.graph ? { graph: state.graph } : {}), ...(state.builds ? { builds: state.builds } : {}), + ...(state.lifecycle ? { lifecycle: state.lifecycle } : {}), }; await sql(port, async database => { await database` diff --git a/e2e/implementation-launcher.e2e.ts b/e2e/implementation-launcher.e2e.ts index 8f873bd74..ad0ffcff1 100644 --- a/e2e/implementation-launcher.e2e.ts +++ b/e2e/implementation-launcher.e2e.ts @@ -3,7 +3,7 @@ import { execFileSync, spawn } from "node:child_process"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { failedBuild } from "./build-seed"; +import { builtBuild, failedBuild } from "./build-seed"; import { seedChildChannel, testChannelPath } from "./database"; import { ROOT } from "./servers"; import { storedQuestion } from "../apps/server/src/testing/plan"; @@ -761,6 +761,20 @@ test("a build in progress shows who started it and offers no action", async ({ s } }); +test("a build that ran for an hour reads as built when it finished, not when it was asked for", async ({ seed, join: enter }) => { + let now = Date.now(); + await seed( + "# Long build\n\nIt took an hour to build.\n", + builtBuild(preparedGraph(), new Date(now - 65 * 60_000), new Date(now - 2 * 60_000)), + ); + let page = await enter("ana"); + await page.getByRole("group", { name: "Document view" }) + .getByRole("button", { name: "Build", exact: true }).click(); + let view = buildView(page); + await expect(view.getByText("Built", { exact: true })).toBeVisible(); + await expect(view.getByText(/^built /)).toHaveText("built 2m ago"); +}); + test("on a phone, tapping Build or an inert Build plan says why, briefly", async ({ seed, join: enter }) => { await seed("# Phone build\n\nNot built yet.\n"); let page = await enter("ana", { diff --git a/packages/protocol/implementation.d.ts b/packages/protocol/implementation.d.ts index 42a464a33..846d9d1b5 100644 --- a/packages/protocol/implementation.d.ts +++ b/packages/protocol/implementation.d.ts @@ -19,6 +19,8 @@ export type BuildRequest = { graphVersion: number; graphRevision: number; createdAt: string; + /** When it stopped or failed; a build that finished before this was recorded has none. */ + finishedAt?: string; expiresAt: number; state: "queued" | "starting" | "running" | "stopped" | "failed"; session?: string; From 5f691bee21496f6e3b0e5d5649166a35ce41eecb Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Fri, 16 Oct 2026 14:38:28 -0700 Subject: [PATCH 2/4] Show a renamed document's current address on the machine running its Planner The Planner poll names documents by id, and Chopin answered it only when the set of ids changed, so a rename never reached the connector; the connector also kept the URL of a backend it already ran. Chopin now remembers the address it last told each connection for each document and answers the poll again, once, when a document's address has changed, and a document rename wakes the poll. The connector takes the new address for a running backend: chopin status, its output and the backend's next start use it, without restarting the backend. The answer is the existing planners shape, so no protocol bump. The connector change needs a chopin CLI release to reach machines. Fixes #660 Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline (fast subagents) Assistant-verification: red-before-fix: planner-dispatch.test.ts "tells the machine a renamed document's current address, once" got { waiting: true }; connector planner.test.ts rename test recorded the old address Assistant-verification: after fix: both pass; planner-dispatch.test.ts and planner.test.ts in full, machine-choice, machines, planner-attachment and connector e2e passed Co-authored-by: Alex Lavaee --- apps/connector/src/planner.test.ts | 81 +++++++++++++++++++ apps/connector/src/planner.ts | 27 ++++++- apps/server/src/experiments/routes.ts | 19 ++++- .../harness/remote/planner-dispatch.test.ts | 23 ++++++ apps/server/src/main.ts | 2 + docs/agent-connection.md | 5 +- 6 files changed, 151 insertions(+), 6 deletions(-) diff --git a/apps/connector/src/planner.test.ts b/apps/connector/src/planner.test.ts index f229e91bb..2fbcf38f0 100644 --- a/apps/connector/src/planner.test.ts +++ b/apps/connector/src/planner.test.ts @@ -1129,6 +1129,87 @@ test.skipIf(process.platform === "win32")( 40_000, ); +test.skipIf(process.platform === "win32")( + "a dispatched Planner whose document was renamed goes by its new address, in status, output and its next start", + async () => { + let chopin = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async request => + new URL(request.url).pathname === "/api/connector/planner" + ? Response.json({ + token: `chopin_p_${"P".repeat(43)}`, + expiresAt: Date.now() + 15 * 60_000, + document: { + id: "D_1", + repository: "octo-org/score", + url: ((await request.json()) as { document: string }).document, + }, + }) + : new Response(null, { status: 404 }), + }); + let directory = await mkdtemp(join(scratch, "renamed-")); + let script = join(directory, "backend.ts"); + let log = join(directory, "launches.jsonl"); + await writeFile( + script, + `import { appendFileSync } from "node:fs"; +appendFileSync(process.argv[2], JSON.stringify({ pid: process.pid, document: process.env.CHOPIN_DOCUMENT }) + "\\n"); +process.on("SIGTERM", () => process.exit(0)); +setTimeout(() => process.exit(0), 1_000); +`, + ); + let abort = new AbortController(); + let said = ""; + let sink = { + write: (text: string) => { + said += text; + return true; + }, + }; + let recorded: string[][] = []; + let planners = dispatchedPlanners({ + url: `http://127.0.0.1:${chopin.port}`, + machine: MACHINE, + documents: [], + command: [process.execPath, script, log], + checkouts: checkouts({ directory, registered: { "octo-org/score": directory } }), + directory, + output: createOutput(sink, sink, {}), + signal: abort.signal, + chosen: { + record: async documents => { + recorded.push(documents); + }, + }, + }); + let launched = async () => (await entries(log)).filter(entry => entry.pid); + try { + planners.assign([{ id: "D_1", url: "http://x/documents/o/r/old-name" }]); + await until(async () => (await launched()).length === 1, 10_000); + expect(said).toContain("Planner for http://x/documents/o/r/old-name started."); + // Renamed while it runs: Chopin's next answer names the same document at its new address. + planners.assign([{ id: "D_1", url: "http://x/documents/o/r/new-name" }]); + expect(recorded.at(-1)).toEqual(["http://x/documents/o/r/new-name"]); + expect(planners.running()).toEqual(["D_1"]); + // It is not restarted for the rename; once it exits by itself, it starts again at the new one. + await until(async () => planners.running().length === 0, 10_000); + planners.assign([{ id: "D_1", url: "http://x/documents/o/r/new-name" }]); + await until(async () => (await launched()).length === 2, 10_000); + expect(said).toContain("Planner for http://x/documents/o/r/new-name exited (exit 0)"); + expect((await launched()).map(entry => entry.document)).toEqual([ + "http://x/documents/o/r/old-name", + "http://x/documents/o/r/new-name", + ]); + } finally { + abort.abort(); + await planners.stop(); + await chopin.stop(true); + } + }, + 40_000, +); + test.skipIf(process.platform === "win32")( "one machine runs the Planners of documents in several repositories, each in its repository's checkout", async () => { diff --git a/apps/connector/src/planner.ts b/apps/connector/src/planner.ts index 0cfa071d1..72796e5a9 100644 --- a/apps/connector/src/planner.ts +++ b/apps/connector/src/planner.ts @@ -149,8 +149,14 @@ export function dispatchedPlanners(options: { assign(planners: PlannerAssignment[], stopped?: Record) { let wanted = new Map(planners.map(value => [value.id, value])); for (let [id, backend] of running) { - if (wanted.has(id)) backend.exited?.(true); - else withdraw(id, backend, stopped?.[id]); + let planner = wanted.get(id); + if (!planner) { + withdraw(id, backend, stopped?.[id]); + continue; + } + // A renamed document keeps its id; Chopin names its new address. + if (!backend.own) backend.url = planner.url; + backend.exited?.(true); } for (let [id, planner] of wanted) { if (running.has(id) || options.signal.aborted) continue; @@ -162,6 +168,7 @@ export function dispatchedPlanners(options: { backend.served = servePlanner({ ...options, document: planner.url, + address: () => backend.url, id: planner.id, signal, again: () => @@ -211,6 +218,8 @@ async function servePlanner(options: { document: string; /** The document's id in Chopin, when known before its credential names it. */ id?: string; + /** The document's current address, when Chopin can name a new one as it is renamed. */ + address?: () => string; command: string[]; checkouts: Checkouts; directory: string; @@ -229,10 +238,12 @@ async function servePlanner(options: { */ again?: () => Promise; }) { - let { output, signal, document } = options; + let { output, signal } = options; let healthyMs = options.healthyMs ?? HEALTHY_MS; let values = Object.values(options.env ?? {}); + let current = () => options.address?.() ?? options.document; for (let attempt = 0; !signal.aborted;) { + let document = current(); let started = Date.now(); let credential; try { @@ -294,7 +305,12 @@ async function servePlanner(options: { if (checkout) { output.say(`› Planner for ${document} started.`); let healthy = setTimeout(() => void options.failures?.started(document), healthyMs); - outcome = await launch({ ...options, cwd: checkout }, credential.token, worktree, github); + outcome = await launch( + { ...options, document, cwd: checkout }, + credential.token, + worktree, + github, + ); clearTimeout(healthy); } else { output.error(`Can't start the Planner for ${document}: ${outcome!.how}`); @@ -302,6 +318,8 @@ async function servePlanner(options: { let { how, stderr } = outcome!; await github?.stop(); if (signal.aborted) return; + // Renamed while it ran: say so by its new address. + document = current(); // What the backend printed may hold its credential, a GitHub token or an --env value. let secrets = [credential.token, ...github?.secrets() ?? [], ...values]; let ended = Date.now(); @@ -319,6 +337,7 @@ async function servePlanner(options: { // Status shows a failed start while Chopin is asked whether it still wants this Planner. if (early) await failed(ended); if (!(await options.again())) return; + document = current(); } if (ended - started >= HEALTHY_MS) attempt = 0; let wait = backoff(attempt++); diff --git a/apps/server/src/experiments/routes.ts b/apps/server/src/experiments/routes.ts index a2097c738..7f33e655b 100644 --- a/apps/server/src/experiments/routes.ts +++ b/apps/server/src/experiments/routes.ts @@ -393,6 +393,11 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let dispatched = new Map>(); /** Why each machine stopped running a document's Planner, told to its connector once. */ let stopReasons = new Map>(); + /** + * The address each connection was last told for each document whose Planner it runs, by + * connection id, so a rename reaches the connector: its Planner poll names documents by id. + */ + let toldUrls = new Map>(); /** * Whether a connection can run its owner's Planners on request: it has a Planner command, speaks * protocol 4, and this server runs the remote Planner with machines' own GitHub authorization. @@ -635,6 +640,7 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio * machine's other connections able to run Planners serves, and Default machine's move on. */ connections.onEnded = connection => { + toldUrls.delete(connection.id); let others = connections.machine(connection.machineId).filter(runsPlanners); void stopPlanners( connection.machineId, @@ -1638,9 +1644,20 @@ export function registerExperimentRoutes(router: Router, auth: HostedAuth, optio let running = new Set(args.planners as string[]); let changed = async (): Promise => { let planners = await plannerDocuments(connection); - if (planners.length === running.size && planners.every(value => running.has(value.id))) { + let told = toldUrls.get(connection.id) ?? new Map(); + toldUrls.set(connection.id, told); + // A document renamed since this connection was told about it is a change too. + let moved = planners.some(value => + told.has(value.id) && told.get(value.id) !== value.url + ); + for (let value of planners) if (!told.has(value.id)) told.set(value.id, value.url); + if ( + !moved && planners.length === running.size + && planners.every(value => running.has(value.id)) + ) { return undefined; } + toldUrls.set(connection.id, new Map(planners.map(value => [value.id, value.url]))); let listed = new Set(planners.map(value => value.id)); let reasons = stopReasons.get(connection.machineId); let stopped: Record = {}; diff --git a/apps/server/src/harness/remote/planner-dispatch.test.ts b/apps/server/src/harness/remote/planner-dispatch.test.ts index 70a0fd870..c70e5600a 100644 --- a/apps/server/src/harness/remote/planner-dispatch.test.ts +++ b/apps/server/src/harness/remote/planner-dispatch.test.ts @@ -306,6 +306,29 @@ describe("choosing a machine for a document", () => { expect(server.attachments.attached(channel.id)).toBe(false); }); + it("tells the machine a renamed document's current address, once", async () => { + let { machine, choose, channel, storage } = await dispatchServer(); + let laptop = await machine("member", "U_member", "laptop"); + await choose("member", laptop.id); + let told = await laptop.poll([]); + expect(told.planners[0].url).toEndWith(`/${channel.slug}`); + let renamed = await storage.channels.rename({ + id: channel.id, + title: "Renamed document", + now: new Date(), + }); + expect(renamed.channel.slug).not.toBe(channel.slug); + // The same document, so a connector running it hears only its new address. + expect(await laptop.poll([channel.id])).toEqual({ + kind: "planners", + planners: [{ + id: channel.id, + url: expect.stringMatching(new RegExp(`/${renamed.channel.slug}$`)), + }], + }); + expect(await laptop.poll([channel.id])).toEqual({ waiting: true }); + }); + it("never takes over a Planner another account holds", async () => { let { machine, choose, choice, channel, server, url } = await dispatchServer(); let laptop = await machine("member", "U_member", "laptop"); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 476758af5..ca52b62c4 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1840,6 +1840,8 @@ async function resetOpenAgents( } function announceChannel(channel: ChannelRecord): void { + // A machine running its Planner hears a new address on its next Planner poll; wake that poll. + experiments?.connections.wake(channel.repositoryId); let room = Rooms.get(channel.id); if (!room) return; for (let ws of room.members.values()) { diff --git a/docs/agent-connection.md b/docs/agent-connection.md index e5aab1b41..8aeeea62d 100644 --- a/docs/agent-connection.md +++ b/docs/agent-connection.md @@ -746,4 +746,7 @@ A backend Chopin asked the machine to run (see Chopin's Planner poll still asks for it. Chopin closes the link of a Planner it no longer asks for, so the backend can exit before the poll's answer arrives; the connector then waits for that answer, and stops the backend for good when -the answer leaves its document out. +the answer leaves its document out. When a document is renamed, the poll's next +answer names it at its new address; the connector shows that address in +`chopin status` and its output, and starts the backend there next time, without +restarting a backend that is running. From 5251740806ff716907a9aa2bd5ba694f9112be47 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Fri, 16 Oct 2026 14:38:33 -0700 Subject: [PATCH 3/4] Place a Planner's blocking question beside the block it names Decisions a Planner asked through blocking human input always went to the end of the document, not linked to any passage. An agent connection input-request may now carry an optional block, the index and digest of the block it concerns from the Planner's latest read, and the in-process host input takes the same block in its options. Its cards go directly after that block and are linked to it. A block that changed, repeats or is itself a decision card leaves the cards unlinked at the end, as before. The field is optional, so the connection version is unchanged. Fixes #661 Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline (fast subagents) Assistant-verification: red-before-fix: human-input.test.ts "a question that names its block ..." placed the card after the last section; link.test.ts block test never got a card; protocol.test.ts refused the block field Assistant-verification: after fix: agent-link, human-input and link tests pass, including the unlinked fallback; full bun test run passed Co-authored-by: Alex Lavaee --- .../src/harness/atomic/human-input.test.ts | 44 +++++++++++++++++++ apps/server/src/harness/atomic/human-input.ts | 22 ++++++++-- apps/server/src/harness/remote/link.test.ts | 23 ++++++++++ apps/server/src/harness/remote/link.ts | 1 + apps/server/src/harness/remote/registry.ts | 5 +-- apps/server/src/questions/service-ask.ts | 32 ++++++++++++-- docs/agent-connection.md | 10 ++++- docs/hosted-agent.md | 8 +++- packages/agent-link/src/client.ts | 5 +++ packages/agent-link/src/protocol.test.ts | 23 ++++++++++ packages/agent-link/src/protocol.ts | 5 +++ 11 files changed, 164 insertions(+), 14 deletions(-) diff --git a/apps/server/src/harness/atomic/human-input.test.ts b/apps/server/src/harness/atomic/human-input.test.ts index 4aa2c7310..fe4a65fdb 100644 --- a/apps/server/src/harness/atomic/human-input.test.ts +++ b/apps/server/src/harness/atomic/human-input.test.ts @@ -359,6 +359,50 @@ test("a workflow card shows its question as asked, without run or stage ids", as ); }); +/** A document of a title and two sections, whose first section is block 1. */ +async function sections() { + let f = await fixture(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Existing document\n\nStorage section.\n\nDeployment section.\n", + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + expect(Room.digests(f.plan.document)).toHaveLength(3); + return f; +} + +test("a question that names its block is placed beside that block and linked to it", async () => { + let f = await sections(); + let response = f.input.questionnaire({ questions: [params.questions[1]!] }, { + ...f.options, + block: { index: 1, digest: Room.digests(f.plan.document)[1]! }, + }); + let [card] = await f.cards(1); + let source = Plan.source(f.plan); + expect(source.indexOf("Storage section.")).toBeLessThan(source.indexOf(" { + let f = await sections(); + let response = f.input.questionnaire({ questions: [params.questions[1]!] }, { + ...f.options, + block: { index: 1, digest: `sha256:${"0".repeat(64)}` }, + }); + let [card] = await f.cards(1); + let source = Plan.source(f.plan); + expect(source.indexOf("Deployment section.")).toBeLessThan(source.indexOf(" { let f = await fixture(); let cancelled = new AbortController(); diff --git a/apps/server/src/harness/atomic/human-input.ts b/apps/server/src/harness/atomic/human-input.ts index ed1896595..9ab5c8e5f 100644 --- a/apps/server/src/harness/atomic/human-input.ts +++ b/apps/server/src/harness/atomic/human-input.ts @@ -11,6 +11,20 @@ import type { DocumentRoom } from "../../agent/tools"; export type HumanInputStatus = "answered" | "cancelled"; +/** + * A host input request's options, with the block it concerns: its `index` and `digest` from the + * Planner's latest read of the document. Its card goes beside that block and is linked to it; a + * changed or repeated block leaves the card unlinked at the end of the document. + */ +export type HumanInputOptions = HostInputOptions & { block?: { index: number; digest: string } }; + +/** Chopin's HostInput, whose methods also take the block a request concerns. */ +export type HumanInput = { + [Method in keyof HostInput]: HostInput[Method] extends (...args: infer A) => infer R + ? (...args: { [K in keyof A]: A[K] extends HostInputOptions ? HumanInputOptions : A[K] }) => R + : never; +}; + /** * Atomic owns input requests; Chopin owns their shared decision records. * @@ -25,10 +39,10 @@ export function createHumanInput( room: DocumentRoom, hold?: (workflowRunId: string, signal: AbortSignal) => Promise, report?: (status: HumanInputStatus) => void, -): HostInput { +): HumanInput { async function questionnaire( params: QuestionParams, - options: HostInputOptions, + options: HumanInputOptions, ): Promise { if (options.signal.aborted) { report?.("cancelled"); @@ -54,7 +68,7 @@ export function createHumanInput( room.server, room.id, definition, - undefined, + options.block ? { near: options.block } : undefined, room.anchors, options.signal, ); @@ -105,7 +119,7 @@ export function createHumanInput( header: string, question: string, choices: string[], - options: HostInputOptions, + options: HumanInputOptions, ) { let result = await questionnaire({ questions: [{ diff --git a/apps/server/src/harness/remote/link.test.ts b/apps/server/src/harness/remote/link.test.ts index 94013f677..13c698775 100644 --- a/apps/server/src/harness/remote/link.test.ts +++ b/apps/server/src/harness/remote/link.test.ts @@ -14,6 +14,8 @@ import { AgentRefused } from "@chopin/agent-link/client"; import { GitHubError } from "../../github/client"; import * as Store from "../../questions/store"; import * as Plan from "../../plan/service"; +import * as Edit from "../../plan/edit"; +import * as Room from "../../plan/room"; import { createPlannerAgent } from "../agents"; import { createHumanInput } from "../atomic/human-input"; import { openPlannerSession } from "../session"; @@ -1345,6 +1347,27 @@ describe("Agent connection questions", () => { expect(await confirm).toMatchObject({ status: "answered", value: true }); }); + it("places a question that names its block beside that block, linked to it", async () => { + let { f, client, sessionId } = await questions(); + let edited = Edit.apply(f.plan, f.plan.revision, [{ + op: "replace_root", + source: "# Existing document\n\nStorage section.\n\nDeployment section.\n", + }]); + if (!edited.ok) throw new Error("fixture edit failed"); + if (edited.mutation) await Plan.publish(f.plan, f.server, f.room.id, edited.mutation); + let asked = client.ask(sessionId, { method: "questionnaire", params }, { + block: { index: 1, digest: Room.digests(f.plan.document)[1]! }, + }); + let [card] = await f.cards(1); + let source = Plan.source(f.plan); + expect(source.indexOf("Storage section.")).toBeLessThan(source.indexOf(" { let { f, client, sessionId } = await questions(); let clock = laterTimers(); diff --git a/apps/server/src/harness/remote/link.ts b/apps/server/src/harness/remote/link.ts index 016d72983..11d952b3c 100644 --- a/apps/server/src/harness/remote/link.ts +++ b/apps/server/src/harness/remote/link.ts @@ -842,6 +842,7 @@ export class AgentLink { sessionId: message.session, ...(message.workflowRunId ? { workflowRunId: message.workflowRunId } : {}), ...(message.workflowStageId ? { workflowStageId: message.workflowStageId } : {}), + ...(message.block ? { block: message.block } : {}), }; let input = message.input; try { diff --git a/apps/server/src/harness/remote/registry.ts b/apps/server/src/harness/remote/registry.ts index 3406362c3..cfc56ea73 100644 --- a/apps/server/src/harness/remote/registry.ts +++ b/apps/server/src/harness/remote/registry.ts @@ -1,6 +1,5 @@ -import type { HostInput } from "@bastani/atomic"; import type { FullPlanner } from "../atomic/full"; -import type { HumanInputStatus } from "../atomic/human-input"; +import type { HumanInput, HumanInputStatus } from "../atomic/human-input"; import type { AgentLink } from "./link"; /** @@ -14,7 +13,7 @@ export type RemotePlanner = /** A background job's session: the client may offer only Chopin's tools and ask nothing. */ isolated: boolean; /** Decisions for one client input request; absent for an isolated session. */ - humanInput?: (report: (status: HumanInputStatus) => void) => HostInput; + humanInput?: (report: (status: HumanInputStatus) => void) => HumanInput; /** Stops or resumes the client's workflow runs; set while the session is open. */ control?: (action: "stop" | "resume", runId?: string) => Promise; /** diff --git a/apps/server/src/questions/service-ask.ts b/apps/server/src/questions/service-ask.ts index de8dc3af5..8cc2e9340 100644 --- a/apps/server/src/questions/service-ask.ts +++ b/apps/server/src/questions/service-ask.ts @@ -1,4 +1,4 @@ -import { limits, ulid } from "@chopin/dialect"; +import { limits, parse, ulid } from "@chopin/dialect"; import * as Question from "@chopin/question"; import * as Y from "yjs"; @@ -25,6 +25,25 @@ import { announce } from "./card-notifications"; import { withdraw } from "./service-cancel"; import { type AskPlacement, validatePlacement } from "./service-definition"; +/** + * The prose block a question concerns, as a Planner's blocking question names it: the block at + * `index` when its digest still matches, else the one block with that digest. A changed, repeated + * or non-prose block resolves to nothing, and the card stays unlinked at the end of the document. + */ +export type AskNear = { near: { index: number; digest: string } }; + +function nearBlock(plan: Plan, near: AskNear["near"]) { + let index = room.resolveBlock(plan.document, near); + if (index === undefined) return undefined; + let block = parse(room.project(plan.document)).children[index]; + if ( + !block || block.type === "mdxJsxFlowElement" + && (block.name === "Questionnaire" || block.name === "Decision") + ) return undefined; + let digest = room.digests(plan.document)[index]!; + return { at: { index, digest }, anchor: room.anchorAt(plan.document, index, digest) }; +} + /** * Ask each decision independently; register its record before publishing its node. * @@ -36,7 +55,7 @@ export async function ask( server: Server, roomId: string, definition: Definition, - placement?: AskPlacement, + placement?: AskPlacement | AskNear, created?: () => void, signal?: AbortSignal, ): Promise { @@ -53,7 +72,12 @@ export async function ask( at: room.QuestionnaireInsertion["at"]; }> = []; await Service.exclusive(plan, async () => { - let anchors = placement ? validatePlacement(plan, definition, placement) : undefined; + let near = placement && "near" in placement ? nearBlock(plan, placement.near) : undefined; + let anchors = placement && "blocks" in placement + ? validatePlacement(plan, definition, placement) + : near && definition.questions.map(() => [near.anchor]); + let placedAt = (index: number) => + placement && "blocks" in placement ? placement.blocks[index]?.[0] : near?.at; if (Service.implementationActive(plan)) throw new Error("implementation is active"); let questions: Store.Questions = { open: new Map(plan.questions.open), @@ -146,7 +170,7 @@ export async function ask( record.anchors = Anchors.set(Anchors.read(record), question.id, anchors[index]!); } records.set(id, record); - return { id, single, value, waiting, at: placement?.blocks[index]?.[0] }; + return { id, single, value, waiting, at: placedAt(index) }; }); // Verbatim host input has no per-field limits. Refuse before anything is registered or // published. diff --git a/docs/agent-connection.md b/docs/agent-connection.md index 8aeeea62d..8bf44461b 100644 --- a/docs/agent-connection.md +++ b/docs/agent-connection.md @@ -366,7 +366,7 @@ how `invoke_planner` returns it. Once a client is attached, both arrive as { "type": "part", "session": "", "turn": "", "part": {} } { "type": "host-tool-call", "session": "", "turn": "", "toolCallId": "", "toolName": "", "input": "{}" } { "type": "turn-end", "session": "", "turn": "", "status": "finished", "message?": "" } -{ "type": "input-request", "request": "", "session": "", "workflowRunId?": "", "workflowStageId?": "", "rootRunId?": "", "input": {} } +{ "type": "input-request", "request": "", "session": "", "workflowRunId?": "", "workflowStageId?": "", "rootRunId?": "", "block?": { "index": 0, "digest": "sha256:<64 hex>" }, "input": {} } { "type": "input-cancel", "request": "" } { "type": "runs", "session": "", "active": [], "paused": [], "cards": [] } { "type": "control-result", "request": "", "ok": true, "message?": "" } @@ -527,6 +527,14 @@ A questionnaire has 1 to 16 questions of up to 32 options; `preview` may be 16,000 characters; `select` offers up to 32 choices. The document's own limits still apply. Text is shown verbatim. +`block` names the block of the document the question concerns, by the `index` +and `digest` the Planner's latest `read_plan` or `edit_plan` result gave it. Its +cards go directly after that block and are linked to it. When that block has +changed since, its digest now repeats, or it is itself a decision card, the +cards go at the end of the document, not linked to any passage, as they do +without `block`. A Planner running in Chopin's own process (`HARNESS=atomic`) +names the block the same way, in the options of its host input request. + `input-result` is `{ "type": "input-result", "request": "", "status": "", "value?": json, "message?": "" }`. `status` and `value` by method: diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index 7eadaebd3..a5efebb36 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -304,8 +304,12 @@ extension dialogs, and workflow-stage input become ordinary shared Decisions: appear verbatim in the prompt. Explicitly submitted empty text is an answer, not cancellation. - Workflow cards show their question exactly as asked, with no run or stage - ids; Chat's run card shows which run is waiting on Decisions. A request is - appended as one adjacent batch at the end of the document. + ids; Chat's run card shows which run is waiting on Decisions. A request that + names the block it concerns goes, as one adjacent batch, directly after that + block and is linked to it (see + [Agent connection questions](agent-connection.md#questions)). One that names + none, or a block that has changed since, is appended at the end of the + document, not linked yet. - An abort withdraws still-open cards, removes their document nodes, and records cancellation by `@chopin`. Member cancellations retain any answers already given in that batch. Late submissions cannot approve withdrawn input. diff --git a/packages/agent-link/src/client.ts b/packages/agent-link/src/client.ts index 9530b258d..f71e0288b 100644 --- a/packages/agent-link/src/client.ts +++ b/packages/agent-link/src/client.ts @@ -83,6 +83,11 @@ export type InputOptions = { workflowRunId?: string; workflowStageId?: string; rootRunId?: string; + /** + * The block the question concerns, from the Planner's latest read of the document; its card + * goes beside that block. A block that has changed since leaves the card unlinked. + */ + block?: { index: number; digest: string }; signal?: AbortSignal; }; diff --git a/packages/agent-link/src/protocol.test.ts b/packages/agent-link/src/protocol.test.ts index 4dfb35b8f..c1bfcbed2 100644 --- a/packages/agent-link/src/protocol.test.ts +++ b/packages/agent-link/src/protocol.test.ts @@ -77,6 +77,29 @@ describe("Agent connection messages", () => { } }); + it("accepts the block a question concerns without changing the connection version", () => { + let message = { + type: "input-request" as const, + request: "r", + session: "s", + block: { index: 3, digest: `sha256:${"a".repeat(64)}` }, + input: { method: "confirm" as const, title: "Ship?", message: "Now." }, + }; + expect(parseClientMessage(JSON.stringify(message))).toEqual({ ok: true, message }); + for ( + let block of [ + { index: -1, digest: message.block.digest }, + { index: 1.5, digest: message.block.digest }, + { index: 3, digest: "sha256:short" }, + { index: 3, digest: message.block.digest, extra: true }, + ] + ) { + expect(parseClientMessage(JSON.stringify({ ...message, block }))).toMatchObject({ + ok: false, + }); + } + }); + it("accepts optional stage parents and stopped stages without changing the connection version", () => { let message = { type: "runs" as const, diff --git a/packages/agent-link/src/protocol.ts b/packages/agent-link/src/protocol.ts index a10143e10..fda6ad62a 100644 --- a/packages/agent-link/src/protocol.ts +++ b/packages/agent-link/src/protocol.ts @@ -243,6 +243,11 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ workflowRunId: id.optional(), workflowStageId: id.optional(), rootRunId: id.optional(), + /** The block the question concerns, as the Planner's latest read of the document names it. */ + block: z.strictObject({ + index: z.number().int().nonnegative(), + digest: z.string().regex(/^sha256:[0-9a-f]{64}$/), + }).optional(), input: inputSchema, }), z.strictObject({ type: z.literal("input-cancel"), request: id }), From ad1bd0579de77d4f746a8ed7b7f21ebfdcfabe50 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Fri, 16 Oct 2026 14:38:39 -0700 Subject: [PATCH 4/4] Let a wide Mermaid diagram fit its frame on request, with a visible control A Mermaid diagram too wide to read at the frame's width keeps its drawn size and scrolls, and its only cue was a 32px fade at the cut edge. Where scrollbars hide at rest, as in headless Chromium and on macOS, nothing else showed there was more, and a mouse without a sideways wheel could not reach the end. Off a phone, such a diagram now shows Fit diagram to width under its scroll lane: the whole shape scales to the frame, with its height following its width, until Actual size returns it to scrolling from its start. Phones keep Expand diagram. Fixes #658 Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline (fast subagents) Assistant-verification: red-before-fix: wide-content.e2e.ts wide LR flowchart tests at 1280 and 1920 failed on the unfixed client (no control; the frame showed only its first steps, with no lane painted: offsetHeight equal to clientHeight); the 390 touch test passed as coverage Assistant-verification: after fix: wide-content, code, responsive-content and visual-handoff e2e passed; bun run ci and bun run types passed Co-authored-by: Alex Lavaee --- apps/web/DESIGN.md | 2 +- e2e/code.e2e.ts | 2 +- e2e/wide-content.e2e.ts | 74 +++++++++++++++++++ packages/editor/src/styles.css | 11 ++- packages/editor/src/widgets/diagram-sheet.tsx | 25 +++++++ packages/editor/src/widgets/render-blocks.tsx | 12 ++- 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 65ee7173c..c9f179454 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -153,7 +153,7 @@ break), or cut the metadata; `scripts/no-middot.test.ts` enforces this. ## 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, and at its end a quiet History (clock) button. History slides in from the right under the title bar, over the views bar and the document, so nothing beneath reflows, and slides back out on close. Its title and close button stay put while the list scrolls under the shared scroll-edge fade. Each editing session is one line: face, who, a one-line summary of what it did (written by a small model when History opens and shown as a breathing skeleton until it arrives, or _made edits_ when no Planner can write it), and a terse age (_now_, _2m_, _3h_, _4d_, _2w_, then the date), with space between rows growing with the time between them. Lingering on a row for a moment shows the full line and the exact time just left of it. Contents needs no button: one quiet tick per heading sits in the document's right margin, the first level with the title, darker for the section being read. Hovering or focusing a tick peeks at that section beside it (its heading and opening lines, in a hairline card with a light shadow that follows the pointer down the ticks), and choosing one scrolls there. The ticks follow edits and an earlier version on show. Comment markers own the lane beside the prose, so the ticks appear only when the right margin can hold both, as on an expanded or wide sheet; History hides them while open. Choosing an earlier session replaces the document in place with a read-only copy, and the views bar gives its place to the version's own: Current (back to the live document), whose edits, the exact time, and for writers a small Restore. Restoring brings back that version's prose as a new edit by the person who pressed it, while decision, questionnaire and research blocks stay as they are now, since their records own them. The newest session is the live document. 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, inert with its reason in a tooltip until it can run. Open decisions and required investigations replace it with a coloured capsule showing how many remain. Hover or click reveals their titles; each whole row opens its specific card, with one hover surface and a decorative arrow. 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. +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, and at its end a quiet History (clock) button. History slides in from the right under the title bar, over the views bar and the document, so nothing beneath reflows, and slides back out on close. Its title and close button stay put while the list scrolls under the shared scroll-edge fade. Each editing session is one line: face, who, a one-line summary of what it did (written by a small model when History opens and shown as a breathing skeleton until it arrives, or _made edits_ when no Planner can write it), and a terse age (_now_, _2m_, _3h_, _4d_, _2w_, then the date), with space between rows growing with the time between them. Lingering on a row for a moment shows the full line and the exact time just left of it. Contents needs no button: one quiet tick per heading sits in the document's right margin, the first level with the title, darker for the section being read. Hovering or focusing a tick peeks at that section beside it (its heading and opening lines, in a hairline card with a light shadow that follows the pointer down the ticks), and choosing one scrolls there. The ticks follow edits and an earlier version on show. Comment markers own the lane beside the prose, so the ticks appear only when the right margin can hold both, as on an expanded or wide sheet; History hides them while open. Choosing an earlier session replaces the document in place with a read-only copy, and the views bar gives its place to the version's own: Current (back to the live document), whose edits, the exact time, and for writers a small Restore. Restoring brings back that version's prose as a new edit by the person who pressed it, while decision, questionnaire and research blocks stay as they are now, since their records own them. The newest session is the live document. 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, inert with its reason in a tooltip until it can run. Open decisions and required investigations replace it with a coloured capsule showing how many remain. Hover or click reveals their titles; each whole row opens its specific card, with one hover surface and a decorative arrow. 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. Off a phone, a Mermaid diagram too wide to read at the frame's width opens at its start and offers Fit diagram to width under its scroll lane, which scales its whole shape to the frame until Actual size returns it, so the cut edge never reads as a crop. 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/e2e/code.e2e.ts b/e2e/code.e2e.ts index 0b9119e83..21e7ff03a 100644 --- a/e2e/code.e2e.ts +++ b/e2e/code.e2e.ts @@ -180,7 +180,7 @@ flowchart LR `); let page = await join("ana"); - let svg = content(page).locator("[data-plan-preview] svg"); + let svg = content(page).getByRole("region", { name: "Diagram preview" }).locator("svg"); await expect(svg).toBeVisible(); let labels = await svg.evaluate(element => [...element.querySelectorAll("g.label, g.edgeLabel")].map(label => diff --git a/e2e/wide-content.e2e.ts b/e2e/wide-content.e2e.ts index 33b0dc073..21b5194d0 100644 --- a/e2e/wide-content.e2e.ts +++ b/e2e/wide-content.e2e.ts @@ -38,6 +38,34 @@ flowchart LR \`\`\` `; +/** A left-to-right flowchart of ten steps, far wider than any document's measure. */ +const FLOW = `\`\`\`mermaid +flowchart LR + A[Draft the document] --> B[Review the plan] --> C[Ask the decisions] --> D[Answer them] + D --> E[Break it into tasks] --> F[Build plan] --> G[Open pull requests] + G --> H[Verify every task] --> I[Merge] --> J[Ship it to everyone] +\`\`\` +`; + +/** Where the frame, its drawing and the drawing's first and last steps are on screen. */ +function frame(region: Locator) { + return region.evaluate(element => { + let box = element.getBoundingClientRect(); + let svg = element.querySelector("svg")!.getBoundingClientRect(); + let steps = [...element.querySelectorAll(".node")].map(node => node.getBoundingClientRect()) + .sort((a, b) => a.left - b.left); + let shown = (step: DOMRect) => step.left >= box.left - 1 && step.right <= box.right + 1; + return { + scrolls: element.scrollWidth > element.clientWidth + 1, + fits: svg.right <= box.right + 1, + first: shown(steps[0]!), + last: shown(steps.at(-1)!), + drawn: svg.height, + height: box.height, + }; + }); +} + function mask(table: Locator): Promise { return table.evaluate(node => getComputedStyle(node).maskImage); } @@ -168,3 +196,49 @@ test("a dense diagram opens full screen at a readable size and closes", async ({ await expect(expand).toBeFocused(); await expectNoHorizontalOverflow(page); }); + +for (let width of [1280, 1920]) { + test(`a wide flowchart starts at its first step and fits the frame on request at ${width}px`, async ({ join, seed }) => { + await seed(`# Delivery\n\nHow a document becomes pull requests.\n\n${FLOW}\nThen it ships.\n`); + let page = await join("ana", { viewport: { width, height: 900 } }); + let region = content(page).getByRole("region", { name: "Diagram preview" }); + await expect(region).toBeVisible(); + let fit = content(page).getByRole("button", { name: "Fit diagram to width" }); + + // Too wide to read at the measure: its first screen is its start, and the frame says so. + await expect(fit).toBeVisible(); + let shown = await frame(region); + expect(shown).toMatchObject({ scrolls: true, first: true, last: false }); + await expect(region).toHaveAttribute("data-more", "end"); + + // Fitted, the whole shape is in the frame, with no band above or below it. + await fit.click(); + await expect.poll(async () => (await frame(region)).fits).toBe(true); + let fitted = await frame(region); + expect(fitted).toMatchObject({ scrolls: false, first: true, last: true }); + expect(fitted.drawn).toBeLessThan(shown.drawn); + await expect(region).not.toHaveAttribute("data-more"); + + // Actual size scrolls again, from its start. + await content(page).getByRole("button", { name: "Actual size" }).click(); + await expect.poll(async () => (await frame(region)).scrolls).toBe(true); + expect(await frame(region)).toMatchObject({ first: true, last: false }); + await expect(fit).toBeVisible(); + await expectNoHorizontalOverflow(page); + }); +} + +test("a wide flowchart on a phone starts at its first step and offers its full-screen reading", async ({ join, seed }) => { + await seed(`# Delivery\n\nHow a document becomes pull requests.\n\n${FLOW}\nThen it ships.\n`); + let page = await join("ana", PHONE); + let region = content(page).getByRole("region", { name: "Diagram preview" }); + await expect(region).toBeVisible(); + await expect(content(page).getByRole("button", { name: "Expand diagram" })).toBeVisible(); + await expect(content(page).getByRole("button", { name: "Fit diagram to width" })).toHaveCount(0); + expect(await frame(region)).toMatchObject({ scrolls: true, first: true, last: false }); + await expect(region).toHaveAttribute("data-more", "end"); + // A swipe reaches its last step. + await region.evaluate(node => (node.scrollLeft = node.scrollWidth)); + await expect.poll(async () => (await frame(region)).last).toBe(true); + await expectNoHorizontalOverflow(page); +}); diff --git a/packages/editor/src/styles.css b/packages/editor/src/styles.css index 6c73e00b3..d14011ad0 100644 --- a/packages/editor/src/styles.css +++ b/packages/editor/src/styles.css @@ -609,7 +609,10 @@ } } -/* Rendered only on a phone, and shown only where the document is narrow too. */ +/* + * Expand is rendered only on a phone, and shown only where the document is narrow too. Fit is + * rendered only off a phone, for a drawing too wide to read at the frame's width. + */ .plan-content .plan-diagram-actions { display: none; justify-content: flex-end; @@ -617,6 +620,10 @@ padding-inline: calc(var(--spacing) * 1.5); } +.plan-content .plan-diagram-actions[data-plan-chrome="fit"] { + display: flex; +} + /* * Wide authored blocks on a phone (`:root[data-phone]`), in a narrow document. Narrow * desktop windows keep their scroll lanes untouched. @@ -3879,6 +3886,8 @@ body:has(.plan-comment-sheet-popup[data-swiping]) .plan-content .plan-diagram[data-fit] svg { max-width: 100%; + /* Scaled by its width, so its height follows rather than leaving bands above and below. */ + height: auto; } .plan-content .plan-diagram[data-more="end"] { diff --git a/packages/editor/src/widgets/diagram-sheet.tsx b/packages/editor/src/widgets/diagram-sheet.tsx index 64bfb199f..2a33d416d 100644 --- a/packages/editor/src/widgets/diagram-sheet.tsx +++ b/packages/editor/src/widgets/diagram-sheet.tsx @@ -46,6 +46,31 @@ export function ExpandDiagram( ); } +/** + * A row under a diagram too wide to read at the measure, on a desktop: its + * whole shape scaled to the frame's width, or back to its drawn size. Visible + * whenever the drawing scrolls, so a frame cut at its edge reads as more to see + * even where scrollbars hide at rest, and a mouse without a sideways wheel + * still reaches the end. + */ +export function FitDiagram({ fitted, onToggle }: { fitted: boolean; onToggle: () => void }) { + return ( +
+ +
+ ); +} + /** * A full-screen sheet holding a second, larger reading of a diagram. * diff --git a/packages/editor/src/widgets/render-blocks.tsx b/packages/editor/src/widgets/render-blocks.tsx index ac5df452e..685785344 100644 --- a/packages/editor/src/widgets/render-blocks.tsx +++ b/packages/editor/src/widgets/render-blocks.tsx @@ -57,7 +57,7 @@ import { widgets$ } from "../widget-options"; import { describeDiagramError, kindOf, languageOptions, plainText, titleOf } from "./code"; import { CodeView } from "./code-view"; import { fitsLegibly } from "./diagram-fit"; -import { DiagramSheet, ExpandDiagram } from "./diagram-sheet"; +import { DiagramSheet, ExpandDiagram, FitDiagram } from "./diagram-sheet"; import { usePhone } from "../pointer"; import { LanguageMenu } from "./language-menu"; import { registerPreviewSelection } from "./preview-selection"; @@ -566,11 +566,14 @@ function sidesOf(element: HTMLElement): ReturnType { * * Mermaid lays text out at its authored size, so shrinking a wide chart to the * measure makes its labels unreadable. A modest overshoot is scaled to fit; - * anything wider keeps its size and scrolls, fading the side with more to see. + * anything wider keeps its size and scrolls from its start, fading the side + * with more to see, and offers its whole shape scaled to the frame's width. */ function Diagram({ id, html, stale }: { id: string; html: string; stale: boolean }) { let region = useRef(null); let [fit, setFit] = useState(false); + /** A reader asked for the whole shape at the frame's width. */ + let [fitted, setFitted] = useState(false); let [expanded, setExpanded] = useState(false); let phone = usePhone(); let expand = useRef(null); @@ -603,7 +606,7 @@ function Diagram({ id, html, stale }: { id: string; html: string; stale: boolean // only known after that has been laid out. useEffect(() => { if (region.current) setMore(sidesOf(region.current)); - }, [fit, html]); + }, [fit, fitted, html]); return ( <> @@ -611,7 +614,7 @@ function Diagram({ id, html, stale }: { id: string; html: string; stale: boolean ref={region} aria-label="Diagram preview" className="plan-diagram" - data-fit={fit ? "" : undefined} + data-fit={fit || fitted ? "" : undefined} data-more={more} data-stale={stale ? "" : undefined} role="region" @@ -622,6 +625,7 @@ function Diagram({ id, html, stale }: { id: string; html: string; stale: boolean dangerouslySetInnerHTML={{ __html: html }} /> {phone && !fit && setExpanded(true)} />} + {!phone && !fit && setFitted(!fitted)} />} {expanded && ( {