feat(ui): add Memories search tab to Memory settings (#115419)

* feat(ui): add Memories search tab to Memory settings

* test(ui): pin a canonical lobster look in memory overview tests

lobsterPetSeed mixes a random per-load salt into the seed, so the hero's
palette—and palette-dependent sprite geometry like the sleeping eye
peek—varied per test process and failed ~1 in 3 runs.

* test(ui): satisfy memory overview palette typing
This commit is contained in:
Peter Steinberger
2026-07-28 18:09:16 -04:00
committed by GitHub
parent 74f3af9735
commit d74e2214d3
11 changed files with 836 additions and 7 deletions

View File

@@ -2191,6 +2191,7 @@ export const en: TranslationMap = {
tablistLabel: "Memory sections",
tabs: {
overview: "Overview",
memories: "Memories",
dreams: "Dreams",
settings: "Settings",
},
@@ -2237,10 +2238,32 @@ export const en: TranslationMap = {
},
shortcuts: {
title: "Explore memory",
memories: "Search memories",
diary: "Read the dream diary",
settings: "Configure memory",
},
},
memories: {
searchLabel: "Search memories",
searchPlaceholder: "Search this agent's memories",
searchButton: "Search",
idle: "Search for a person, project, decision, or anything else this agent remembers.",
searching: "Searching memories…",
results: "{count} results",
empty: "No memories matched “{query}”.",
error: "Memory search failed: {message}",
retry: "Retry",
gatewayUpdateRequired: "Update the gateway to search memories from the Control UI.",
hybridSearch: "hybrid search",
keywordSearch: "keyword search",
lineRange: "lines {start}{end}",
score: "score {score}",
sourceMemory: "memory",
sourceSessions: "sessions",
fileLoading: "Loading the full memory file…",
fileError: "Could not load this memory file: {message}",
fileUnsupported: "This memory file cannot be shown as text.",
},
engine: {
title: "Engine",
description:

View File

@@ -0,0 +1,305 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { AgentSelectOption } from "../../components/agent-select.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import "./memory-memories.ts";
type Request = (method: string, params: Record<string, unknown>) => Promise<unknown>;
type MemoryMemoriesTestElement = HTMLElement & {
client: GatewayBrowserClient | null;
connected: boolean;
methodAdvertised: boolean;
agentId: string | null;
agents: readonly AgentSelectOption[];
updateComplete: Promise<unknown>;
};
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function createElement(request: Request, advertised = true) {
const element = document.createElement("openclaw-memory-memories") as MemoryMemoriesTestElement;
element.client = { request } as unknown as GatewayBrowserClient;
element.connected = true;
element.methodAdvertised = advertised;
element.agentId = "main";
element.agents = [];
document.body.append(element);
return element;
}
async function typeQuery(element: MemoryMemoriesTestElement, query: string) {
await element.updateComplete;
const input = element.querySelector<HTMLInputElement>("#memory-search-input");
if (!input) {
throw new Error("missing memory search input");
}
input.value = query;
input.dispatchEvent(new InputEvent("input", { bubbles: true }));
await element.updateComplete;
}
function submit(element: MemoryMemoriesTestElement) {
element
.querySelector("form")
?.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
}
const result = {
path: "memory/people/ada.md",
startLine: 2,
endLine: 3,
score: 0.876,
snippet: "Ada prefers careful reviews.",
source: "memory" as const,
};
describe("MemoryMemoriesElement", () => {
it("renders idle and gateway-update-required states", async () => {
const current = createElement(vi.fn(() => Promise.resolve({})));
await current.updateComplete;
expect(current.textContent).toContain("Search for a person, project, decision");
expect(current.querySelector("form")).not.toBeNull();
current.remove();
const old = createElement(
vi.fn(() => Promise.resolve({})),
false,
);
await old.updateComplete;
expect(old.textContent).toContain("Update the gateway to search memories");
expect(old.querySelector("form")).toBeNull();
old.remove();
});
it("keeps search disabled when no agent is available", async () => {
const request = vi.fn(() => Promise.resolve({}));
const element = createElement(request);
try {
element.agentId = null;
await typeQuery(element, "Ada");
expect(element.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled).toBe(
true,
);
submit(element);
expect(request).not.toHaveBeenCalled();
} finally {
element.remove();
}
});
it("searches only on submit and renders loading, ready, mode, and result metadata", async () => {
const pending = deferred<unknown>();
const request = vi.fn(() => pending.promise);
const element = createElement(request);
try {
await typeQuery(element, "Ada");
expect(request).not.toHaveBeenCalled();
submit(element);
await waitForFast(() => expect(element.textContent).toContain("Searching memories"));
expect(request).toHaveBeenCalledWith("memory.search", { query: "Ada", agentId: "main" });
pending.resolve({
agentId: "main",
provider: "local",
searchMode: "hybrid",
results: [result],
});
await waitForFast(() => expect(element.textContent).toContain(result.snippet));
expect(element.textContent).toContain("hybrid search");
expect(element.textContent?.replace(/\s+/g, " ")).toContain(
"memory/people/ada.md · lines 23",
);
expect(element.textContent).toContain("score 0.88");
expect(element.textContent).toContain("memory");
} finally {
element.remove();
}
});
it("renders empty and retryable error states", async () => {
const request = vi
.fn<Request>()
.mockRejectedValueOnce(new Error("index unavailable"))
.mockResolvedValueOnce({
agentId: "main",
provider: "none",
searchMode: "fts-only",
results: [],
});
const element = createElement(request);
try {
await typeQuery(element, "missing");
submit(element);
await waitForFast(() => expect(element.textContent).toContain("index unavailable"));
const retry = [...element.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Retry",
);
retry?.click();
await waitForFast(() => expect(element.textContent).toContain("No memories matched"));
expect(element.textContent).toContain("keyword search");
expect(request).toHaveBeenCalledTimes(2);
} finally {
element.remove();
}
});
it("loads a row file once, highlights the matched range, and keeps one row open", async () => {
const second = { ...result, path: "memory/projects/Open Claw.md", startLine: 1, endLine: 1 };
const request = vi.fn((method: string) => {
if (method === "memory.search") {
return Promise.resolve({
agentId: "main",
provider: "local",
searchMode: "hybrid",
results: [result, second],
});
}
return Promise.resolve({
agentId: "main",
file: {
path: result.path,
name: "ada.md",
size: 30,
updatedAtMs: 1,
mimeType: "text/plain",
encoding: "utf8",
content: "first\nmatched two\nmatched three\nfourth",
},
});
});
const element = createElement(request);
try {
await typeQuery(element, "Ada");
submit(element);
await waitForFast(() => expect(element.querySelectorAll("article")).toHaveLength(2));
const rows = element.querySelectorAll<HTMLButtonElement>("article > button");
rows[0]?.click();
await waitForFast(() =>
expect(element.querySelector('[data-memory-match="true"]')).toBeTruthy(),
);
expect(element.querySelector('[data-memory-match="true"]')?.textContent).toBe(
"matched two\nmatched three",
);
rows[0]?.click();
rows[0]?.click();
await element.updateComplete;
expect(
request.mock.calls.filter(([method]) => method === "agents.workspace.get"),
).toHaveLength(1);
expect(request).toHaveBeenCalledWith("agents.workspace.get", {
agentId: "main",
path: result.path,
});
rows[1]?.click();
await element.updateComplete;
expect(rows[0]?.getAttribute("aria-expanded")).toBe("false");
expect(rows[1]?.getAttribute("aria-expanded")).toBe("true");
expect(rows[1]?.getAttribute("aria-controls")).toBe("memory-detail-1");
expect(element.querySelector("#memory-detail-1")).not.toBeNull();
} finally {
element.remove();
}
});
it("keeps session, QMD, absolute, and escaping paths non-expandable", async () => {
const nonExpandable = [
{ ...result, path: "sessions/main/session-1.jsonl", source: "sessions" as const },
{ ...result, path: "sessions/main/mislabeled.jsonl" },
{ ...result, path: "qmd/workspace-main/memory/notes.md" },
{ ...result, path: "/external/MEMORY.md" },
{ ...result, path: "C:\\external\\MEMORY.md" },
{ ...result, path: "memory/../outside.md" },
];
const request = vi.fn<Request>(() =>
Promise.resolve({
agentId: "main",
provider: "local",
searchMode: "hybrid",
results: [{ ...result, path: "MEMORY.md" }, ...nonExpandable],
}),
);
const element = createElement(request);
try {
await typeQuery(element, "memory");
submit(element);
await waitForFast(() => expect(element.querySelectorAll("article")).toHaveLength(7));
expect(element.querySelectorAll("article > button")).toHaveLength(1);
expect(element.querySelectorAll("article > div.settings-row")).toHaveLength(6);
expect(
request.mock.calls.filter(([method]) => method === "agents.workspace.get"),
).toHaveLength(0);
} finally {
element.remove();
}
});
it("keeps workspace read failures on the expanded row", async () => {
const request = vi.fn((method: string) =>
method === "memory.search"
? Promise.resolve({
agentId: "main",
provider: "local",
searchMode: "hybrid",
results: [result],
})
: Promise.reject(new Error("workspace file not found")),
);
const element = createElement(request);
try {
await typeQuery(element, "Ada");
submit(element);
await waitForFast(() => expect(element.querySelector("article > button")).not.toBeNull());
element.querySelector<HTMLButtonElement>("article > button")?.click();
await waitForFast(() =>
expect(element.textContent).toContain(
"Could not load this memory file: workspace file not found",
),
);
expect(element.textContent).toContain(result.snippet);
expect(element.textContent).not.toContain("Memory search failed");
} finally {
element.remove();
}
});
it("resets results when the selected agent changes", async () => {
const request = vi.fn(() =>
Promise.resolve({
agentId: "main",
provider: "local",
searchMode: "hybrid",
results: [result],
}),
);
const element = createElement(request);
try {
await typeQuery(element, "Ada");
submit(element);
await waitForFast(() => expect(element.textContent).toContain(result.snippet));
element.agentId = "research";
await waitForFast(() => expect(element.textContent).toContain("Search for a person"));
expect(element.textContent).not.toContain(result.snippet);
expect(element.querySelector<HTMLInputElement>("#memory-search-input")?.value).toBe("");
} finally {
element.remove();
}
});
});

View File

@@ -0,0 +1,328 @@
import { html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { AgentsWorkspaceGetResult } from "../../../../packages/gateway-protocol/src/index.js";
import type { MemorySearchResponse } from "../../../../src/gateway/server-methods/memory-search.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { AgentSelectOption } from "../../components/agent-select.ts";
import { t } from "../../i18n/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import "../../styles/memory-memories.css";
import { renderMemoryAgentScope } from "./memory.ts";
type SearchResult = MemorySearchResponse["results"][number];
type SearchState =
| { kind: "idle" }
| { kind: "loading"; query: string }
| ({ kind: "ready"; query: string } & MemorySearchResponse)
| { kind: "error"; query: string; message: string };
type DetailState =
| { kind: "loading" }
| { kind: "ready"; content: string }
| { kind: "error"; message: string };
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function resultKey(result: SearchResult, index: number): string {
return `${index}:${result.path}:${result.startLine}:${result.endLine}`;
}
function isExpandableWorkspaceResult(result: SearchResult): boolean {
const normalizedPath = result.path.replaceAll("\\", "/");
const safeRelativePath =
!normalizedPath.startsWith("/") &&
!normalizedPath.startsWith("sessions/") &&
!/^[a-zA-Z]:\//.test(normalizedPath) &&
normalizedPath.split("/").every((segment) => segment && segment !== "." && segment !== "..");
const workspaceMemoryPath =
normalizedPath === "MEMORY.md" || normalizedPath.startsWith("memory/");
// workspace.get is workspace-contained; sessions/* and qmd/* are logical manager paths.
return result.source === "memory" && safeRelativePath && workspaceMemoryPath;
}
function renderFileContent(content: string, result: SearchResult) {
const lines = content.split(/\r?\n/);
const start = Math.max(0, result.startLine - 1);
const end = Math.min(lines.length, result.endLine);
const before = lines.slice(0, start);
const matched = lines.slice(start, end);
const after = lines.slice(end);
return html`<pre class="memory-memories__file" tabindex="0"><span
>${before.join("\n")}${before.length ? "\n" : ""}</span
><mark data-memory-match="true">${matched.join("\n")}</mark
><span>${after.length ? `\n${after.join("\n")}` : ""}</span></pre>`;
}
class MemoryMemoriesElement extends OpenClawLightDomElement {
@property({ attribute: false }) client: GatewayBrowserClient | null = null;
@property({ type: Boolean }) connected = false;
@property({ type: Boolean }) methodAdvertised = true;
@property() agentId: string | null = null;
@property({ attribute: false }) agents: readonly AgentSelectOption[] = [];
@property({ attribute: false }) onAgentChange: (agentId: string | null) => void = () => {};
@state() private query = "";
@state() private searchState: SearchState = { kind: "idle" };
@state() private openResultKey: string | null = null;
@state() private details = new Map<string, DetailState>();
private searchRequest: object | null = null;
private detailRequests = new Map<string, object>();
protected override updated(changed: PropertyValues<this>) {
if (
changed.has("agentId") ||
changed.has("client") ||
changed.has("connected") ||
changed.has("methodAdvertised")
) {
this.resetSearch();
}
}
private resetSearch() {
this.searchRequest = null;
this.detailRequests.clear();
this.query = "";
this.searchState = { kind: "idle" };
this.openResultKey = null;
this.details = new Map();
}
private async search(query: string) {
const normalizedQuery = query.trim();
const client = this.connected ? this.client : null;
const agentId = this.agentId;
if (!normalizedQuery || !client || !agentId || !this.methodAdvertised) {
return;
}
const request = { client, agentId, query: normalizedQuery };
this.searchRequest = request;
this.query = normalizedQuery;
this.searchState = { kind: "loading", query: normalizedQuery };
this.openResultKey = null;
this.details = new Map();
this.detailRequests.clear();
try {
const result = await client.request<MemorySearchResponse>("memory.search", {
query: normalizedQuery,
agentId,
});
if (this.searchRequest !== request || this.agentId !== agentId || this.client !== client) {
return;
}
this.searchState = { kind: "ready", query: normalizedQuery, ...result };
} catch (error) {
if (this.searchRequest !== request || this.agentId !== agentId || this.client !== client) {
return;
}
this.searchState = { kind: "error", query: normalizedQuery, message: errorMessage(error) };
}
}
private toggleResult(result: SearchResult, index: number) {
const key = resultKey(result, index);
if (this.openResultKey === key) {
this.openResultKey = null;
return;
}
this.openResultKey = key;
if (!this.details.has(key)) {
void this.loadDetail(key, result);
}
}
private async loadDetail(key: string, result: SearchResult) {
const client = this.connected ? this.client : null;
const agentId = this.agentId;
if (!client || !agentId) {
return;
}
const request = { client, agentId, path: result.path };
this.detailRequests.set(key, request);
this.details = new Map(this.details).set(key, { kind: "loading" });
try {
const response = await client.request<AgentsWorkspaceGetResult>("agents.workspace.get", {
agentId,
path: result.path,
});
if (this.detailRequests.get(key) !== request || this.agentId !== agentId) {
return;
}
const detail: DetailState =
response.file.encoding === "utf8"
? { kind: "ready", content: response.file.content }
: { kind: "error", message: t("memoryPage.memories.fileUnsupported") };
this.details = new Map(this.details).set(key, detail);
} catch (error) {
if (this.detailRequests.get(key) !== request || this.agentId !== agentId) {
return;
}
this.details = new Map(this.details).set(key, {
kind: "error",
message: errorMessage(error),
});
} finally {
if (this.detailRequests.get(key) === request) {
this.detailRequests.delete(key);
}
}
}
private renderDetail(key: string, panelId: string, result: SearchResult) {
if (this.openResultKey !== key) {
return nothing;
}
const detail = this.details.get(key);
return html`<div id=${panelId} class="memory-memories__detail">
${!detail || detail.kind === "loading"
? html`<p role="status">${t("memoryPage.memories.fileLoading")}</p>`
: detail.kind === "error"
? html`<p class="memory-memories__detail-error" role="alert">
${t("memoryPage.memories.fileError", { message: detail.message })}
</p>`
: renderFileContent(detail.content, result)}
</div>`;
}
private renderResults(ready: Extract<SearchState, { kind: "ready" }>) {
const mode =
ready.searchMode === "hybrid"
? t("memoryPage.memories.hybridSearch")
: t("memoryPage.memories.keywordSearch");
return html`
<div class="memory-memories__results-heading">
<span>${t("memoryPage.memories.results", { count: String(ready.results.length) })}</span>
<span class="memory-memories__mode">${mode}</span>
</div>
${ready.results.length === 0
? html`<p class="memory-memories__state">
${t("memoryPage.memories.empty", {
query: ready.query,
})}
</p>`
: html`<div class="settings-group memory-memories__results">
${ready.results.map((result, index) => {
const key = resultKey(result, index);
const open = this.openResultKey === key;
const expandable = isExpandableWorkspaceResult(result);
const panelId = `memory-detail-${index}`;
const summary = html`
<span class="settings-row__text">
<span class="settings-row__title">${result.snippet}</span>
<span class="settings-row__desc memory-memories__path"
>${result.path} ·
${t("memoryPage.memories.lineRange", {
start: String(result.startLine),
end: String(result.endLine),
})}</span
>
</span>
<span class="settings-row__control memory-memories__meta">
<span class="memory-memories__source"
>${t(
result.source === "sessions"
? "memoryPage.memories.sourceSessions"
: "memoryPage.memories.sourceMemory",
)}</span
>
<span
>${t("memoryPage.memories.score", {
score: result.score.toFixed(2),
})}</span
>
</span>
`;
return html`<article class="memory-memories__result">
${expandable
? html`<button
type="button"
class="settings-row settings-row--nav"
aria-expanded=${String(open)}
aria-controls=${panelId}
@click=${() => this.toggleResult(result, index)}
>
${summary}
</button>`
: html`<div class="settings-row">${summary}</div>`}
${expandable ? this.renderDetail(key, panelId, result) : nothing}
</article>`;
})}
</div>`}
`;
}
private renderSearchState() {
switch (this.searchState.kind) {
case "loading":
return html`<p class="memory-memories__state" role="status">
${t("memoryPage.memories.searching")}
</p>`;
case "error": {
const failed = this.searchState;
return html`<div class="memory-memories__state" role="alert">
<p>${t("memoryPage.memories.error", { message: failed.message })}</p>
<button class="btn btn--sm" @click=${() => void this.search(failed.query)}>
${t("memoryPage.memories.retry")}
</button>
</div>`;
}
case "ready":
return this.renderResults(this.searchState);
default:
return html`<p class="memory-memories__state">${t("memoryPage.memories.idle")}</p>`;
}
}
override render() {
return html`<div class="settings-page memory-memories">
${renderMemoryAgentScope({
agentId: this.agentId,
agents: this.agents,
onAgentChange: this.onAgentChange,
})}
${!this.methodAdvertised
? html`<p class="memory-memories__unavailable">
${t("memoryPage.memories.gatewayUpdateRequired")}
</p>`
: html`<form
class="memory-memories__search"
role="search"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
void this.search(this.query);
}}
>
<label class="settings-control__sr-label" for="memory-search-input"
>${t("memoryPage.memories.searchLabel")}</label
>
<input
id="memory-search-input"
type="search"
class="settings-input"
.value=${this.query}
placeholder=${t("memoryPage.memories.searchPlaceholder")}
@input=${(event: InputEvent) => {
this.query = (event.currentTarget as HTMLInputElement).value;
}}
/>
<button
class="btn btn--sm primary"
type="submit"
?disabled=${!this.connected ||
!this.agentId ||
!this.query.trim() ||
this.searchState.kind === "loading"}
>
${t("memoryPage.memories.searchButton")}
</button>
</form>
${this.renderSearchState()}`}
</div>`;
}
}
if (!customElements.get("openclaw-memory-memories")) {
customElements.define("openclaw-memory-memories", MemoryMemoriesElement);
}

View File

@@ -1,8 +1,25 @@
/* @vitest-environment jsdom */
import { expectDefined } from "@openclaw/normalization-core";
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { DoctorMemoryStatusPayload } from "../../../../src/gateway/server-methods/doctor.ts";
// The hero derives its lobster from lobsterPetSeed, which mixes in a random
// per-load salt, so the palette (and with it sprite geometry like the sleeping
// eye peek) varies per test process. Pin a canonical look so pose assertions
// stay deterministic.
vi.mock("../../components/lobster-pet.ts", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../components/lobster-pet.ts")>();
return {
...actual,
createLobsterPetLook: () =>
actual.canonicalLobsterLook(
expectDefined(actual.LOBSTER_PET_PALETTES[0], "canonical lobster palette"),
),
};
});
import { renderMemoryOverview, type MemoryOverviewStatus } from "./memory-overview.ts";
function fixturePayload(): DoctorMemoryStatusPayload {
@@ -167,4 +184,28 @@ describe("renderMemoryOverview", () => {
expect(lightRow?.textContent).not.toContain("Enabled");
expect(lightRow?.textContent).not.toContain("next ");
});
it("opens the Memories tab from the overview shortcut", () => {
const onNavigate = vi.fn();
const container = document.createElement("div");
render(
renderMemoryOverview({
agentId: "main",
agents: [],
engineSelection: { kind: "auto", engineId: "memory-core" },
engineDisabled: false,
status: { kind: "ready", payload: fixturePayload() },
onAgentChange: vi.fn(),
onRefresh: vi.fn(),
onNavigate,
}),
container,
);
const shortcut = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
button.textContent?.includes("Search memories"),
);
shortcut?.click();
expect(onNavigate).toHaveBeenCalledWith("memories");
});
});

View File

@@ -34,7 +34,7 @@ type MemoryOverviewProps = {
status: MemoryOverviewStatus;
onAgentChange: (agentId: string | null) => void;
onRefresh: () => void;
onNavigate: (tab: "dreams" | "settings") => void;
onNavigate: (tab: "memories" | "dreams" | "settings") => void;
};
type DreamingStatus = NonNullable<DoctorMemoryStatusPayload["dreaming"]>;
@@ -247,6 +247,10 @@ function renderShortcuts(props: MemoryOverviewProps) {
return renderSettingsSection(
{ title: t("memoryPage.overview.shortcuts.title") },
html`
${renderSettingsNavRow({
title: t("memoryPage.overview.shortcuts.memories"),
onClick: () => props.onNavigate("memories"),
})}
${renderSettingsNavRow({
title: t("memoryPage.overview.shortcuts.diary"),
onClick: () => props.onNavigate("dreams"),

View File

@@ -137,7 +137,7 @@ function addonStatus(element: HTMLElement, label: string): string | null {
}
/** Which tab body is actually mounted, rather than what the tab strip claims. */
function visibleTab(element: HTMLElement): "overview" | "dreams" | "settings" | null {
function visibleTab(element: HTMLElement): "overview" | "memories" | "dreams" | "settings" | null {
const panel = element.querySelector('[role="tabpanel"]');
if (!panel) {
return null;
@@ -145,6 +145,9 @@ function visibleTab(element: HTMLElement): "overview" | "dreams" | "settings" |
if (panel.querySelector("openclaw-memory-dreaming")) {
return "dreams";
}
if (panel.querySelector("openclaw-memory-memories")) {
return "memories";
}
return panel.querySelector(".memory-overview") ? "overview" : "settings";
}
@@ -373,6 +376,10 @@ describe("MemorySettingsPage tab routing", () => {
element.tab = "settings";
await element.updateComplete;
expect(visibleTab(element)).toBe("settings");
element.tab = "memories";
await element.updateComplete;
expect(visibleTab(element)).toBe("memories");
} finally {
element.remove();
}
@@ -392,6 +399,9 @@ describe("MemorySettingsPage tab routing", () => {
// Nothing moves until the router feeds the new tab back in.
await element.updateComplete;
expect(visibleTab(element)).toBe("overview");
selectTab(element, "memories");
expect(navigate).toHaveBeenCalledWith("memory", { search: "?tab=memories" });
} finally {
element.remove();
}

View File

@@ -11,6 +11,7 @@ import type { AgentSelectOption } from "../../components/agent-select.ts";
import { t } from "../../i18n/index.ts";
import { listSelectableAgents, normalizeAgentLabel } from "../../lib/agents/display.ts";
import { currentConfigObject } from "../../lib/config/index.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import {
loadPluginCatalog,
setPluginEnabled,
@@ -24,6 +25,7 @@ import {
type DreamingConfigPathSupport,
} from "../agents/memory/dreaming.ts";
import "./memory-dreaming-page.ts";
import "./memory-memories.ts";
import { renderDreamingSettings, renderDreamingUnsupported } from "./memory-dreaming.ts";
import { renderMemoryOverview, type MemoryOverviewStatus } from "./memory-overview.ts";
import {
@@ -461,6 +463,19 @@ class MemorySettingsPage extends OpenClawLightDomElement {
onRefresh: () => void this.loadOverviewStatus(true),
onNavigate: (tab) => this.navigateTab(tab),
}),
memories: html`
<openclaw-memory-memories
.client=${this.context.gateway.snapshot.client}
.connected=${this.context.gateway.snapshot.phase === "connected"}
.methodAdvertised=${isGatewayMethodAdvertised(
this.context.gateway.snapshot,
"memory.search",
) === true}
.agentId=${agentId}
.agents=${agents}
.onAgentChange=${(next: string | null) => this.selectAgent(next)}
></openclaw-memory-memories>
`,
dreams: html`
<openclaw-memory-dreaming
.agentId=${agentId}

View File

@@ -8,7 +8,7 @@
import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveSlotSelection } from "../../../../src/plugins/slots.ts";
export type MemoryTab = "overview" | "dreams" | "settings";
export type MemoryTab = "overview" | "memories" | "dreams" | "settings";
export type MemoryBackend = "builtin" | "qmd";
@@ -26,7 +26,7 @@ export type MemoryEngineSelection =
/** Scroll target for `memory.backend`, which Settings curates out of the editor. */
export const MEMORY_BACKEND_ANCHOR_ID = "memory-backend";
const MEMORY_TABS: readonly MemoryTab[] = ["overview", "dreams", "settings"];
const MEMORY_TABS: readonly MemoryTab[] = ["overview", "memories", "dreams", "settings"];
/** Reads a `?tab=` value from a settings-search destination or a shared link. */
function normalizeMemoryTab(value: string | null | undefined): MemoryTab | null {

View File

@@ -42,6 +42,7 @@ function createProps(overrides: Partial<MemoryViewProps> = {}): MemoryViewProps
pluginsHref: "/settings/plugins",
memoryImportHref: "/memory-import",
overview: html`<div class="test-overview"></div>`,
memories: html`<div class="test-memories"></div>`,
dreams: html`<div class="test-dreams"></div>`,
editor: html`<div class="test-editor"></div>`,
dreamingSettings: html`<div class="test-dreaming-settings"></div>`,
@@ -133,6 +134,9 @@ describe("renderMemory", () => {
expect(
renderInto(createProps({ activeTab: "overview" })).querySelector(".test-overview"),
).not.toBeNull();
expect(
renderInto(createProps({ activeTab: "memories" })).querySelector(".test-memories"),
).not.toBeNull();
const settings = renderInto(createProps({ activeTab: "settings" }));
expect(settings.querySelector(".test-editor")).not.toBeNull();
@@ -149,6 +153,7 @@ describe("memoryTabForRoute", () => {
expect(memoryTabForRoute({ tab: "search" })).toBe("settings");
expect(memoryTabForRoute({ tab: "dreaming" })).toBe("dreams");
expect(memoryTabForRoute({ tab: "overview" })).toBe("overview");
expect(memoryTabForRoute({ tab: "memories" })).toBe("memories");
expect(memoryTabForRoute({ tab: "unknown" })).toBeNull();
});
@@ -164,6 +169,7 @@ describe("memoryTabForRoute", () => {
describe("memorySchemaKeysForTab", () => {
it("reveals qmd sub-config only when qmd is the selected backend", () => {
expect(memorySchemaKeysForTab("overview", "builtin")).toEqual([]);
expect(memorySchemaKeysForTab("memories", "builtin")).toEqual([]);
expect(memorySchemaKeysForTab("dreams", "qmd")).toEqual([]);
expect(memorySchemaKeysForTab("settings", "builtin")).toEqual(["citations", "search"]);
expect(memorySchemaKeysForTab("settings", "qmd")).toEqual(["citations", "qmd", "search"]);

View File

@@ -64,6 +64,8 @@ type MemoryViewProps = {
memoryImportHref: string;
/** New status-led landing view. */
overview: TemplateResult;
/** Search and read the selected agent's indexed memory. */
memories: TemplateResult;
/** Agent-scoped dream diary and scene. */
dreams: TemplateResult;
/** One embedded editor for every `memory.*` schema field. */
@@ -284,6 +286,7 @@ export function renderMemory(props: MemoryViewProps) {
active: props.activeTab,
tabs: [
{ value: "overview", label: t("memoryPage.tabs.overview") },
{ value: "memories", label: t("memoryPage.tabs.memories") },
{ value: "dreams", label: t("memoryPage.tabs.dreams") },
{ value: "settings", label: t("memoryPage.tabs.settings") },
],
@@ -294,9 +297,11 @@ export function renderMemory(props: MemoryViewProps) {
<div id=${MEMORY_PANEL_ID} class="memory-page__panel" role="tabpanel">
${props.activeTab === "overview"
? props.overview
: props.activeTab === "dreams"
? props.dreams
: renderSettingsTab(props)}
: props.activeTab === "memories"
? props.memories
: props.activeTab === "dreams"
? props.dreams
: renderSettingsTab(props)}
</div>
</section>
`;

View File

@@ -0,0 +1,92 @@
.memory-memories__search {
display: flex;
gap: var(--space-2);
}
.memory-memories__search .settings-input {
flex: 1;
}
.memory-memories__state,
.memory-memories__unavailable {
margin: 0;
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
color: var(--muted);
}
.memory-memories__state p,
.memory-memories__detail p {
margin: 0 0 var(--space-3);
}
.memory-memories__results-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
color: var(--text);
font-size: var(--control-ui-text-sm);
font-weight: 600;
}
.memory-memories__mode,
.memory-memories__source {
padding: 2px 7px;
border-radius: 999px;
background: var(--bg-accent);
color: var(--muted);
font-size: var(--control-ui-text-xs);
font-weight: 600;
}
.memory-memories__result + .memory-memories__result {
border-top: 1px solid var(--border);
}
.memory-memories__meta {
flex-wrap: wrap;
color: var(--muted);
font-size: var(--control-ui-text-xs);
}
.memory-memories__path {
overflow-wrap: anywhere;
font-family: var(--mono);
}
.memory-memories__detail {
padding: 0 var(--space-4) var(--space-4);
}
.memory-memories__detail-error {
margin: 0;
color: var(--danger);
font-size: var(--control-ui-text-sm);
}
.memory-memories__file {
max-height: 420px;
margin: 0;
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
color: var(--text);
font: 12px/1.55 var(--mono);
white-space: pre;
}
.memory-memories__file mark {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: inherit;
}
@media (max-width: 560px) {
.memory-memories__search {
align-items: stretch;
flex-direction: column;
}
}