From 1589ae31bc601c92d22fa351dc2f01ff19922968 Mon Sep 17 00:00:00 2001 From: kristinhe Date: Wed, 4 Nov 2026 16:06:56 +0800 Subject: [PATCH 1/2] fix(memhub): make topic nodes hover-only Co-authored-by: Cursor --- memhub/apps/web/src/MemoryGraph3D.test.tsx | 20 +++++- memhub/apps/web/src/MemoryGraph3D.tsx | 80 +++++++++++++++------- memhub/apps/web/src/MemoryMap.test.tsx | 14 ++-- memhub/apps/web/src/MemoryMap.tsx | 8 +-- memhub/apps/web/src/core-visual.css | 8 +++ 5 files changed, 93 insertions(+), 37 deletions(-) diff --git a/memhub/apps/web/src/MemoryGraph3D.test.tsx b/memhub/apps/web/src/MemoryGraph3D.test.tsx index b6cddf3..65d69c7 100644 --- a/memhub/apps/web/src/MemoryGraph3D.test.tsx +++ b/memhub/apps/web/src/MemoryGraph3D.test.tsx @@ -6,7 +6,11 @@ import type { } from "@one-memory/contracts"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; -import { MemoryGraph3D } from "./MemoryGraph3D"; +import { + graphNodeTooltip, + isGraphNodeSelectable, + MemoryGraph3D, +} from "./MemoryGraph3D"; afterEach(cleanup); @@ -48,6 +52,20 @@ function makeGraph(nodeCount: number): MemoryGraphResponse { } describe("MemoryGraph3D", () => { + it("keeps topic annotations hover-only", () => { + const topic = { + ...makeGraph(1).nodes[0], + node_type: "topic" as const, + label: "CI", + }; + expect(isGraphNodeSelectable(topic)).toBe(false); + expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI"); + expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI"); + expect( + isGraphNodeSelectable({ ...topic, node_type: "memory" }), + ).toBe(true); + }); + it("没有 WebGL 时给出可访问降级提示", () => { render( (null); const labelHost = useRef(null); const selectRef = useRef(onSelectNode); + const nodesByIdRef = useRef( + new Map(graph.nodes.map((node) => [node.node_id, node])), + ); + nodesByIdRef.current = new Map( + graph.nodes.map((node) => [node.node_id, node]), + ); + const topicPrefixRef = useRef(""); + topicPrefixRef.current = `${text("主题")}${ + locale.startsWith("zh") || locale.startsWith("ja") ? ":" : ": " + }`; // 选中态通过 ref 传入渲染循环,而不是进 useEffect 依赖。 // 依赖里带 selectedNodeId 会导致每次点击重建整个场景、重跑布局、重建全部 mesh。 const selectedRef = useRef(selectedNodeId); @@ -157,9 +179,7 @@ export function MemoryGraph3D({ } } const labels = graph.nodes - .filter( - (node) => node.node_type === "source" || node.node_type === "topic", - ) + .filter((node) => node.node_type === "source") .slice(0, 4) .map((node) => { const element = document.createElement("span"); @@ -194,7 +214,7 @@ export function MemoryGraph3D({ } }; - const selectable: THREE.Mesh[] = []; + const hittable: THREE.Mesh[] = []; const materials = new Set(); const geometryByType = { source: new THREE.SphereGeometry(0.24, 18, 12), @@ -237,7 +257,7 @@ export function MemoryGraph3D({ mesh.scale.setScalar(1); mesh.visible = returning || extending; scene.add(mesh); - selectable.push(mesh); + hittable.push(mesh); visuals.push({ mesh, material, @@ -298,7 +318,6 @@ export function MemoryGraph3D({ }); } - const nodesById = new Map(graph.nodes.map((node) => [node.node_id, node])); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const hitAt = (event: PointerEvent) => { @@ -307,7 +326,7 @@ export function MemoryGraph3D({ pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); - return raycaster.intersectObjects(selectable, false)[0]; + return raycaster.intersectObjects(hittable, false)[0]; }; let pressed: { x: number; y: number; id: number; moved: boolean } | null = null; @@ -333,8 +352,9 @@ export function MemoryGraph3D({ ) return; const nodeId = hitAt(event)?.object.userData.nodeId; - const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null; - if (node) selectRef.current(node); + const node = + typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null; + if (node && isGraphNodeSelectable(node)) selectRef.current(node); }; const hoverNode = (event: PointerEvent) => { if (pressed) { @@ -344,12 +364,18 @@ export function MemoryGraph3D({ return; } const nodeId = hitAt(event)?.object.userData.nodeId; - const node = typeof nodeId === "string" ? nodesById.get(nodeId) : null; - renderer.domElement.style.cursor = node ? "pointer" : "grab"; + const node = + typeof nodeId === "string" ? nodesByIdRef.current.get(nodeId) : null; + renderer.domElement.style.cursor = + node && isGraphNodeSelectable(node) + ? "pointer" + : node + ? "default" + : "grab"; setHovered( node ? { - label: node.label, + label: graphNodeTooltip(node, topicPrefixRef.current), x: event.clientX - element.getBoundingClientRect().left, y: event.clientY - element.getBoundingClientRect().top, } @@ -629,17 +655,23 @@ export function MemoryGraph3D({ {!available ? (

{text("这台设备打不开 3D 视图,已显示关联列表。")}

- {graph.nodes.map((node) => ( - - ))} + {graph.nodes.map((node) => + isGraphNodeSelectable(node) ? ( + + ) : ( + + {graphNodeTooltip(node, topicPrefixRef.current)} + + ), + )}
) : null} {available && ( diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx index ccb051b..88a0359 100644 --- a/memhub/apps/web/src/MemoryMap.test.tsx +++ b/memhub/apps/web/src/MemoryMap.test.tsx @@ -149,7 +149,7 @@ it("OBS-22: the graph inspector has no editing entry and never says changes cann expect(screen.queryByText(/暂不能保存更改/)).toBeNull(); }); -it("replaces a topic detail with an off-page graph memory detail", async () => { +it("keeps topic annotations out of the inspector and opens detail-bearing graph nodes", async () => { const onOpen = vi.fn(); const topicGraph = { total_memories: 2, @@ -221,13 +221,13 @@ it("replaces a topic detail with an off-page graph memory detail", async () => { />, ); + await act(async () => {}); + onOpen.mockClear(); await userEvent.click(screen.getByRole("button", { name: "图中选择 git" })); - expect(screen.getByRole("heading", { name: "git" })).toBeTruthy(); - expect(screen.getAllByText("第一条").length).toBeGreaterThan(0); - expect(screen.queryByText("对应记忆")).toBeNull(); - expect(screen.queryByText("属于主题")).toBeNull(); - expect(screen.queryByText("主题")).toBeNull(); - expect(screen.queryByText("两跳")).toBeNull(); + expect(onOpen).not.toHaveBeenCalled(); + expect(screen.queryByRole("heading", { name: "git" })).toBeNull(); + expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy(); + expect(screen.getByTestId("canvas").dataset.selected).toBe("memory:m0"); await userEvent.click( screen.getByRole("button", { name: "图中选择 先验证,再推送" }), ); diff --git a/memhub/apps/web/src/MemoryMap.tsx b/memhub/apps/web/src/MemoryMap.tsx index c68962a..098814b 100644 --- a/memhub/apps/web/src/MemoryMap.tsx +++ b/memhub/apps/web/src/MemoryMap.tsx @@ -684,6 +684,9 @@ export function MemoryMap(props: Props) { setGroupKey(key); } function pickNode(node: MemoryGraphNode) { + // Topics are graph annotations, not detail-bearing records. They remain + // hoverable in the canvas but must not replace the memory inspector. + if (node.node_type === "topic") return; if (node.memory_id) { pick(node.memory_id); return; @@ -708,11 +711,6 @@ export function MemoryMap(props: Props) { if (first) pick(first, false); } } - if (node.node_type === "topic") { - if (props.simplified) return; - setAxis("topic"); - setGroupKey(`topic:${node.label}`); - } } const label = group?.label ?? diff --git a/memhub/apps/web/src/core-visual.css b/memhub/apps/web/src/core-visual.css index 37ef2c4..534a720 100644 --- a/memhub/apps/web/src/core-visual.css +++ b/memhub/apps/web/src/core-visual.css @@ -981,6 +981,14 @@ button.map-tool { background: #fff; box-shadow: var(--shadow-pop); } +.memory-graph-fallback-topic { + display: block; + width: 100%; + margin-block: 6px; + padding: 8px 14px; + color: var(--tx2); + overflow-wrap: anywhere; +} .graph-labels { position: absolute; inset: 0; From 93d0c6449092ba795e2ca66dc44ade89dedb99a0 Mon Sep 17 00:00:00 2001 From: kristinhe Date: Wed, 4 Nov 2026 16:10:30 +0800 Subject: [PATCH 2/2] style(memhub): format graph topic changes Co-authored-by: Cursor --- memhub/apps/web/src/MemoryGraph3D.test.tsx | 4 +--- memhub/apps/web/src/MemoryGraph3D.tsx | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/memhub/apps/web/src/MemoryGraph3D.test.tsx b/memhub/apps/web/src/MemoryGraph3D.test.tsx index 65d69c7..1a348a7 100644 --- a/memhub/apps/web/src/MemoryGraph3D.test.tsx +++ b/memhub/apps/web/src/MemoryGraph3D.test.tsx @@ -61,9 +61,7 @@ describe("MemoryGraph3D", () => { expect(isGraphNodeSelectable(topic)).toBe(false); expect(graphNodeTooltip(topic, "主题:")).toBe("主题:CI"); expect(graphNodeTooltip(topic, "Topic: ")).toBe("Topic: CI"); - expect( - isGraphNodeSelectable({ ...topic, node_type: "memory" }), - ).toBe(true); + expect(isGraphNodeSelectable({ ...topic, node_type: "memory" })).toBe(true); }); it("没有 WebGL 时给出可访问降级提示", () => { diff --git a/memhub/apps/web/src/MemoryGraph3D.tsx b/memhub/apps/web/src/MemoryGraph3D.tsx index 0080f10..48e2256 100644 --- a/memhub/apps/web/src/MemoryGraph3D.tsx +++ b/memhub/apps/web/src/MemoryGraph3D.tsx @@ -36,7 +36,9 @@ export function graphNodeTooltip( node: MemoryGraphNode, topicPrefix: string, ): string { - return node.node_type === "topic" ? `${topicPrefix}${node.label}` : node.label; + return node.node_type === "topic" + ? `${topicPrefix}${node.label}` + : node.label; } export function MemoryGraph3D({