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.
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/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,
+ };
+}
diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx
index 796d62b9a..f8ad2eaea 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"> & {