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("");
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronRight, Network } from "lucide-react";
import { activeBoardAncestors, boardDirectory, filterBoardDirectory, type BoardBranch } from "@/lib/board-directory";
import { boardHref, matchesRef, type Board } from "@/lib/workspace";
import styles from "./workspace.module.css";
export function BoardDirectory({ boards, workspace, active, query, demo }: { boards: Board[]; workspace: string; active?: string; query: string; demo: boolean }) {
const [closed, setClosed] = useState(new Set<string>());
const branches = useMemo(() => filterBoardDirectory(boardDirectory(boards), query), [boards, query]);
useEffect(() => {
const path = activeBoardAncestors(boards, active);
setClosed((previous) => { const next = new Set(previous); for (const id of path) next.delete(id); return next; });
}, [active, boards]);
function render(items: BoardBranch[], depth = 0) {
return <ul className={styles.directoryList}>{items.map(({ board, children }) => {
const expanded = !!query.trim() || !closed.has(board.id);
return <li key={board.id}><div className={styles.directoryRow} style={{ paddingLeft: Math.min(depth, 8) * 16 }}>
{children.length ? <button type="button" className={styles.directoryToggle} aria-label={`${expanded ? "收起" : "展开"}${board.title}的子画布`} aria-expanded={expanded} onClick={() => setClosed((previous) => { const next = new Set(previous); if (expanded) next.add(board.id); else next.delete(board.id); return next; })}><ChevronRight size={13} style={{ transform: expanded ? "rotate(90deg)" : undefined }} /></button> : <span className={styles.directorySpacer} />}
<Link className={styles.pageLink} aria-current={matchesRef(board, active) ? "page" : undefined} href={boardHref(workspace, board.slug || board.id, demo)}><Network size={15} strokeWidth={1.6} aria-hidden="true" /><span>{board.title}</span></Link>
</div>{expanded && children.length > 0 && render(children, depth + 1)}</li>;
})}</ul>;
}
return branches.length ? render(branches) : query ? <p className={styles.hint}>没有找到相关页面</p> : null;
}
+4 -1
View File
@@ -9,7 +9,8 @@ import { loadBoardCanvas } from "@/lib/canvas-loader";
import type { CanvasSnapshot } from "@/lib/canvas-sync";
import styles from "./workspace.module.css";
import { useActiveBoard } from "./active-board";
import GraphCanvas from "@/components/canvas/graph-canvas";
import dynamic from "next/dynamic";
const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string };
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
@@ -22,6 +23,8 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
const [retry, setRetry] = useState(0);
useEffect(() => {
setLoaded(null); setError("");
// Fetch data while the much larger editor chunk loads.
void import("@/components/canvas/graph-canvas").catch(() => {});
if (demo) {
const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId));
if (item) setLoaded({ board: item, route });
@@ -124,3 +124,12 @@
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
.directoryList { list-style: none; padding: 0; margin: 0; }
.directoryRow { display: flex; align-items: center; min-width: 0; }
.directoryRow .pageLink { flex: 1; min-width: 0; padding-left: 3px; }
.directoryToggle, .directorySpacer { flex: 0 0 20px; width: 20px; height: 26px; }
.directoryToggle { display: grid; place-items: center; border: 0; border-radius: 4px; background: transparent; color: #87837c; cursor: pointer; }
.directoryToggle:hover { background: #eae7e2; }
.directoryToggle:focus-visible { outline: 2px solid #9b877a; outline-offset: -2px; }
.directoryToggle svg { transition: transform 120ms ease; }