Merge pull request #222 from Tencent-Hunyuan/fix/memory-detail-structure-and-stability
fix(memhub): stabilize memory detail navigation
This commit is contained in:
7 files changed
+434
-39
No files matched your search
@@ -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(<Host simplified />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
view.rerender(<Host simplified deferMemoryId="m0" />);
|
||||
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(<Host simplified />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
view.rerender(<Host simplified failMemoryId="m0" />);
|
||||
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(
|
||||
<Host simplified sources={[importedSources[0]]} />,
|
||||
@@ -269,6 +302,14 @@ it("keeps the detail header outside the scrolling body and resets detail scroll
|
||||
const body = container.querySelector<HTMLElement>(".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<typeof MemoryMap>[0]["libraryView"];
|
||||
} = {}) {
|
||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
} | null>(null);
|
||||
return (
|
||||
<MemoryMap
|
||||
simplified={simplified}
|
||||
@@ -391,14 +438,25 @@ function Host({
|
||||
}
|
||||
: null
|
||||
}
|
||||
detailLoad={detailLoad}
|
||||
draft={detail?.memory.content ?? ""}
|
||||
busy={false}
|
||||
answer={null}
|
||||
streaming={null}
|
||||
requestIds={{ current: new Map() }}
|
||||
onOpenMemory={(id) => {
|
||||
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(<Host simplified libraryView={libraryView} />);
|
||||
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(<Host simplified libraryView={base} />);
|
||||
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(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...base,
|
||||
loading: false,
|
||||
failed: true,
|
||||
requestedOffset: 100,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<Host
|
||||
simplified
|
||||
items={nextItems}
|
||||
libraryView={{
|
||||
...base,
|
||||
requestedOffset: 100,
|
||||
page: {
|
||||
...page,
|
||||
items: nextItems,
|
||||
offset: 100,
|
||||
source_ids_by_memory: {
|
||||
"retry-0": ["codex"],
|
||||
"retry-1": ["codex"],
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
failed: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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(<Host simplified libraryView={base} />);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...base,
|
||||
failed: true,
|
||||
requestedOffset: 100,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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", () => {
|
||||
|
||||
@@ -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<number | null>(
|
||||
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) {
|
||||
<button
|
||||
type="submit"
|
||||
className="map-search-submit"
|
||||
disabled={library.loading || hidden}
|
||||
disabled={pageTransitioning || hidden}
|
||||
>
|
||||
{text("搜索")}
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
{library && (library.loading || library.stale || library.failed) ? (
|
||||
<p className="map-list-status" aria-live="polite">
|
||||
{library.failed
|
||||
? text("读取失败")
|
||||
: library.stale
|
||||
? text("正在核对来源…")
|
||||
: refreshing && library.page?.total !== 0
|
||||
? text("正在核对…")
|
||||
: text("正在读取…")}
|
||||
{library ? (
|
||||
<p
|
||||
className="map-list-status"
|
||||
aria-live="polite"
|
||||
aria-hidden={!libraryStatus || undefined}
|
||||
data-visible={libraryStatus ? "true" : "false"}
|
||||
>
|
||||
{libraryStatus || "\u00a0"}
|
||||
</p>
|
||||
) : 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)}
|
||||
>
|
||||
<span className={`map-state ${m.status}`} aria-hidden="true" />
|
||||
@@ -956,7 +1034,7 @@ export function MemoryMap(props: Props) {
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
<p>{text("记忆读取失败,请重试。")}</p>
|
||||
<R6Button onClick={library.onRetry}>
|
||||
<R6Button onClick={retryLibrary}>
|
||||
{text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
</div>
|
||||
@@ -991,7 +1069,7 @@ export function MemoryMap(props: Props) {
|
||||
!library.stale && (
|
||||
<nav
|
||||
aria-label={text("地图记忆分页")}
|
||||
aria-busy={library.loading || undefined}
|
||||
aria-busy={pageTransitioning || undefined}
|
||||
className="map-pagination"
|
||||
>
|
||||
<button
|
||||
@@ -999,11 +1077,16 @@ export function MemoryMap(props: Props) {
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={library.loading || library.page.offset === 0}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset - library.page.limit);
|
||||
if (library.page) {
|
||||
const offset = library.page.offset - library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
@@ -1029,13 +1112,18 @@ export function MemoryMap(props: Props) {
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
library.loading ||
|
||||
pageTransitioning ||
|
||||
library.page.offset + library.page.limit >= library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset + library.page.limit);
|
||||
if (library.page) {
|
||||
const offset = library.page.offset + library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
@@ -1220,7 +1308,7 @@ export function MemoryMap(props: Props) {
|
||||
<MemoryEvidencePanel
|
||||
open={inspectorOpen && !!(activeId || visibleGroupNode)}
|
||||
suspended={overlaysSuspended}
|
||||
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
|
||||
scrollResetKey={inspectorResetKey}
|
||||
onClose={closeInspector}
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
@@ -1255,7 +1343,7 @@ export function MemoryMap(props: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={library.onRetry}
|
||||
onClick={retryLibrary}
|
||||
>
|
||||
{text("重试")}
|
||||
</button>
|
||||
@@ -1276,8 +1364,17 @@ export function MemoryMap(props: Props) {
|
||||
</div>
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
<div
|
||||
key={`${inspectorDetail.memory.memory_id}:${detailResetRevision}:${
|
||||
readingActiveDetail
|
||||
? "loading"
|
||||
: failedActiveDetail
|
||||
? "failed"
|
||||
: "ready"
|
||||
}`}
|
||||
className="map-detail-surface"
|
||||
aria-busy={
|
||||
refreshing ||
|
||||
readingActiveDetail ||
|
||||
inspectorDetail.memory.memory_id !== activeId ||
|
||||
undefined
|
||||
}
|
||||
@@ -1302,7 +1399,21 @@ export function MemoryMap(props: Props) {
|
||||
until the new one arrived, which read as "nothing happened"
|
||||
for two or three seconds. A read is not a value: show that a
|
||||
read is happening instead. */}
|
||||
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
|
||||
{failedActiveDetail ? (
|
||||
<div className="map-detail-loading">
|
||||
<p role="alert">
|
||||
{text("这条记忆的原文暂时无法读取,选择已保留。")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => activeId && onOpenMemory(activeId)}
|
||||
>
|
||||
{text("重试读取原文")}
|
||||
</button>
|
||||
</div>
|
||||
) : readingActiveDetail ||
|
||||
(activeId && inspectorDetail.memory.memory_id !== activeId) ? (
|
||||
<div className="map-detail-loading">
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
|
||||
@@ -1049,6 +1049,7 @@ export function MemorySpace({
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
requestedOffset: sdkLibraryOffset,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
|
||||
@@ -136,6 +136,70 @@ it("splits long memory text into a bounded heading and readable body", () => {
|
||||
"selected-memory-body",
|
||||
);
|
||||
});
|
||||
it("renders an SDK experience from its structured task and solution", () => {
|
||||
const task =
|
||||
"review an uncommitted diff for privacy and correctness blockers";
|
||||
const solution = `Reusable checklist:\n1. Run \`git diff --check\`.\n2. Inspect ${"/very-long-path".repeat(20)}.`;
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: {
|
||||
...detail.memory,
|
||||
content: `${task}\n${solution}`,
|
||||
revision: 0,
|
||||
},
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
sdk_ref: {
|
||||
scene: "coding",
|
||||
kind: "experience",
|
||||
variant: "coding",
|
||||
},
|
||||
sdk_task: task,
|
||||
sdk_solution: solution,
|
||||
}}
|
||||
sources={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: task })).toBeTruthy();
|
||||
const body = document.querySelector(".selected-memory-body");
|
||||
expect(body?.textContent).toBe(solution);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("heading", { name: task })
|
||||
.closest("[data-memory-kind]")
|
||||
?.getAttribute("data-memory-kind"),
|
||||
).toBe("experience");
|
||||
});
|
||||
it("does not apply experience fields to a fact memory", () => {
|
||||
const content = `${"事实".repeat(40)}。补充说明`;
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, content, revision: 0 },
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
sdk_ref: {
|
||||
scene: "working",
|
||||
kind: "fact",
|
||||
variant: "working",
|
||||
},
|
||||
sdk_task: "不应显示的任务标题",
|
||||
sdk_solution: "不应显示的经验正文",
|
||||
}}
|
||||
sources={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("不应显示的任务标题")).toBeNull();
|
||||
expect(screen.queryByText("不应显示的经验正文")).toBeNull();
|
||||
expect(
|
||||
document
|
||||
.querySelector(".selected-memory-detail")
|
||||
?.getAttribute("data-memory-kind"),
|
||||
).toBe("memory");
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
"a late sentence end",
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SelectedMemoryDetail({
|
||||
// (CJK text has none, so it breaks at the limit itself).
|
||||
const punctuation = memory.content.search(/[。!?!?\n]/);
|
||||
const space = memory.content.lastIndexOf(" ", HEADING_LIMIT);
|
||||
const split =
|
||||
const fallbackSplit =
|
||||
punctuation > 0 && punctuation < HEADING_LIMIT
|
||||
? punctuation + 1
|
||||
: memory.content.length > HEADING_LIMIT
|
||||
@@ -73,11 +73,26 @@ export function SelectedMemoryDetail({
|
||||
? space
|
||||
: HEADING_LIMIT
|
||||
: -1;
|
||||
const heading =
|
||||
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
|
||||
const body = split > 0 ? memory.content.slice(split).trim() : "";
|
||||
const fallbackHeading =
|
||||
fallbackSplit > 0
|
||||
? memory.content.slice(0, fallbackSplit).trim()
|
||||
: memory.content;
|
||||
const fallbackBody =
|
||||
fallbackSplit > 0 ? memory.content.slice(fallbackSplit).trim() : "";
|
||||
const experience =
|
||||
detail.sdk_ref?.kind === "experience" &&
|
||||
Boolean(detail.sdk_task?.trim() || detail.sdk_solution?.trim());
|
||||
const heading = experience
|
||||
? detail.sdk_task?.trim() || fallbackHeading
|
||||
: fallbackHeading;
|
||||
const body = experience
|
||||
? detail.sdk_solution?.trim() || (detail.sdk_task ? "" : fallbackBody)
|
||||
: fallbackBody;
|
||||
return (
|
||||
<article className="selected-memory-detail">
|
||||
<article
|
||||
className="selected-memory-detail"
|
||||
data-memory-kind={experience ? "experience" : "memory"}
|
||||
>
|
||||
<p className="selected-memory-status">
|
||||
{statusLabel}
|
||||
{!detail.sdk_status &&
|
||||
|
||||
@@ -788,12 +788,16 @@ button.rail-button.active::before {
|
||||
}
|
||||
.map-list-status {
|
||||
flex: none;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
color: var(--tx3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.map-list-status[data-visible="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
.map-filter-notice {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
@@ -879,6 +883,10 @@ button.rail-button.active::before {
|
||||
background: #fdfefe;
|
||||
border-color: #c7d3dc;
|
||||
}
|
||||
.map-memory-item:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
.map-state {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@@ -1142,6 +1150,7 @@ button.map-tool {
|
||||
}
|
||||
.map-detail-loading {
|
||||
display: grid;
|
||||
min-height: 280px;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -1152,8 +1161,13 @@ button.map-tool {
|
||||
}
|
||||
@keyframes map-detail-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.map-detail-surface,
|
||||
.map-detail-loading {
|
||||
animation: map-detail-enter 140ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
.map-inspector .wb-detail {
|
||||
|
||||
@@ -263,12 +263,14 @@
|
||||
display: block;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 17px;
|
||||
line-height: 29px;
|
||||
letter-spacing: normal;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.selected-memory-source {
|
||||
margin: 32px 0 22px;
|
||||
|
||||
Reference in new issue
Block a user