Compare commits

...
Author SHA1 Message Date
kristinheandCursor 81ed1d816f fix(memhub): open source memories from map nodes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 12:00:26 +08:00
3 changed files with 304 additions and 109 deletions

No files matched your search

+224 -96
View File
@@ -5,7 +5,6 @@ import type {
MemoryGraphNode, MemoryGraphNode,
MemoryGraphResponse, MemoryGraphResponse,
MemoryProcessingSummary, MemoryProcessingSummary,
SourceImportReceipt as Receipt,
SourceResponse, SourceResponse,
} from "@one-memory/contracts"; } from "@one-memory/contracts";
import { import {
@@ -40,12 +39,6 @@ import { afterEach, expect, it, vi } from "vitest";
import { MemoryMap } from "./MemoryMap"; import { MemoryMap } from "./MemoryMap";
import type { MemoryArrival } from "./memory-arrival-state"; import type { MemoryArrival } from "./memory-arrival-state";
const { sourceReceipt } = vi.hoisted(() => ({
sourceReceipt: vi.fn(),
}));
vi.mock("./api", () => ({
getSourceImportReceipt: sourceReceipt,
}));
vi.mock("./MemoryGraph3D", () => ({ vi.mock("./MemoryGraph3D", () => ({
MemoryGraph3D: ({ MemoryGraph3D: ({
graph, graph,
@@ -982,35 +975,6 @@ const importedSources = [
imported_conversations: 1, imported_conversations: 1,
}, },
] as SourceResponse[]; ] as SourceResponse[];
const importedReceipt: Receipt = {
source_id: "codex",
measured_at: "2026-09-14T00:00:00Z",
messages: 5,
conversations: 2,
person_messages: 2,
agent_messages: 2,
tool_messages: 0,
unknown_author_messages: 1,
unknown_time_messages: 1,
examples: [
{
evidence_id: "raw-codex",
role: "user",
written_by: "person",
created_at: null,
excerpt: "合成来源原文,不是治理记忆。",
truncated: false,
},
],
};
async function openSourceReceipt() {
await userEvent.click(
screen.getByRole("button", { name: "图中选择 Codex · 2 段" }),
);
await userEvent.click(screen.getByText("查看已读内容"));
await waitFor(() => expect(sourceReceipt).toHaveBeenCalledWith("codex"));
}
it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => { it("REV09: selecting an imported source retains the canvas and only its authorized node", async () => {
render(<Host items={[]} sources={importedSources} />); render(<Host items={[]} sources={importedSources} />);
const canvas = screen.getByTestId("canvas"); const canvas = screen.getByTestId("canvas");
@@ -1025,7 +989,8 @@ it("REV09: selecting an imported source retains the canvas and only its authoriz
expect( expect(
screen.getByRole("list", { name: "地图记忆列表" }).children, screen.getByRole("list", { name: "地图记忆列表" }).children,
).toHaveLength(0); ).toHaveLength(0);
expect(sourceReceipt).not.toHaveBeenCalled(); expect(screen.getByRole("heading", { name: "记忆详情" })).toBeTruthy();
expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: /全部记忆/ })); await userEvent.click(screen.getByRole("button", { name: /全部记忆/ }));
expect(within(canvas).getAllByRole("button")).toHaveLength(2); expect(within(canvas).getAllByRole("button")).toHaveLength(2);
expect(canvas.dataset.selected ?? "").toBe(""); expect(canvas.dataset.selected ?? "").toBe("");
@@ -1047,83 +1012,247 @@ it("REV09: fallback search matches source names and IDs without bypassing the se
); );
await userEvent.type(search, "cOdEx"); await userEvent.type(search, "cOdEx");
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex"); expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
expect(screen.getByText("查看已读内容")).toBeTruthy(); expect(screen.getByText("还没有已生成的记忆")).toBeTruthy();
await userEvent.clear(search); await userEvent.clear(search);
await userEvent.type(search, "Cursor"); await userEvent.type(search, "Cursor");
expect(screen.queryByTestId("canvas")).toBeNull(); expect(screen.queryByTestId("canvas")).toBeNull();
expect(screen.queryByText("查看已读内容")).toBeNull(); expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
await userEvent.clear(search); await userEvent.clear(search);
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe(""); expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
expect(screen.queryByText("查看已读内容")).toBeNull(); expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
}); });
it("REV09: source selection uses the actual permission-checked receipt API", async () => { it("selecting a source node waits for its settled page, then opens the first memory", async () => {
sourceReceipt.mockResolvedValue(importedReceipt); const onQuery = vi.fn();
render(<Host items={[]} sources={importedSources} />); const onSource = vi.fn();
await openSourceReceipt(); const resetPage = vi.fn();
expect( const oldPage = {
await screen.findByText(importedReceipt.examples[0].excerpt), items: memories,
).toBeTruthy(); total: 2,
expect(screen.getByText("用户 · 时间未知")).toBeTruthy(); offset: 0,
expect(screen.getByText("原始消息").nextElementSibling?.textContent).toBe( limit: 20,
"5", source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(oldPage),
query: "旧查询",
settledQuery: "旧查询",
onQuery,
onSource,
resetPage,
}}
/>,
); );
// No raw conversation is read by id on the SDK data plane. await act(async () => {});
expect(screen.queryByRole("button", { name: "查看完整对话" })).toBeNull(); await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
expect(onQuery).toHaveBeenCalledWith("");
expect(onSource).toHaveBeenCalledWith("codex");
expect(resetPage).toHaveBeenCalledOnce();
view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(oldPage),
source: "codex",
settledSource: "",
loading: true,
onQuery,
onSource,
resetPage,
}}
/>,
);
expect(
screen
.getAllByRole("status")
.some((node) => node.textContent === "正在读取记忆详情…"),
).toBe(true);
const settledItems = [memories[1], memories[0]];
view.rerender(
<Host
simplified
items={settledItems}
sources={importedSources}
libraryView={{
...pagedView({
...oldPage,
items: settledItems,
source_ids_by_memory: { m1: ["codex"], m0: ["codex"] },
}),
source: "codex",
settledSource: "codex",
settledQuery: "",
onQuery,
onSource,
resetPage,
}}
/>,
);
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
expect(screen.queryByText("查看已读内容")).toBeNull();
expect(document.querySelector(".source-import-receipt")).toBeNull();
}); });
it.each(["revoked", "removed", "revoked-hidden"])( it("does not replace a newer memory choice when a source page settles", async () => {
"REV09: %s source drops fallback, preview and a late receipt without reopening on reauthorization", const page = {
async (state) => { items: memories,
let finish: (value: Receipt) => void = () => {}; total: 2,
sourceReceipt.mockImplementation( offset: 0,
() => limit: 20,
new Promise<Receipt>((resolve) => { source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
finish = resolve; };
}), const view = render(
); <Host simplified sources={importedSources} libraryView={pagedView(page)} />,
const view = render(<Host items={[]} sources={[importedSources[0]]} />); );
await openSourceReceipt(); await act(async () => {});
view.rerender( await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
<Host await userEvent.click(
items={[]} screen.getByRole("button", { name: "图中选择 第二条" }),
hidden={state === "revoked-hidden"} );
sources={ expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
state === "removed"
? []
: [{ ...importedSources[0], authorized: false }]
}
/>,
);
expect(screen.queryByTestId("canvas")).toBeNull();
expect(document.querySelector(".source-import-receipt")).toBeNull();
await act(async () => finish(importedReceipt));
view.rerender(<Host items={[]} sources={[importedSources[0]]} />);
expect(screen.getByTestId("canvas").dataset.selected ?? "").toBe("");
expect(screen.queryByText(importedReceipt.examples[0].excerpt)).toBeNull();
expect(screen.queryByText("查看已读内容")).toBeNull();
expect(sourceReceipt).toHaveBeenCalledTimes(1);
},
);
it("REV09: compact preview close keeps source selection for reopening", async () => { view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.getByRole("heading", { name: "第二条" })).toBeTruthy();
});
it("closing the compact source inspector cancels its pending auto-open", async () => {
vi.stubGlobal("matchMedia", (query: string) => ({ vi.stubGlobal("matchMedia", (query: string) => ({
matches: query.includes("max-width"), matches: query.includes("max-width"),
addEventListener() {}, addEventListener() {},
removeEventListener() {}, removeEventListener() {},
})); }));
sourceReceipt.mockResolvedValue(importedReceipt); const page = {
render(<Host items={[]} sources={importedSources} />); items: memories,
await openSourceReceipt(); total: 2,
await screen.findByText(importedReceipt.examples[0].excerpt); offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" })); await userEvent.click(screen.getByRole("button", { name: "关闭记忆详情" }));
expect(document.querySelector(".source-import-receipt")).toBeNull();
view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.queryByRole("dialog", { name: "记忆与原文" })).toBeNull();
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
});
it("keeps the source selection and offers retry when its page fails", async () => {
const onRetry = vi.fn();
const page = {
items: memories,
total: 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host simplified sources={importedSources} libraryView={pagedView(page)} />,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
view.rerender(
<Host
simplified
sources={importedSources}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
failed: true,
onRetry,
}}
/>,
);
expect(
screen
.getAllByRole("alert")
.some((node) => node.textContent === "记忆暂时无法读取"),
).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "重试" }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("drops a pending source selection when that source is revoked", async () => {
const page = {
items: memories,
total: 2,
offset: 0,
limit: 20,
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
};
const view = render(
<Host
simplified
sources={[importedSources[0]]}
libraryView={pagedView(page)}
/>,
);
await act(async () => {});
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex"); expect(screen.getByTestId("canvas").dataset.selected).toBe("source:codex");
await userEvent.click(
screen.getByRole("button", { name: "查看选中来源原文" }), view.rerender(
<Host
simplified
sources={[{ ...importedSources[0], authorized: false }]}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
"source:codex",
);
expect(screen.queryByText("正在读取记忆详情…")).toBeNull();
view.rerender(
<Host
simplified
sources={[importedSources[0]]}
libraryView={{
...pagedView(page),
source: "codex",
settledSource: "codex",
}}
/>,
);
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
"source:codex",
); );
expect(screen.getByRole("dialog", { name: "记忆与原文" })).toBeTruthy();
expect(screen.getByText("查看已读内容")).toBeTruthy();
}); });
it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => { it("REV09: first governed memories replace fallback without remounting the graph or breaking memory IDs", async () => {
@@ -1162,5 +1291,4 @@ it.each([
render(<Host items={[]} sources={sources} />); render(<Host items={[]} sources={sources} />);
expect(screen.queryByTestId("canvas")).toBeNull(); expect(screen.queryByTestId("canvas")).toBeNull();
expect(document.querySelector(".source-import-receipt")).toBeNull(); expect(document.querySelector(".source-import-receipt")).toBeNull();
expect(sourceReceipt).not.toHaveBeenCalled();
}); });
+78 -13
View File
@@ -48,7 +48,6 @@ import {
import { OptionMenu } from "./OptionMenu"; import { OptionMenu } from "./OptionMenu";
import { R6Button } from "./R6Controls"; import { R6Button } from "./R6Controls";
import { SelectedMemoryDetail } from "./SelectedMemoryDetail"; import { SelectedMemoryDetail } from "./SelectedMemoryDetail";
import { SourceImportReceipt } from "./SourceImportReceipt";
import { sourceNameOr } from "./source-label"; import { sourceNameOr } from "./source-label";
import { useMediaPreference } from "./use-media-preference"; import { useMediaPreference } from "./use-media-preference";
import { Answer, NodeDetail, type SavedAnswer } from "./Workbench"; import { Answer, NodeDetail, type SavedAnswer } from "./Workbench";
@@ -97,6 +96,8 @@ type Props = {
loading: boolean; loading: boolean;
failed: boolean; failed: boolean;
stale: boolean; stale: boolean;
settledSource?: string;
settledQuery?: string;
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */ /** The source id the SDK uses for "no source given" (shown as 未识别来源). */
unattributedSource?: string; unattributedSource?: string;
onQuery(value: string): void; onQuery(value: string): void;
@@ -213,6 +214,9 @@ export function MemoryMap(props: Props) {
}, [submittedQuery]); }, [submittedQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null); const [groupNode, setGroupNode] = useState<MemoryGraphNode | null>(null);
const [pendingSourceSelection, setPendingSourceSelection] = useState<
string | null
>(null);
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [inspectorOpen, setInspectorOpen] = useState(false); const [inspectorOpen, setInspectorOpen] = useState(false);
const [filterNotice, setFilterNotice] = useState(""); const [filterNotice, setFilterNotice] = useState("");
@@ -363,6 +367,7 @@ export function MemoryMap(props: Props) {
// Source revocation also clears a suspended preview, without closing shared memory detail. // Source revocation also clears a suspended preview, without closing shared memory detail.
if (groupNode && !visibleGroupNode) { if (groupNode && !visibleGroupNode) {
setGroupNode(null); setGroupNode(null);
setPendingSourceSelection(null);
setInspectorOpen(false); setInspectorOpen(false);
} }
}, [groupNode, visibleGroupNode]); }, [groupNode, visibleGroupNode]);
@@ -389,6 +394,7 @@ export function MemoryMap(props: Props) {
const pick = useCallback( const pick = useCallback(
(memoryId: string, scroll = true) => { (memoryId: string, scroll = true) => {
if (!active.some((memory) => memory.memory_id === memoryId)) return; if (!active.some((memory) => memory.memory_id === memoryId)) return;
setPendingSourceSelection(null);
scrollSelection.current = scroll; scrollSelection.current = scroll;
if (!filtered.some((memory) => memory.memory_id === memoryId)) { if (!filtered.some((memory) => memory.memory_id === memoryId)) {
setGroupKey(""); setGroupKey("");
@@ -402,6 +408,32 @@ export function MemoryMap(props: Props) {
}, },
[active, filtered, onOpenMemory], [active, filtered, onOpenMemory],
); );
useEffect(() => {
if (
!library ||
!pendingSourceSelection ||
overlaysSuspended ||
library.loading ||
library.failed ||
!selectedSource ||
selectedSource.source_id !== pendingSourceSelection ||
library.source !== pendingSourceSelection ||
(library.settledSource ?? library.source) !== pendingSourceSelection ||
(library.settledQuery ?? library.query).trim() ||
library.page?.offset !== 0
)
return;
setPendingSourceSelection(null);
const first = active[0];
if (first) pick(first.memory_id, false);
}, [
active,
library,
overlaysSuspended,
pendingSourceSelection,
pick,
selectedSource,
]);
useEffect(() => { useEffect(() => {
if (!props.simplified || openedDefault.current || overlaysSuspended) return; if (!props.simplified || openedDefault.current || overlaysSuspended) return;
if (library?.loading && filtered.length === 0) return; if (library?.loading && filtered.length === 0) return;
@@ -452,6 +484,7 @@ export function MemoryMap(props: Props) {
const clear = useCallback(() => { const clear = useCallback(() => {
setSelectedId(null); setSelectedId(null);
setGroupNode(null); setGroupNode(null);
setPendingSourceSelection(null);
setInspectorOpen(false); setInspectorOpen(false);
onCloseDetail(); onCloseDetail();
}, [onCloseDetail]); }, [onCloseDetail]);
@@ -483,6 +516,7 @@ export function MemoryMap(props: Props) {
clear(); clear();
return; return;
} }
setPendingSourceSelection(null);
setInspectorOpen(false); setInspectorOpen(false);
} }
function chooseGroup(key: string) { function chooseGroup(key: string) {
@@ -509,8 +543,17 @@ export function MemoryMap(props: Props) {
setAxis("source"); setAxis("source");
setGroupKey(`source:${node.source_id}`); setGroupKey(`source:${node.source_id}`);
if (library) { if (library) {
setPendingSourceSelection(node.source_id);
library.onQuery("");
library.onSource(node.source_id); library.onSource(node.source_id);
library.resetPage(); library.resetPage();
} else {
const first = sourceGroups
.find((entry) => entry.key === `source:${node.source_id}`)
?.memoryIds.find((memoryId) =>
active.some((memory) => memory.memory_id === memoryId),
);
if (first) pick(first, false);
} }
} }
if (node.node_type === "topic") { if (node.node_type === "topic") {
@@ -987,11 +1030,13 @@ export function MemoryMap(props: Props) {
<button <button
type="button" type="button"
className="quiet map-tool" className="quiet map-tool"
aria-label={text( aria-label={text("记忆详情")}
selectedSource ? "查看选中来源原文" : "查看选中记忆原文",
)}
title={text("查看原文")} title={text("查看原文")}
onClick={() => setInspectorOpen(true)} onClick={() => {
setInspectorOpen(true);
if (selectedSource && library)
setPendingSourceSelection(selectedSource.source_id);
}}
> >
<FileText size={17} /> <FileText size={17} />
</button> </button>
@@ -1126,7 +1171,7 @@ export function MemoryMap(props: Props) {
{inspectorDetail {inspectorDetail
? text("记忆详情") ? text("记忆详情")
: selectedSource : selectedSource
? text("来源原文") ? text("记忆详情")
: visibleGroupNode : visibleGroupNode
? text("关联节点") ? text("关联节点")
: text("记忆详情")} : text("记忆详情")}
@@ -1145,13 +1190,33 @@ export function MemoryMap(props: Props) {
</header> </header>
<div className="map-inspector-body" ref={inspectorBody}> <div className="map-inspector-body" ref={inspectorBody}>
{selectedSource ? ( {selectedSource ? (
inspectorOpen && <div className="map-inspector-empty map-inspector-loading">
!overlaysSuspended && ( {library?.failed &&
<SourceImportReceipt library.source === selectedSource.source_id ? (
key={selectedSource.source_id} <>
source={selectedSource} <p role="alert">{text("记忆暂时无法读取")}</p>
/> <button
) type="button"
className="secondary"
onClick={library.onRetry}
>
{text("重试")}
</button>
</>
) : pendingSourceSelection || library?.loading ? (
<>
<div className="map-detail-skeleton" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
<p role="status">{text("正在读取记忆详情…")}</p>
</>
) : (
<p>{text("还没有已生成的记忆")}</p>
)}
</div>
) : props.simplified && inspectorDetail ? ( ) : props.simplified && inspectorDetail ? (
<div <div
aria-busy={ aria-busy={
+2
View File
@@ -1027,6 +1027,8 @@ export function MemorySpace({
loading: sdkLibrary.loading, loading: sdkLibrary.loading,
failed: sdkLibrary.failed, failed: sdkLibrary.failed,
stale: false, stale: false,
settledSource: sdkLibrary.settledSource,
settledQuery: sdkLibrary.settledQuery,
unattributedSource: UNATTRIBUTED_SOURCE_ID, unattributedSource: UNATTRIBUTED_SOURCE_ID,
onQuery: setSdkLibraryQuery, onQuery: setSdkLibraryQuery,
onSource: setSdkLibrarySource, onSource: setSdkLibrarySource,