Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <table> is written outside Lexical; tables are re-found on every editor update because Lexical can replace a table element without its shape changing.
41 changes: 41 additions & 0 deletions apps/server/src/tasks/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
1 change: 1 addition & 0 deletions apps/server/src/tasks/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand Down
48 changes: 8 additions & 40 deletions apps/web/src/build-plan-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -93,49 +88,22 @@ export function useBuildPlan(
wire?: Wire;
},
): BuildPlan {
let [snapshot, setSnapshot] = useState<ImplementationSnapshot>();
let [loadError, setLoadError] = useState<string>();
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<string>();
let [rebuildNeedsAgent, setRebuildNeedsAgent] = useState(false);
let draftId = useRef<string>(undefined);
let endpoint = implementationEndpoint(room);
let reload = () => setRefresh(value => value + 1);

let read = async (signal?: AbortSignal) => {
let value = await implementationResponse<ImplementationSnapshot>(
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(() => {
Expand Down
69 changes: 69 additions & 0 deletions apps/web/src/implementation-snapshot.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
let [revision, setRevision] = useState(0);
let request = useRef<AbortController | undefined>(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<ImplementationSnapshot>(
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,
};
}
3 changes: 2 additions & 1 deletion apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -529,13 +529,14 @@ export function RoomWorkspace(
let [implementation, setImplementation] = useState<Plan.ImplementationStatus>({
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);
};
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/plan.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"> & {
Expand Down