chore: 收口 review 执行清单与 runtime 验证

- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录

- 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目

- 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑

- 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
lix-2026
2026-05-14 05:52:08 +08:00
parent b4a452a8b7
commit 96e03645f7
69 changed files with 4780 additions and 979 deletions
@@ -24,6 +24,7 @@ export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data,
treeStreamStatus: treeStream.status,
treeStreamCursor: treeStream.cursor,
});
return (
@@ -815,6 +815,7 @@ export function DocumentAiAgentPanelRuntime({
subtree: contextSubtree,
outline: contextOutline,
evidence: contextEvidence,
pageSubtreeSource: pageAggregateSnapshot.pageSubtreeSource ?? "none",
pageOptions: pageAggregateSnapshot.pageOptions,
},
options: {
@@ -11,6 +11,7 @@ import { useAiAgentUiStore } from "@/store/ai-agent-ui";
export type PageAggregateAiSnapshot = {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
pageSubtreeSource?: "server" | "local" | "none";
persistedMeta: {
workspaceId: string | null;
revision: number | null;
@@ -45,6 +45,9 @@ type SidebarProps = {
activeTab: SidebarPanel | null;
onClose: () => void;
};
// 旧 Next route 已退场;补完节点能力保留在 AI Agent 的 mindmap_expand_node 工具中。
const MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED = false;
const ColorInput = ({
value,
@@ -1631,41 +1634,43 @@ const AiPanel = ({
{docDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{docDebug}</div>}
</div>
<div className="space-y-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI </Label>
<div className="flex items-center gap-2">
<Toggle
pressed={expandUseSearx}
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
size="sm"
className="text-xs"
>
<Network className="h-4 w-4 mr-1" />
</Toggle>
{MINDMAP_LEGACY_EXPAND_NODE_ENTRY_ENABLED ? (
<div className="space-y-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI </Label>
<div className="flex items-center gap-2">
<Toggle
pressed={expandUseSearx}
onPressedChange={(v) => setExpandUseSearx(Boolean(v))}
size="sm"
className="text-xs"
>
<Network className="h-4 w-4 mr-1" />
</Toggle>
</div>
</div>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={3}
value={expandInstruction}
onChange={(e) => setExpandInstruction(e.target.value)}
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
/>
<button
type="button"
disabled={expandLoading}
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={runExpandSelectedNode}
>
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
</button>
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
<p className="text-xs text-gray-400">
SearxNG + 线 AI ops mindmap
</p>
</div>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={3}
value={expandInstruction}
onChange={(e) => setExpandInstruction(e.target.value)}
placeholder="例如:补充该节点的关键概念、常见误区与参考链接(每条都要可点击来源)"
/>
<button
type="button"
disabled={expandLoading}
className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={runExpandSelectedNode}
>
{expandLoading ? "补完中..." : "补完选中节点(写入并保存)"}
</button>
{expandDebug && <div className="text-xs text-gray-500 whitespace-pre-wrap">{expandDebug}</div>}
<p className="text-xs text-gray-400">
SearxNG + 线 AI ops mindmap
</p>
</div>
) : null}
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
@@ -104,6 +104,7 @@ describe("page-aggregate-client-state", () => {
const state = createPageAggregateClientState(page);
expect(state.documentId).toBe("doc_1");
expect(state.serverPageTitle).toBe("页面标题");
expect(state.persistedPageTitle).toBeNull();
expect(state.draftPageTitle).toBeNull();
@@ -221,7 +222,7 @@ describe("page-aggregate-client-state", () => {
});
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
it("本地标题或正文与服务端快照不一致时,应生成临时 pageSubtree 而不是复用旧快照", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
@@ -239,7 +240,17 @@ describe("page-aggregate-client-state", () => {
selectPageAggregateClientPageSubtree(localContentState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
).toEqual(
expect.objectContaining({
projectionId: expect.any(String),
projection: "page_tree",
rootNode: expect.objectContaining({
metadata: expect.objectContaining({
title: "页面标题",
}),
}),
}),
);
const draftTitleState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title",
@@ -249,7 +260,16 @@ describe("page-aggregate-client-state", () => {
selectPageAggregateClientPageSubtree(draftTitleState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
).toEqual(
expect.objectContaining({
projection: "page_tree",
rootNode: expect.objectContaining({
metadata: expect.objectContaining({
title: "草稿标题",
}),
}),
}),
);
const persistedTitleState = pageAggregateClientStateReducer(initialState, {
type: "commit_persisted_page_title",
@@ -299,6 +319,7 @@ describe("page-aggregate-client-state", () => {
expect(snapshot).toEqual({
blocks: page.body.content as Json,
pageSubtree: page.tree.pageSubtree,
pageSubtreeSource: "server",
persistedMeta: {
workspaceId: "ws_1",
revision: 3,
@@ -1,10 +1,13 @@
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { buildPageSubtreeProjection, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
export type PageAggregateClientPageSubtreeSource = "server" | "local" | "none";
export type PageAggregateClientState = {
documentId: string;
serverPageTitle: string;
persistedPageTitle: string | null;
draftPageTitle: string | null;
@@ -71,6 +74,7 @@ export function createPageAggregateClientState(
page: PageAggregateProjection,
): PageAggregateClientState {
return {
documentId: page.identity.documentId,
serverPageTitle: normalizePageTitle(page.head.title),
persistedPageTitle: null,
draftPageTitle: null,
@@ -160,13 +164,43 @@ export function selectPageAggregateClientPageSubtree(
liveSidebarTitle: string | null;
},
): PageSubtreeProjection | null {
return selectPageAggregateClientPageSubtreeState(state, input).pageSubtree;
}
export function selectPageAggregateClientPageSubtreeState(
state: PageAggregateClientState,
input: {
liveSidebarTitle: string | null;
},
): {
pageSubtree: PageSubtreeProjection | null;
source: PageAggregateClientPageSubtreeSource;
} {
const titleState = selectPageAggregateClientTitleState(state, input);
const contentUnchanged = state.content === state.serverContentSnapshot;
if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) {
return withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle);
return {
pageSubtree: withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle),
source: "server",
};
}
return null;
if (state.content != null) {
return {
pageSubtree: buildPageSubtreeProjection({
documentId: state.documentId,
title: titleState.displayTitle,
content: state.content,
}),
source: "local",
};
}
return {
pageSubtree: null,
source: "none",
};
}
export function selectPageAggregateClientAiSnapshot(
@@ -178,6 +212,7 @@ export function selectPageAggregateClientAiSnapshot(
): {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
pageSubtreeSource: PageAggregateClientPageSubtreeSource;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
@@ -186,12 +221,14 @@ export function selectPageAggregateClientAiSnapshot(
pageOptions: PageOptionsState;
} {
const blocks = state.content as Json | null;
const pageSubtreeState = selectPageAggregateClientPageSubtreeState(state, {
liveSidebarTitle: input.liveSidebarTitle,
});
return {
blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, {
liveSidebarTitle: input.liveSidebarTitle,
}),
pageSubtree: pageSubtreeState.pageSubtree,
pageSubtreeSource: pageSubtreeState.source,
persistedMeta: {
workspaceId: input.workspaceId,
revision: state.contentRevision,
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
const TREE_SHELL_DOM_HOST_SOURCE = path.join(process.cwd(), "src/components/sidebar/tree-shell-dom-host.tsx");
describe("sidebar file tree delete preflight source", () => {
it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => {
@@ -28,4 +29,58 @@ describe("sidebar file tree delete preflight source", () => {
expect(source).toContain('if (viewMode === "filesystem") {');
expect(source).toContain("await handleDeleteResourceSelection();");
});
it("页面新建/删除成功后应本地更新 Sidebar,不再同步等待整树刷新", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const createStart = source.indexOf("const handleCreate = useCallback(");
const createEnd = source.indexOf("const handleRename = useCallback(");
const deleteSelectionStart = source.indexOf("const handleDeleteResourceSelection = useCallback(");
const deleteSelectionEnd = source.indexOf("const handleFileTreeShellDeleteSelection = useCallback(");
const deleteStart = source.indexOf("const handleDelete = useCallback(");
const deleteEnd = source.indexOf("const handleDeleteFromContextMenuNode = useCallback(");
expect(createStart).toBeGreaterThanOrEqual(0);
expect(createEnd).toBeGreaterThan(createStart);
expect(deleteSelectionStart).toBeGreaterThanOrEqual(0);
expect(deleteSelectionEnd).toBeGreaterThan(deleteSelectionStart);
expect(deleteStart).toBeGreaterThanOrEqual(0);
expect(deleteEnd).toBeGreaterThan(deleteStart);
const createBody = source.slice(createStart, createEnd);
const deleteSelectionBody = source.slice(deleteSelectionStart, deleteSelectionEnd);
const deleteBody = source.slice(deleteStart, deleteEnd);
expect(createBody).not.toContain("await refreshTree();");
expect(deleteSelectionBody).toContain("removeDocumentsFromTree(docIds);");
expect(deleteSelectionBody).not.toContain("await refreshTree();");
expect(deleteBody).toContain("removeDocumentsFromTree([documentId]);");
expect(deleteBody).not.toContain("await refreshTree();");
});
it("Rust-family tree shell mutation 成功后应本地 apply,不再默认整树刷新", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
const domHostSource = fs.readFileSync(TREE_SHELL_DOM_HOST_SOURCE, "utf8");
const mutationStart = source.indexOf("const handleTreeShellMutation = useCallback(");
const mutationEnd = source.indexOf("useEffect(() => {", mutationStart);
expect(mutationStart).toBeGreaterThanOrEqual(0);
expect(mutationEnd).toBeGreaterThan(mutationStart);
const mutationBody = source.slice(mutationStart, mutationEnd);
expect(mutationBody).toContain('payload.type === "tree.node.created"');
expect(mutationBody).toContain('payload.type === "tree.node.renamed"');
expect(mutationBody).toContain('payload.type === "tree.subtree.moved"');
expect(mutationBody).toContain("insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode)");
expect(mutationBody).toContain("renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null)");
expect(mutationBody).toContain("moveLocalNode(prev, documentId, parentId, sortOrder)");
expect(mutationBody).not.toMatch(
/const handleTreeShellMutation = useCallback\(\(\) => \{\s*void refreshTree\(\);\s*\}, \[refreshTree\]\);/,
);
expect(domHostSource).toContain("onTreeMutation?.({");
expect(domHostSource).toContain('type: "tree.node.created"');
expect(domHostSource).toContain("parentId: commandParentId");
expect(domHostSource).toContain("title: commandTitle");
expect(domHostSource).toContain("sortOrder: commandSortOrder");
});
});
+137 -20
View File
@@ -50,6 +50,7 @@ import {
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import type { TreeShellMutationPayload } from "@/components/sidebar/tree-shell-host";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import {
buildPageTreeProjectionItems,
@@ -203,6 +204,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data,
treeStreamStatus: treeStream.status,
treeStreamCursor: treeStream.cursor,
});
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
@@ -1188,9 +1190,91 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[assetById, handleOpenAssetInMain],
);
const handleTreeShellMutation = useCallback(() => {
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) {
return currentTree;
}
const next = insertNode(withoutTarget, parentId, index, removed);
return next;
}, []);
const handleTreeShellMutation = useCallback((payload: TreeShellMutationPayload) => {
const documentId = payload.documentId?.trim() || "";
if (!documentId) {
void refreshTree();
return;
}
if (payload.type === "tree.node.created") {
const parentId = payload.parentId?.trim() || null;
const title = payload.title?.trim() || "无标题";
const createdAt = payload.execution?.created_at || new Date().toISOString();
const updatedAt = payload.execution?.updated_at ?? payload.updatedAt ?? createdAt;
const commandSortOrder =
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
? payload.sortOrder
: null;
const accessScope = payload.execution?.access_scope ?? "private";
const nextNode: SidebarTreeNode = {
id: documentId,
workspace_id: payload.workspaceId?.trim() || sidebarData.activeWorkspaceId || "",
title,
parent_id: parentId,
sort_order: commandSortOrder,
access_scope: accessScope,
is_starred: false,
is_template: payload.execution?.is_template ?? false,
created_at: createdAt,
updated_at: updatedAt,
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: commandSortOrder,
childCount: 0,
expandedByDefault: true,
},
};
setTree((prev) => {
if (nodeById.has(documentId)) return prev;
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
});
setExpanded((prev) => {
const next = new Set(prev);
if (parentId) next.add(parentId);
next.add(documentId);
return next;
});
return;
}
if (payload.type === "tree.node.renamed") {
const title = payload.title?.trim();
if (!title) {
void refreshTree();
return;
}
setTree((prev) => renameDocumentInTree(prev, documentId, title, payload.updatedAt ?? null));
return;
}
if (payload.type === "tree.subtree.moved") {
const parentId = payload.parentId?.trim() || null;
const sortOrder =
typeof payload.sortOrder === "number" && Number.isFinite(payload.sortOrder)
? payload.sortOrder
: Number.MAX_SAFE_INTEGER;
setTree((prev) => moveLocalNode(prev, documentId, parentId, sortOrder));
if (parentId) {
setExpanded((prev) => new Set(prev).add(parentId));
}
return;
}
void refreshTree();
}, [refreshTree]);
}, [moveLocalNode, nodeById, refreshTree, sidebarData.activeWorkspaceId]);
useEffect(() => {
const handler = async (event: KeyboardEvent) => {
@@ -1619,6 +1703,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
);
const removeDocumentsFromTree = useCallback((documentIds: string[]) => {
const targetIds = Array.from(new Set(documentIds.map((item) => item.trim()).filter(Boolean)));
if (targetIds.length === 0) return;
setTree((prev) => {
let next = prev;
let changed = false;
targetIds.forEach((documentId) => {
const result = removeNode(next, documentId);
if (result.removed) {
next = result.tree;
changed = true;
}
});
return changed ? next : prev;
});
}, []);
const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
selectedRowIds: string[];
anchorRowId: string | null;
@@ -1734,6 +1835,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
removeDocumentsFromTree(docIds);
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
if (activeId && docIds.includes(activeId)) {
router.push("/");
@@ -1744,7 +1846,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
await handleDeleteAssets(assetIds, selectedAssetHints);
}
await refreshTree();
setContextMenu(null);
if (!isRustFamilyTreeRenderer) {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
@@ -1760,7 +1861,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
resourceShellRowById,
resourceSelection,
handleDeleteAssets,
refreshTree,
removeDocumentsFromTree,
router,
sidebarData.activeWorkspaceId,
]);
@@ -1844,7 +1945,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return next;
});
await refreshTree();
const query = new URLSearchParams();
if (nextNode.workspace_id) {
query.set("workspaceId", nextNode.workspace_id);
@@ -1861,7 +1961,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
creatingDocumentUnderParentRef.current.delete(creatingKey);
}
},
[refreshTree, router],
[router],
);
const handleRename = useCallback(
@@ -1885,16 +1985,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[refreshTree, sidebarData.activeWorkspaceId],
);
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) {
return currentTree;
}
const next = insertNode(withoutTarget, parentId, index, removed);
return next;
}, []);
const handleMove = useCallback(
async (documentId: string, parentId: string | null, index: number) => {
setTree((prev) => moveLocalNode(prev, documentId, parentId, index));
@@ -1948,8 +2038,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
if (uploadTargetPlan.targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId));
const targetMindmapId = uploadTargetPlan.targetMindmapId;
if (targetMindmapId) {
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
}
const errors: string[] = [];
@@ -2151,13 +2242,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
documentId,
workspaceId: sidebarData.activeWorkspaceId ?? null,
});
await refreshTree();
removeDocumentsFromTree([documentId]);
emitDocumentsChanged(documentId);
if (activeId === documentId) {
router.push("/");
}
},
[activeId, refreshTree, router, sidebarData.activeWorkspaceId],
[activeId, removeDocumentsFromTree, router, sidebarData.activeWorkspaceId],
);
const handleDeleteFromContextMenuNode = useCallback(
@@ -3489,6 +3580,32 @@ const removeNode = (
return { removed, tree: nextTree };
};
const renameDocumentInTree = (
nodes: SidebarTreeNode[],
documentId: string,
title: string,
updatedAt: string | null,
): SidebarTreeNode[] => {
let changed = false;
const next = nodes.map((node) => {
if (node.id === documentId) {
changed = true;
return {
...node,
title,
updated_at: updatedAt ?? node.updated_at,
};
}
const children = renameDocumentInTree(node.children, documentId, title, updatedAt);
if (children !== node.children) {
changed = true;
return { ...node, children };
}
return node;
});
return changed ? next : nodes;
};
const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
if (!parentId) {
const next = [...nodes];
@@ -15,6 +15,7 @@ import type {
FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload,
TreeShellMutationPayload,
TreeShellHostMode,
TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host";
@@ -100,7 +101,7 @@ type TreeShellRustDomShellHostProps = {
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
children?: ReactNode;
};
@@ -120,6 +121,19 @@ type TreeShellRuntimeWasmModule = {
type TreeShellRuntimeReducer = (request: unknown) => Promise<unknown> | unknown;
type TreeCommandResponsePayload = {
id?: string;
result?: {
documentId?: string;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
updatedAt?: string | null;
execution?: TreeShellMutationPayload["execution"];
} | null;
};
type TreeShellRuntimeGlobal = typeof globalThis & {
__MNOTE_TREE_SHELL_RUNTIME__?: {
reduceTreeShellRuntime?: TreeShellRuntimeReducer;
@@ -179,6 +193,20 @@ function normalizeShellRowKind(value: unknown, defaultValue = "") {
return rowKind;
}
function readTreeCommandResult(payload: unknown): NonNullable<TreeCommandResponsePayload["result"]> {
if (!isRecord(payload) || !isRecord(payload.result)) {
return {};
}
return payload.result;
}
function readTreeCommandDocumentId(payload: unknown, result: NonNullable<TreeCommandResponsePayload["result"]>) {
if (result.documentId) {
return normalizeString(result.documentId);
}
return isRecord(payload) ? normalizeString(payload.id) : "";
}
function itemHasVisibleChildren(item: TreeShellDomProjectionItem, childrenByParent: Map<string, TreeShellDomProjectionItem[]>) {
return item.childCount > 0 && (childrenByParent.get(item.nodeId)?.length ?? 0) > 0;
}
@@ -796,16 +824,40 @@ export function TreeShellRustDomShellHost({
if (event.kind === "createNode") {
const parentId = normalizeString(event.parentNodeId) || null;
const result = await runTreeCommand({ action: "create", parentId });
const documentId = normalizeString((result as { result?: { documentId?: string }; id?: string })?.result?.documentId, normalizeString((result as { id?: string })?.id));
onTreeMutation?.({ type: "tree.node.created", documentId: documentId || null });
const commandResult = readTreeCommandResult(result);
const documentId = readTreeCommandDocumentId(result, commandResult);
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
const commandTitle = normalizeString(commandResult.title, "无标题");
const commandSortOrder =
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
? commandResult.sortOrder
: null;
onTreeMutation?.({
type: "tree.node.created",
documentId: documentId || null,
parentId: commandParentId,
title: commandTitle,
sortOrder: commandSortOrder,
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
updatedAt: normalizeString(commandResult.updatedAt) || null,
execution: commandResult.execution ?? null,
});
continue;
}
if (event.kind === "renameNode") {
const documentId = normalizeString(event.nodeId);
const title = normalizeString(event.title);
if (!documentId || !title) continue;
await runTreeCommand({ action: "rename", documentId, title });
onTreeMutation?.({ type: "tree.node.renamed", documentId });
const result = await runTreeCommand({ action: "rename", documentId, title });
const commandResult = readTreeCommandResult(result);
onTreeMutation?.({
type: "tree.node.renamed",
documentId,
title: normalizeString(commandResult.title, title),
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
updatedAt: normalizeString(commandResult.updatedAt) || null,
execution: commandResult.execution ?? null,
});
continue;
}
if (event.kind === "moveSubtree") {
@@ -813,12 +865,26 @@ export function TreeShellRustDomShellHost({
if (!documentId) continue;
const parentId = normalizeString(event.targetParentId) || null;
const sortOrder = typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder) ? event.sortOrder : 0;
await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
onTreeMutation?.({ type: "tree.subtree.moved", documentId });
const result = await runTreeCommand({ action: "move", documentId, parentId, sortOrder });
const commandResult = readTreeCommandResult(result);
const commandParentId = normalizeString(commandResult.parentId, parentId ?? "") || parentId;
const commandSortOrder =
typeof commandResult.sortOrder === "number" && Number.isFinite(commandResult.sortOrder)
? commandResult.sortOrder
: sortOrder;
onTreeMutation?.({
type: "tree.subtree.moved",
documentId,
parentId: commandParentId,
sortOrder: commandSortOrder,
workspaceId: normalizeString(commandResult.workspaceId, workspaceId) || workspaceId,
updatedAt: normalizeString(commandResult.updatedAt) || null,
execution: commandResult.execution ?? null,
});
}
}
},
[onTreeMutation, runTreeCommand],
[onTreeMutation, runTreeCommand, workspaceId],
);
const handlePageOpen = useCallback(
@@ -837,7 +903,7 @@ export function TreeShellRustDomShellHost({
onPageExpandChange?.({ documentId: nodeId, expanded: state.expandedIds.includes(nodeId) });
}
},
[onPageExpandChange, pageState.expandedIds, reducePageAction],
[onPageExpandChange, pageState, reducePageAction],
);
const handlePageKeyDown = useCallback(
@@ -46,6 +46,22 @@ export type FileTreeShellDeleteSelectionPayload = {
focusedRowId: string | null;
};
export type TreeShellMutationPayload = {
type: string;
documentId: string | null;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
updatedAt?: string | null;
execution?: ({
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
} & Record<string, unknown>) | null;
};
type TreeShellHostProps = {
mode: TreeShellHostMode;
surfaceTestId: string;
@@ -89,7 +105,7 @@ type TreeShellHostProps = {
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
children: ReactNode;
};
@@ -2,9 +2,11 @@
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type {
FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload,
TreeShellHostMode,
TreeShellMutationPayload,
TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host";
import {
@@ -33,10 +35,7 @@ type TreeShellBridgePageExpandPayload = {
expanded: boolean;
};
type TreeShellBridgeMutationPayload = {
type: string;
documentId: string | null;
};
type TreeShellBridgeMutationPayload = TreeShellMutationPayload;
type TreeShellBridgeSelectionPayload = {
selectedRowIds: string[];
@@ -137,6 +136,7 @@ export type TreeShellIframeHostProps = {
onPageFocusChange?: (payload: { documentId: string | null }) => void;
onFileTreeContextMenu?: (payload: TreeShellBridgeContextMenuPayload) => void;
onFileTreeSelectionChange?: (payload: TreeShellBridgeSelectionPayload) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
@@ -160,6 +160,12 @@ type TreeShellBridgeMessage = {
copy: boolean;
files: File[];
itemKey: string | null;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
updatedAt?: string | null;
execution?: TreeShellMutationPayload["execution"];
};
const INLINE_TREE_SHELL_LOADING_HTML = [
@@ -1269,15 +1275,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
sortOrder = position === "after" ? targetIndex + 1 : targetIndex;
}
try {
await runTreeCommand({
const result = await runTreeCommand({
action: "move",
documentId: sourceNodeId,
parentId,
sortOrder,
});
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
setLastAction(targetItem ? \`页面已拖放到 \${targetItem.title}\` : "页面已完成拖放移动");
postToHost("tree.subtree.moved", {
documentId: sourceNodeId,
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : sortOrder,
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
updatedAt: normalizeText(commandResult.updatedAt) || null,
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
target: { documentId: sourceNodeId },
});
return true;
@@ -1301,8 +1313,15 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
normalizeText(result && result.id),
);
setLastAction(documentId ? \`已创建子页面 \${documentId}\` : "已创建子页面");
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
postToHost("tree.node.created", {
documentId: documentId || null,
parentId: normalizeText(commandResult.parentId, parentId || "") || null,
title: normalizeText(commandResult.title, "无标题"),
sortOrder: Number.isFinite(commandResult.sortOrder) ? Number(commandResult.sortOrder) : null,
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
updatedAt: normalizeText(commandResult.updatedAt) || null,
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
target: { documentId: documentId || null },
});
return true;
@@ -1322,16 +1341,21 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
);
if (!nodeId || !title) return false;
try {
await runTreeCommand({
const result = await runTreeCommand({
action: "rename",
documentId: nodeId,
title,
});
const commandResult = result && result.result && typeof result.result === "object" ? result.result : {};
const item = itemById.get(nodeId);
if (item) item.title = title;
setLastAction(\`已重命名为 \${title}\`);
postToHost("tree.node.renamed", {
documentId: nodeId,
title: normalizeText(commandResult.title, title),
workspaceId: normalizeText(commandResult.workspaceId, workspaceId) || workspaceId || null,
updatedAt: normalizeText(commandResult.updatedAt) || null,
execution: commandResult.execution && typeof commandResult.execution === "object" ? commandResult.execution : null,
target: { documentId: nodeId },
});
if (fallbackCommandEvent?.titleElement) {
@@ -4424,8 +4448,11 @@ export function TreeShellIframeHost({
mode,
workspaceId,
rootNodeId,
activePickerItemKey,
focusedDocumentId,
inlineBootstrapActiveDocumentId,
inlineInitialTreeHtml,
inlineProjectionItems,
allowRootPick,
excludeIds,
resolvedChannel,
@@ -4590,6 +4617,12 @@ export function TreeShellIframeHost({
onTreeMutation?.({
type: message.type,
documentId: message.documentId,
...(message.parentId !== undefined ? { parentId: message.parentId } : {}),
...(message.title !== undefined ? { title: message.title } : {}),
...(message.sortOrder !== undefined ? { sortOrder: message.sortOrder } : {}),
...(message.workspaceId !== undefined ? { workspaceId: message.workspaceId } : {}),
...(message.updatedAt !== undefined ? { updatedAt: message.updatedAt } : {}),
...(message.execution !== undefined ? { execution: message.execution } : {}),
});
return;
}
@@ -6,6 +6,7 @@ import {
type FileTreeShellDeleteSelectionPayload,
type FileTreeShellExternalDropPayload,
type FileTreeShellInternalDropPayload,
type TreeShellMutationPayload,
type TreeShellPickerCommand,
type TreeRendererFamily,
} from "@/components/sidebar/tree-shell-host";
@@ -36,7 +37,7 @@ type SidebarPageTreeSurfaceProps = {
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
onPageFocusChange?: (payload: { documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
};
type SidebarFileTreeSurfaceProps = {
@@ -74,7 +75,7 @@ type SidebarFileTreeSurfaceProps = {
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
};
export type SidebarTreeSurfaceProps =
@@ -40,6 +40,7 @@ function Harness(props: {
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
treeStreamCursor?: string | null;
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
}) {
const state = usePreferredSidebarSnapshot(props);
@@ -145,6 +146,80 @@ describe("usePreferredSidebarSnapshot", () => {
});
});
it("query 版本比已更新过的 stream 更新时,应优先 query,避免旧 stream 回压", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const refreshedQuery = buildSidebarData([
buildDocument({
title: "标题 C",
updated_at: "2026-04-21T00:00:02.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={staleTreeStream}
treeStreamStatus="live"
treeStreamCursor="cursor_stream_1"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "query",
cursor: null,
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 C" })],
}),
});
});
it("stream 与 query 版本相同时,应优先 stream 并暴露 cursor", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const queryData = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const treeStreamData = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={queryData}
treeStreamData={treeStreamData}
treeStreamStatus="live"
treeStreamCursor="cursor_stream_2"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
cursor: "cursor_stream_2",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
});
it("tree stream 进入 fallback 后应回退到 query 快照", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarDataSyncKey } from "@/components/sidebar/sidebar-sync";
import { buildSidebarDataSyncKey, getSidebarDataFreshness } from "@/components/sidebar/sidebar-sync";
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
@@ -9,6 +9,7 @@ export function usePreferredSidebarSnapshot(input: {
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
treeStreamCursor?: string | null;
}) {
const querySyncKey = useMemo(
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
@@ -19,29 +20,40 @@ export function usePreferredSidebarSnapshot(input: {
[input.treeStreamData],
);
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
const initialVersion = useMemo(() => getSidebarDataFreshness(input.initialData), [input.initialData]);
const queryVersion = useMemo(
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : null),
[input.sidebarQueryData],
);
const treeStreamVersion = useMemo(
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : null),
[input.treeStreamData],
);
const streamIsPreferred = input.treeStreamStatus !== "fallback";
const queryHasFreshSnapshot =
input.sidebarQueryData != null &&
querySyncKey != null &&
querySyncKey !== initialSyncKey &&
treeStreamSyncKey === initialSyncKey;
const preferredVersion = Math.max(
initialVersion,
queryVersion ?? 0,
streamIsPreferred ? treeStreamVersion ?? 0 : 0,
);
const queryHasPreferredVersion = queryVersion != null && queryVersion >= preferredVersion;
const treeStreamHasPreferredVersion =
streamIsPreferred &&
input.treeStreamData != null &&
treeStreamVersion != null &&
treeStreamVersion >= preferredVersion;
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
if (queryHasFreshSnapshot) {
return "query";
}
if (input.treeStreamData && streamIsPreferred) {
if (treeStreamHasPreferredVersion) {
return "tree_stream";
}
if (input.sidebarQueryData) {
if (queryHasPreferredVersion) {
return "query";
}
return "initial";
}, [
input.sidebarQueryData,
input.treeStreamData,
queryHasFreshSnapshot,
streamIsPreferred,
queryHasPreferredVersion,
treeStreamHasPreferredVersion,
]);
const data =
@@ -56,13 +68,22 @@ export function usePreferredSidebarSnapshot(input: {
: source === "query"
? querySyncKey ?? initialSyncKey
: initialSyncKey;
const version =
source === "tree_stream"
? treeStreamVersion ?? initialVersion
: source === "query"
? queryVersion ?? initialVersion
: initialVersion;
const cursor = source === "tree_stream" ? input.treeStreamCursor ?? null : null;
return useMemo(
() => ({
data,
source,
syncKey,
version,
cursor,
}),
[data, source, syncKey],
[cursor, data, source, syncKey, version],
);
}