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"> & {