From f7c4c33f03acfe25b626345aa84896d64e97bdc1 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:59:07 +0100 Subject: [PATCH 1/3] Report whether a document has a task graph in implementation status plan:implementation and the plan:open reply now carry hasGraph, derived from the latest graph version, so clients can tell a graph exists without fetching a full implementation snapshot. Ported from #460. Co-authored-by: Krzysztof Cieslak Co-Authored-By: Claude Opus 5.5 --- apps/server/src/tasks/notifications.test.ts | 41 +++++++++++++++++++++ apps/server/src/tasks/notifications.ts | 1 + apps/web/src/room-workspace.tsx | 3 +- packages/protocol/plan.d.ts | 2 +- 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/tasks/notifications.test.ts diff --git a/apps/server/src/tasks/notifications.test.ts b/apps/server/src/tasks/notifications.test.ts new file mode 100644 index 000000000..6002871ab --- /dev/null +++ b/apps/server/src/tasks/notifications.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { close, open } from "../plan/service"; +import { openPlan } from "../testing/plan"; +import { implementationGraphs } from "./plan-graphs"; +import { implementationStatus } from "./notifications"; + +test("graph availability publishes after its durable commit and survives reopening", async () => { + let context = await openPlan("# Task graph\n\nA settled document.\n"); + let { plan, broadcasts, storage } = context; + expect(implementationStatus(plan).hasGraph).toBe(false); + let commit = storage.collaboration.commit.bind(storage.collaboration); + storage.collaboration.commit = async input => { + expect(broadcasts.some(frame => frame.kind === "plan:implementation")).toBe(false); + return commit(input); + }; + let result = await implementationGraphs().revise(plan, { + planRevision: plan.revision, + graphRevision: 0, + operations: [{ + op: "add", + task: { + id: "first", + title: "First task", + context: "The document", + goal: "Implement it", + acceptance: ["Works", "Verified"], + dependsOn: [], + }, + }], + }); + expect(result.ok).toBe(true); + expect(broadcasts.find(frame => frame.kind === "plan:implementation")).toMatchObject({ + hasGraph: true, + locked: false, + }); + storage.collaboration.commit = commit; + await close(plan); + let reopened = await open(context.channel.id, context.backend, context.server); + expect(implementationStatus(reopened).hasGraph).toBe(true); + await close(reopened); +}); diff --git a/apps/server/src/tasks/notifications.ts b/apps/server/src/tasks/notifications.ts index 77475128c..cedb5d01b 100644 --- a/apps/server/src/tasks/notifications.ts +++ b/apps/server/src/tasks/notifications.ts @@ -5,6 +5,7 @@ import { broadcast } from "../wire"; export function implementationStatus(plan: Plan) { return { revision: plan.persistence.revision, + hasGraph: !!plan.graph?.versions.at(-1)?.definition.tasks.length, locked: !!plan.execution || plan.builds.some(locksEditing), }; } diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 8f2a71ab9..fa9007fd9 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -529,13 +529,14 @@ export function RoomWorkspace( let [implementation, setImplementation] = useState({ revision: -1, locked: false, + hasGraph: false, }); let [showImplementation, setShowImplementation] = useState(false); /** Which end of the capsule-to-Build-tab link is hovered, so the other lights up. */ let [buildHover, setBuildHover] = useState<"capsule" | "tab">(); let liveBuild = useLiveBuild(); useEffect(() => { - setImplementation({ revision: -1, locked: false }); + setImplementation({ revision: -1, locked: false, hasGraph: false }); let update = (value: Plan.ImplementationStatus) => { setImplementation(current => value.revision >= current.revision ? value : current); }; diff --git a/packages/protocol/plan.d.ts b/packages/protocol/plan.d.ts index 638a89c48..b042b68bd 100644 --- a/packages/protocol/plan.d.ts +++ b/packages/protocol/plan.d.ts @@ -319,7 +319,7 @@ export declare namespace Plan { }; /** Durable implementation activity, published only after persistence. */ - export type ImplementationStatus = { revision: number; locked: boolean }; + export type ImplementationStatus = { revision: number; locked: boolean; hasGraph: boolean }; export type ImplementationChanged = KIND<"plan:implementation"> & ImplementationStatus; export type Lifecycle = KIND<"plan:lifecycle"> & { From 4daf5853621fec2e480b905ed26c531fd7745690 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:59:07 +0100 Subject: [PATCH 2/3] Load implementation snapshots through a shared hook useImplementationSnapshot fetches the full snapshot only while an implementation view is active, ignores stale responses by room and revision, and refreshes on the frames its caller names. The shared Build plan state (useBuildPlan, behind the header capsule, the Build tab and the Build view) adopts it in place of its inline fetch and reload logic, with the same behaviour: the same reload frames, the throttled edit reload, and the fresh read after a draft turn ends. Ported from #460. Co-authored-by: Krzysztof Cieslak Co-Authored-By: Claude Opus 5.5 --- apps/web/src/build-plan-button.tsx | 48 +++-------------- apps/web/src/implementation-snapshot.ts | 69 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/implementation-snapshot.ts diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx index 32684710a..a69795dfa 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -13,13 +13,8 @@ import { hasBuilt, } from "./build-model"; import { ApiError } from "./api"; -import { - cancelBuildRequest, - implementationEndpoint, - implementationResponse, - rebuildChanges, - startBuild, -} from "./build-start"; +import { cancelBuildRequest, rebuildChanges, startBuild } from "./build-start"; +import { useImplementationSnapshot } from "./implementation-snapshot"; import "./build-plan-button.css"; @@ -93,49 +88,22 @@ export function useBuildPlan( wire?: Wire; }, ): BuildPlan { - let [snapshot, setSnapshot] = useState(); - let [loadError, setLoadError] = useState(); - let [refresh, setRefresh] = useState(0); + let { snapshot, loadError, refresh: reload, read } = useImplementationSnapshot({ + active: enabled, + room, + wire, + reloadOn: RELOAD_ON, + }); let [state, dispatch] = useReducer(advanceFirstBuild, { stage: "idle" } as FirstBuild); let [rebuilding, setRebuilding] = useState(false); let [rebuildError, setRebuildError] = useState(); let [rebuildNeedsAgent, setRebuildNeedsAgent] = useState(false); let draftId = useRef(undefined); - let endpoint = implementationEndpoint(room); - let reload = () => setRefresh(value => value + 1); - - let read = async (signal?: AbortSignal) => { - let value = await implementationResponse( - await fetch(endpoint, { signal, cache: "no-store" }), - ); - if (signal?.aborted) return value; - setSnapshot(current => !current || value.revision >= current.revision ? value : current); - setLoadError(undefined); - return value; - }; useEffect(() => { - setSnapshot(undefined); dispatch({ type: "reset" }); }, [room]); - useEffect(() => { - if (!enabled) return; - let controller = new AbortController(); - read(controller.signal).catch(error => { - if (!controller.signal.aborted) { - setLoadError(error instanceof Error ? error.message : "Connection failed"); - } - }); - return () => controller.abort(); - }, [enabled, endpoint, refresh]); - - useEffect(() => { - if (!enabled) return; - let off = RELOAD_ON.map(kind => wire?.on(kind, reload)); - return () => off.forEach(remove => remove?.()); - }, [enabled, wire]); - // A built document counts its edits since the build, so edits reload it, throttled. let built = !!snapshot && hasBuilt(snapshot); useEffect(() => { diff --git a/apps/web/src/implementation-snapshot.ts b/apps/web/src/implementation-snapshot.ts new file mode 100644 index 000000000..cfeb14a49 --- /dev/null +++ b/apps/web/src/implementation-snapshot.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from "react"; + +import { implementationEndpoint, implementationResponse } from "./build-start"; + +import type { ImplementationSnapshot } from "@chopin/protocol/implementation"; +import type { Wire } from "./wire"; + +const RELOAD_ON = ["plan:implementation", "plan:open", "plan:update", "experiment:changed"]; + +/** + * Full snapshots are needed only while an implementation view is active. Responses for + * another room, or older than the snapshot already held, are ignored. + */ +export function useImplementationSnapshot({ active, room, wire, reloadOn = RELOAD_ON }: { + active: boolean; + room: string; + wire?: Wire; + /** Frames that make the snapshot stale. */ + reloadOn?: string[]; +}) { + let endpoint = implementationEndpoint(room); + let [loaded, setLoaded] = useState<{ room: string; value: ImplementationSnapshot }>(); + let [loadError, setLoadError] = useState(); + let [revision, setRevision] = useState(0); + let request = useRef(undefined); + let latest = useRef(loaded); + let currentRoom = useRef(room); + currentRoom.current = room; + let refresh = () => { + // Invalidate immediately, before React commits the next request's effect. + request.current?.abort(); + setRevision(value => value + 1); + }; + /** Read the snapshot now; the newest one for this room is kept. */ + let read = async (signal?: AbortSignal) => { + let value = await implementationResponse( + await fetch(endpoint, { signal, cache: "no-store" }), + ); + if (signal?.aborted || currentRoom.current !== room) return value; + if (latest.current?.room === room && latest.current.value.revision > value.revision) { + return value; + } + latest.current = { room, value }; + setLoaded(latest.current); + setLoadError(undefined); + return value; + }; + useEffect(() => { + if (!active) return; + let controller = new AbortController(); + request.current = controller; + read(controller.signal).catch(error => { + if (controller.signal.aborted) return; + setLoadError(error instanceof Error ? error.message : "Connection failed"); + }); + return () => controller.abort(); + }, [active, endpoint, revision]); + useEffect(() => { + if (!active) return; + let off = reloadOn.map(kind => wire?.on(kind, refresh)); + return () => off.forEach(remove => remove?.()); + }, [active, wire]); + return { + snapshot: loaded?.room === room ? loaded.value : undefined, + loadError, + refresh, + read, + }; +} From 25be76b095c3783fbeaa1f7b80272aad1aec56ea Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:59:10 +0100 Subject: [PATCH 3/3] lane: sync port-460-snapshot memory --- ...01M4MS3REYMKBZ9B99103EZSG7-the-wrap-scroll-switch-is-th.md | 0 ...01M4MS3RF98RJCVRDF7952GPYG-data-more-on-table-is-writte.md | 4 ++++ 2 files changed, 4 insertions(+) rename .lane/{memory => attic}/packages/editor/src/widgets/code-view.tsx/01M4MS3REYMKBZ9B99103EZSG7-the-wrap-scroll-switch-is-th.md (100%) diff --git a/.lane/memory/packages/editor/src/widgets/code-view.tsx/01M4MS3REYMKBZ9B99103EZSG7-the-wrap-scroll-switch-is-th.md b/.lane/attic/packages/editor/src/widgets/code-view.tsx/01M4MS3REYMKBZ9B99103EZSG7-the-wrap-scroll-switch-is-th.md similarity index 100% rename from .lane/memory/packages/editor/src/widgets/code-view.tsx/01M4MS3REYMKBZ9B99103EZSG7-the-wrap-scroll-switch-is-th.md rename to .lane/attic/packages/editor/src/widgets/code-view.tsx/01M4MS3REYMKBZ9B99103EZSG7-the-wrap-scroll-switch-is-th.md diff --git a/.lane/memory/packages/editor/src/table/edges.ts/01M4MS3RF98RJCVRDF7952GPYG-data-more-on-table-is-writte.md b/.lane/memory/packages/editor/src/table/edges.ts/01M4MS3RF98RJCVRDF7952GPYG-data-more-on-table-is-writte.md index 30842e056..61db56585 100644 --- a/.lane/memory/packages/editor/src/table/edges.ts/01M4MS3RF98RJCVRDF7952GPYG-data-more-on-table-is-writte.md +++ b/.lane/memory/packages/editor/src/table/edges.ts/01M4MS3RF98RJCVRDF7952GPYG-data-more-on-table-is-writte.md @@ -3,6 +3,10 @@ id: 01M4MS3RF98RJCVRDF7952GPYG anchor: '@file' created: 2026-10-11T06:06:28Z norm: '1' +sig: e3b0c44298fc1c14 +body_hash: fc773ccfee587124 +raw_hash: f0087eaf76d78aaf +lines: 1-66 --- data-more on is written outside Lexical; tables are re-found on every editor update because Lexical can replace a table element without its shape changing.