diff --git a/memhub/apps/web/src/MemoryMap.test.tsx b/memhub/apps/web/src/MemoryMap.test.tsx index cbdbb5f..8fd2449 100644 --- a/memhub/apps/web/src/MemoryMap.test.tsx +++ b/memhub/apps/web/src/MemoryMap.test.tsx @@ -13,7 +13,6 @@ import { fireEvent, render, screen, - waitFor, within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -259,6 +258,40 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆"); }); +it("shows a stable loading surface when the selected memory is read again", async () => { + const view = render(); + await act(async () => {}); + expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy(); + view.rerender(); + await userEvent.click( + within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole( + "button", + { name: /第一条/ }, + ), + ); + expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(); + expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆"); + expect(document.querySelector(".map-detail-loading")).toBeTruthy(); +}); + +it("shows retry instead of stale content when the selected memory reread fails", async () => { + const view = render(); + await act(async () => {}); + expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy(); + view.rerender(); + await userEvent.click( + within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole( + "button", + { name: /第一条/ }, + ), + ); + expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(); + expect(screen.getByRole("alert").textContent).toContain( + "这条记忆的原文暂时无法读取", + ); + expect(screen.getByRole("button", { name: "重试读取原文" })).toBeTruthy(); +}); + it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => { const { container } = render( , @@ -269,6 +302,14 @@ it("keeps the detail header outside the scrolling body and resets detail scroll const body = container.querySelector(".map-inspector-body"); expect(heading?.nextElementSibling).toBe(body); if (!body) throw new Error("Memory detail scroll body is missing"); + body.scrollTop = 90; + await userEvent.click( + within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole( + "button", + { name: /第一条/ }, + ), + ); + expect(body.scrollTop).toBe(0); body.scrollTop = 180; await userEvent.click( within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole( @@ -348,6 +389,7 @@ function Host({ processing = null, loaded = true, deferMemoryId, + failMemoryId, graphValue = graph, onViewHistory, onConfigureModel = () => {}, @@ -364,12 +406,17 @@ function Host({ processing?: MemoryProcessingSummary | null; loaded?: boolean; deferMemoryId?: string; + failMemoryId?: string; graphValue?: MemoryGraphResponse; onViewHistory?(): void; onConfigureModel?(): void; libraryView?: Parameters[0]["libraryView"]; } = {}) { const [detail, setDetail] = useState(null); + const [detailLoad, setDetailLoad] = useState<{ + memoryId: string; + status: "loading" | "failed"; + } | null>(null); return ( { + if (failMemoryId && id === failMemoryId) { + setDetail(null); + setDetailLoad({ memoryId: id, status: "failed" }); + return; + } // Lets a test leave one read unanswered. - if (deferMemoryId && id === deferMemoryId) return; + if (deferMemoryId && id === deferMemoryId) { + setDetail(null); + setDetailLoad({ memoryId: id, status: "loading" }); + return; + } + setDetailLoad(null); setDetail({ memory: items.find((m) => m.memory_id === id) ?? items[0], evidence: [ @@ -416,7 +474,10 @@ function Host({ index_sync: null, }); }} - onCloseDetail={() => setDetail(null)} + onCloseDetail={() => { + setDetail(null); + setDetailLoad(null); + }} onAsk={() => {}} onDraft={() => {}} onSave={() => {}} @@ -745,6 +806,8 @@ it("keeps pagination and current detail stable while the next page is loading", const libraryView = { ...pagedView(page), onPage }; const view = render(); const list = screen.getByRole("list", { name: "地图记忆列表" }); + const statusSlot = document.querySelector(".map-list-status"); + expect(statusSlot?.getAttribute("data-visible")).toBe("false"); list.scrollTop = 160; const pager = screen.getByRole("navigation", { name: "地图记忆分页" }); await userEvent.click( @@ -759,6 +822,8 @@ it("keeps pagination and current detail stable while the next page is loading", .getByRole("navigation", { name: "地图记忆分页" }) .getAttribute("aria-busy"), ).toBe("true"); + expect(document.querySelector(".map-list-status")).toBe(statusSlot); + expect(statusSlot?.getAttribute("data-visible")).toBe("true"); expect( ( screen.getByRole("button", { @@ -794,10 +859,131 @@ it("keeps pagination and current detail stable while the next page is loading", />, ); expect(list.scrollTop).toBe(0); + expect(document.querySelector(".map-list-status")).toBe(statusSlot); + expect(statusSlot?.getAttribute("data-visible")).toBe("false"); expect(within(list).getByText("下一页第 1 条")).toBeTruthy(); - await waitFor(() => - expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(), + expect( + await screen.findByRole("heading", { name: "下一页第 1 条" }), + ).toBeTruthy(); + expect( + within(list) + .getByRole("button", { name: /下一页第 1 条/ }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(); +}); + +it("keeps the page intent through a failed read and selects the first row after retry", async () => { + const onPage = vi.fn(); + const onRetry = vi.fn(); + const page = { + items: memories, + total: 202, + offset: 0, + limit: 100, + source_ids_by_memory: { m0: ["codex"], m1: ["codex"] }, + }; + const base = { ...pagedView(page), onPage, onRetry }; + const view = render(); + await act(async () => {}); + await userEvent.click(screen.getByRole("button", { name: "下一页记忆" })); + expect( + within(screen.getByRole("list", { name: "地图记忆列表" })) + .getByRole("button", { name: /第一条/ }) + .hasAttribute("disabled"), + ).toBe(true); + + view.rerender( + , ); + expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy(); + await userEvent.click( + screen.getByRole("button", { name: "重新读取地图记忆" }), + ); + expect(onRetry).toHaveBeenCalledOnce(); + expect( + within(screen.getByRole("list", { name: "地图记忆列表" })) + .getByRole("button", { name: /第一条/ }) + .hasAttribute("disabled"), + ).toBe(true); + expect( + ( + screen.getByRole("button", { + name: "搜索", + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + + const nextItems = memories.map((memory, index) => ({ + ...memory, + memory_id: `retry-${index}`, + content: `重试页第 ${index + 1} 条`, + })); + view.rerender( + , + ); + expect( + await screen.findByRole("heading", { name: "重试页第 1 条" }), + ).toBeTruthy(); +}); + +it("returns to the shown page when a failed page request is cancelled by a row click", async () => { + const onPage = vi.fn(); + const page = { + items: memories, + total: 202, + offset: 0, + limit: 100, + source_ids_by_memory: { m0: ["codex"], m1: ["codex"] }, + }; + const base = { ...pagedView(page), onPage }; + const view = render(); + await act(async () => {}); + await userEvent.click(screen.getByRole("button", { name: "下一页记忆" })); + view.rerender( + , + ); + await userEvent.click( + within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole( + "button", + { name: /第二条/ }, + ), + ); + expect(onPage.mock.calls).toEqual([[100], [0]]); + expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy(); }); it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => { @@ -877,8 +1063,10 @@ it("badges each listed memory with its scene and pages 20 at a time", () => { expect((next as HTMLButtonElement).disabled).toBe(false); fireEvent.click(next); expect(view.onPage).toHaveBeenCalledWith(40); - fireEvent.click(screen.getByRole("button", { name: "上一页记忆" })); - expect(view.onPage).toHaveBeenCalledWith(0); + const previous = screen.getByRole("button", { name: "上一页记忆" }); + expect((previous as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(previous); + expect(view.onPage).not.toHaveBeenCalledWith(0); }); it("says a filtered source is empty rather than that nothing matched", () => { diff --git a/memhub/apps/web/src/MemoryMap.tsx b/memhub/apps/web/src/MemoryMap.tsx index 65c8a87..44e46c5 100644 --- a/memhub/apps/web/src/MemoryMap.tsx +++ b/memhub/apps/web/src/MemoryMap.tsx @@ -101,6 +101,7 @@ type Props = { stale: boolean; settledSource?: string; settledQuery?: string; + requestedOffset?: number; /** The source id the SDK uses for "no source given" (shown as 未识别来源). */ unattributedSource?: string; onQuery(value: string): void; @@ -224,6 +225,15 @@ export function MemoryMap(props: Props) { const [pendingSourceSelection, setPendingSourceSelection] = useState< string | null >(null); + const [pendingPageOffset, setPendingPageOffset] = useState( + null, + ); + const [pageRetrying, setPageRetrying] = useState(false); + const pageRetrySawLoading = useRef(false); + const [detailResetRevision, setDetailResetRevision] = useState(0); + const pageTransitioning = + !!library?.loading || + (pendingPageOffset !== null && (!library?.failed || pageRetrying)); const [question, setQuestion] = useState(""); const [inspectorOpen, setInspectorOpen] = useState(false); const [filterNotice, setFilterNotice] = useState(""); @@ -426,6 +436,7 @@ export function MemoryMap(props: Props) { : selectedSource ? `source:${selectedSource.source_id}` : (visibleGroupNode?.node_id ?? "empty"); + const inspectorResetKey = `${inspectorIdentity}:${detailResetRevision}`; const visibleDetail = detail && (!activeId || activeId === detail.memory.memory_id) && @@ -437,10 +448,24 @@ export function MemoryMap(props: Props) { const inspectorDetail = visibleDetail ?? (activeId && lastDetail.current ? lastDetail.current : null); + const readingActiveDetail = + !!activeId && + props.detailLoad?.memoryId === activeId && + props.detailLoad.status === "loading"; + const failedActiveDetail = + !!activeId && + props.detailLoad?.memoryId === activeId && + props.detailLoad.status === "failed"; const pick = useCallback( (memoryId: string, scroll = true) => { if (!active.some((memory) => memory.memory_id === memoryId)) return; + if (pendingPageOffset !== null && library?.failed && library.page) + library.onPage(library.page.offset); setPendingSourceSelection(null); + setPendingPageOffset(null); + setPageRetrying(false); + pageRetrySawLoading.current = false; + setDetailResetRevision((current) => current + 1); scrollSelection.current = scroll; if (!filtered.some((memory) => memory.memory_id === memoryId)) { setGroupKey(""); @@ -452,7 +477,7 @@ export function MemoryMap(props: Props) { setInspectorOpen(true); onOpenMemory(memoryId); }, - [active, filtered, onOpenMemory], + [active, filtered, library, onOpenMemory, pendingPageOffset], ); useEffect(() => { if ( @@ -480,6 +505,24 @@ export function MemoryMap(props: Props) { pick, selectedSource, ]); + useLayoutEffect(() => { + if (pendingPageOffset === null || !library || library.loading) return; + if (library.failed || library.stale) return; + const expectedOffset = library.requestedOffset ?? pendingPageOffset; + if (library.page?.offset !== expectedOffset) return; + setPendingPageOffset(null); + setPageRetrying(false); + pageRetrySawLoading.current = false; + const first = active[0]; + if (first) { + pick(first.memory_id, false); + return; + } + setSelectedId(null); + setGroupNode(null); + setInspectorOpen(false); + onCloseDetail(); + }, [active, library, onCloseDetail, pendingPageOffset, pick]); useEffect(() => { if (!props.simplified || openedDefault.current || overlaysSuspended) return; if (library?.loading && filtered.length === 0) return; @@ -497,6 +540,17 @@ export function MemoryMap(props: Props) { detail, pick, ]); + useEffect(() => { + if (!pageRetrying || !library) return; + if (library.loading) { + pageRetrySawLoading.current = true; + return; + } + if (pageRetrySawLoading.current && library.failed) { + pageRetrySawLoading.current = false; + setPageRetrying(false); + } + }, [library, pageRetrying]); useEffect(() => { if (!activeId || !scrollSelection.current) return; scrollSelection.current = false; @@ -516,9 +570,9 @@ export function MemoryMap(props: Props) { useLayoutEffect(() => { // Detail content owns its own scrollport. A newly selected memory must // start at its heading instead of inheriting the previous inspector view. - if (inspectorIdentity === "empty") return; + if (inspectorResetKey.startsWith("empty:")) return; if (inspectorBody.current) inspectorBody.current.scrollTop = 0; - }, [inspectorIdentity]); + }, [inspectorResetKey]); const settledPageOffset = library?.page?.offset; useLayoutEffect(() => { // A settled page is a new reading surface, not a continuation of the @@ -531,12 +585,16 @@ export function MemoryMap(props: Props) { setSelectedId(null); setGroupNode(null); setPendingSourceSelection(null); + setPendingPageOffset(null); + setPageRetrying(false); + pageRetrySawLoading.current = false; setInspectorOpen(false); onCloseDetail(); }, [onCloseDetail]); useEffect(() => { // A background map does not own the shared inspector in another mode. - if (overlaysSuspended || library?.loading) return; + if (overlaysSuspended || library?.loading || pendingPageOffset !== null) + return; if ( requestedId && !active.some((memory) => memory.memory_id === requestedId) @@ -556,6 +614,7 @@ export function MemoryMap(props: Props) { clear, overlaysSuspended, library?.loading, + pendingPageOffset, ]); function closeInspector() { if (!compact) { @@ -565,6 +624,14 @@ export function MemoryMap(props: Props) { setPendingSourceSelection(null); setInspectorOpen(false); } + function retryLibrary() { + if (!library) return; + if (pendingPageOffset !== null) { + pageRetrySawLoading.current = false; + setPageRetrying(true); + } + library.onRetry(); + } function chooseGroup(key: string) { setGroupNode(null); if (library) { @@ -619,6 +686,17 @@ export function MemoryMap(props: Props) { ? text("未识别来源") : null) ?? text("全部记忆"); + const libraryStatus = !library + ? "" + : library.failed + ? text("读取失败") + : library.stale + ? text("正在核对来源…") + : pageTransitioning + ? refreshing && library.page?.total !== 0 + ? text("正在核对…") + : text("正在读取…") + : ""; if ( props.simplified && (!library || @@ -819,21 +897,20 @@ export function MemoryMap(props: Props) { ) : null} - {library && (library.loading || library.stale || library.failed) ? ( -

- {library.failed - ? text("读取失败") - : library.stale - ? text("正在核对来源…") - : refreshing && library.page?.total !== 0 - ? text("正在核对…") - : text("正在读取…")} + {library ? ( +

+ {libraryStatus || "\u00a0"}

) : null} {!props.simplified && ( @@ -903,6 +980,7 @@ export function MemoryMap(props: Props) { type="button" className="map-memory-item" aria-pressed={activeId === m.memory_id} + disabled={pageTransitioning} onClick={() => pick(m.memory_id)} >