Show nested canvas directory and reduce canvas loading overhead

This commit is contained in:
2026-11-06 20:48:08 +08:00
parent 05d96abd0e
commit af844131ed
15 changed files with 254 additions and 82 deletions
+21 -10
View File
@@ -20,6 +20,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
const [conflicts, setConflicts] = useState(new Set<string>());
const [activeEdges, setActiveEdges] = useState(new Set<string>());
const reference = useRef(tree); reference.current = tree;
const wake = useRef<() => void>(() => {});
const cache = useRef(new Map<string, AIRun>());
const edgeStates = useRef(new Map<string, Set<string>>());
const pendingRefresh = useRef(new Map<string, number>());
@@ -27,20 +28,24 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
useEffect(() => {
if (!live) return;
let alive = true, timer: ReturnType<typeof setTimeout> | undefined;
let polling = false, dirty = false, nextDiscovery = 0;
const controller = new AbortController();
const request = <T,>(path: string) => api<T>(path, { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } });
async function discover() {
let cursor = ""; const seen = new Set<string>();
do {
const page = await request<{ runs: { run_id: string; board_id: string; status: string }[]; next_cursor?: string }>(canvasPath(workspace, root) + "/ai/runs?status=active&include_descendants=true&limit=50" + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""));
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) cache.current.set(run.run_id, { ...run, next_seq: 0 });
for (const run of page.runs || []) if (!cache.current.has(run.run_id)) { cache.current.set(run.run_id, { ...run, next_seq: 0 }); dirty = true; }
cursor = page.next_cursor || ""; if (seen.has(cursor)) throw new Error("invalid_run_cursor"); seen.add(cursor);
} while (cursor && alive);
}
let polls = 0;
const hasActive = () => [...cache.current.values()].some((run) => active(run.status) || run.has_more);
async function poll() {
if (!alive || polling) return;
if (document.visibilityState === "hidden") { timer = setTimeout(poll, 30000); return; }
polling = true;
try {
if (++polls % 4 === 0) await discover();
if (Date.now() >= nextDiscovery) { await discover(); nextDiscovery = Date.now() + (hasActive() ? 6000 : 30000); }
for (const [id, previous] of cache.current) {
if (!active(previous.status) && !previous.has_more) continue;
let current = previous;
@@ -54,6 +59,7 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
}
if (!active(page.status)) edges.clear(); edgeStates.current.set(id, edges);
current = { ...page, board_id: previous.board_id, events: [...(current.events || []), ...(page.events || [])].slice(-1000) };
if (current.next_seq !== previous.next_seq || current.status !== previous.status) dirty = true;
cache.current.set(id, current);
} while (current.has_more && alive);
}
@@ -68,27 +74,32 @@ export function useCanvasAI(workspace: string, root: string, live: boolean, tree
}
pendingRefresh.current.delete(board);
}
if (alive) { changed(); setError(""); }
if (alive) { if (dirty) { changed(); dirty = false; } setError(""); }
} catch (error) { if (alive && !controller.signal.aborted) { changed(); setError(error instanceof Error ? error.message : "request_failed"); } }
if (alive) timer = setTimeout(poll, 1200);
polling = false;
if (alive) timer = setTimeout(poll, hasActive() || pendingRefresh.current.size ? 1200 : 30000);
}
void discover().catch((error) => { if (alive) setError(error.message); }).finally(() => { if (alive) void poll(); });
return () => { alive = false; controller.abort(); if (timer) clearTimeout(timer); };
const resume = () => { if (timer) clearTimeout(timer); nextDiscovery = 0; void poll(); };
wake.current = resume;
const visible = () => { if (document.visibilityState === "visible") resume(); };
document.addEventListener("visibilitychange", visible);
void poll();
return () => { alive = false; wake.current = () => {}; controller.abort(); if (timer) clearTimeout(timer); document.removeEventListener("visibilitychange", visible); };
}, [workspace, root, live]);
async function run(boardId: string, node: string, model: string, prompt: string, propagate: boolean) {
if (!live) throw new Error("demo_ai_unavailable");
const result = await reference.current.command(boardId, "ai/runs", { node_id: node, model_id: model, prompt, parameters: {}, propagate }, undefined, false);
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed();
const id = result.run_id as string; cache.current.set(id, { run_id: id, board_id: boardId, status: "queued", next_seq: 0, steps: [{ step_id: "pending", board_id: boardId, node_id: node, status: "queued" }] }); changed(); wake.current();
}
async function cancel(run: AIRun) {
await api(canvasPath(workspace, run.board_id) + `/ai/runs/${run.run_id}/cancel`, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": crypto.randomUUID() }, body: "{}" });
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } // Poll canonical terminal state, including already applied steps.
const next = cache.current.get(run.run_id); if (next) { next.status = "queued"; } wake.current(); // Poll canonical terminal state, including already applied steps.
}
async function apply(run: AIRun, node: string, output: string, targetBoard: string) {
await reference.current.prepareApply(targetBoard, node);
await reference.current.command(run.board_id, `ai/runs/${run.run_id}/apply`, { node_id: node, output_id: output }, undefined, false, targetBoard);
setConflicts((current) => { const next = new Set(current); next.delete(targetBoard); return next; });
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); }
const previous = cache.current.get(run.run_id); if (previous) { previous.status = "queued"; changed(); } wake.current();
}
return { enabled, setEnabled, runs, conflicts, activeEdges, error, run, cancel, apply, live };
}
+1
View File
@@ -205,6 +205,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
try {
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
const changed = accept(result);
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) { const target = typeof result.board_id === "string" ? result.board_id : boardId; const remote = await read(target); install(target, remote); publish(new Map(current.current).set(target, keepSelection(remote.document, current.current.get(target)))); }
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");