From a3198023ea866875e757a52ffb4e4b87aaf8ff73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 10:27:48 -0700 Subject: [PATCH] fix(ui): keep dream diary navigation visible while scrolling (#118054) --- .../pages/agents/memory/view.browser.test.ts | 106 +++++++++++++ ui/src/pages/agents/memory/view.test.ts | 45 ++++++ ui/src/pages/agents/memory/view.ts | 150 +++++++++--------- 3 files changed, 227 insertions(+), 74 deletions(-) create mode 100644 ui/src/pages/agents/memory/view.browser.test.ts diff --git a/ui/src/pages/agents/memory/view.browser.test.ts b/ui/src/pages/agents/memory/view.browser.test.ts new file mode 100644 index 000000000000..eb9a9049b598 --- /dev/null +++ b/ui/src/pages/agents/memory/view.browser.test.ts @@ -0,0 +1,106 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { render } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import { createDreamingViewState, renderDreaming } from "./view.ts"; + +const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom"); +let host: HTMLDivElement | undefined; + +afterEach(() => { + host?.remove(); + host = undefined; +}); + +describe.skipIf(!hasBrowserLayout)("dream diary browser layout", () => { + it("keeps diary dates visible and clickable while a long entry scrolls", async () => { + host = document.createElement("div"); + host.style.height = "420px"; + host.style.width = "760px"; + host.style.overflow = "hidden"; + document.body.append(host); + + const viewState = createDreamingViewState(); + viewState.activeSubTab = "diary"; + viewState.activeDiarySubTab = "dreams"; + const props: Parameters[0] = { + viewState, + active: true, + selectedAgentId: "main", + shortTermCount: 0, + promotedCount: 0, + shortTermEntries: [], + promotedEntries: [], + dreamingOf: null, + nextCycle: null, + timezone: null, + statusError: null, + modeSaving: false, + dreamDiaryLoading: false, + dreamDiaryActionLoading: false, + dreamDiaryActionMessage: null, + dreamDiaryActionArchivePath: null, + dreamDiaryError: null, + dreamDiaryContent: [ + "# Dream Diary", + "---", + "*January 1, 2026*", + "The earlier diary entry remains available.", + "---", + "*January 2, 2026*", + ...Array.from( + { length: 24 }, + (_, index) => `Long diary paragraph ${index + 1}: ${"a visible memory ".repeat(8)}`, + ), + ].join("\n\n"), + memoryWikiEnabled: false, + wikiImportInsightsLoading: false, + wikiImportInsightsError: null, + wikiImportInsights: null, + wikiOverviewLoading: false, + wikiOverviewError: null, + wikiOverview: null, + onRefreshDiary: () => {}, + onRefreshImports: () => {}, + onRefreshWikiOverview: () => {}, + onOpenConfig: () => {}, + onOpenWikiPage: async () => null, + onBackfillDiary: () => {}, + onCopyDreamingArchivePath: () => {}, + onDedupeDreamDiary: () => {}, + onResetDiary: () => {}, + onResetGroundedShortTerm: () => {}, + onRepairDreamingArtifacts: () => {}, + onViewStateChange: () => render(renderDreaming(props), host!), + }; + render(renderDreaming(props), host); + + const diary = host.querySelector(".dreams-diary"); + const navigation = host.querySelector(".dreams-diary__daychips"); + expect(diary).not.toBeNull(); + expect(navigation).not.toBeNull(); + expect(getComputedStyle(diary!).overflowY).toBe("auto"); + expect(getComputedStyle(navigation!.parentElement!).position).toBe("sticky"); + expect(diary!.scrollHeight).toBeGreaterThan(diary!.clientHeight); + + diary!.scrollTop = 500; + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + + const olderDate = expectDefined( + navigation!.querySelectorAll(".dreams-diary__day-chip")[1], + "older diary date button", + ); + expect(diary!.scrollTop).toBeGreaterThan(100); + expect(olderDate.getBoundingClientRect().top).toBeGreaterThanOrEqual( + diary!.getBoundingClientRect().top, + ); + expect(olderDate.getBoundingClientRect().bottom).toBeLessThanOrEqual( + diary!.getBoundingClientRect().bottom, + ); + + olderDate.click(); + expect(viewState.diaryPage).toBe(1); + expect(host.querySelector(".dreams-diary__date")?.textContent).toBe("January 1, 2026"); + }); +}); diff --git a/ui/src/pages/agents/memory/view.test.ts b/ui/src/pages/agents/memory/view.test.ts index 5c944ea6f81e..e6e89d2710c0 100644 --- a/ui/src/pages/agents/memory/view.test.ts +++ b/ui/src/pages/agents/memory/view.test.ts @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { expectDefined } from "@openclaw/normalization-core"; import { render } from "lit"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../../../i18n/index.ts"; @@ -835,6 +836,50 @@ describe("dreaming view", () => { setDreamSubTab("scene"); }); + it.each([ + { tab: "dreams", labels: ["1/2", "1/1"] }, + { tab: "insights", labels: ["Travel", "Health"] }, + { tab: "wiki", labels: ["Syntheses", "Concepts"] }, + ] as const)("keeps $tab navigation inside the sticky diary controls", ({ tab, labels }) => { + setDreamSubTab("diary"); + setDreamDiarySubTab(tab); + const props = buildProps({ + dreamDiaryContent: [ + "# Dream Diary", + "---", + "*January 1, 2026*", + "An earlier dream.", + "---", + "*January 2, 2026*", + ...Array.from({ length: 12 }, (_, index) => `Long diary paragraph ${index + 1}.`), + ].join("\n\n"), + onViewStateChange: vi.fn(), + }); + const wikiOverview = props.wikiOverview; + if (wikiOverview) { + const firstCluster = expectDefined(wikiOverview.clusters[0], "first memory wiki cluster"); + props.wikiOverview = { + ...wikiOverview, + clusters: [ + ...wikiOverview.clusters, + { ...firstCluster, key: "concept", label: "Concepts" }, + ], + }; + } + + const container = renderInto(props); + const stickyChrome = expectElement(container, ".dreams-diary__chrome"); + const navigation = expectElement(stickyChrome, ".dreams-diary__daychips"); + const buttons = [...navigation.querySelectorAll(".dreams-diary__day-chip")]; + + expect(buttons.map((button) => compactText(button))).toEqual(labels); + expect(container.querySelector("#dream-diary-panel .dreams-diary__daychips")).toBeNull(); + + buttons[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(viewState.diaryPage).toBe(1); + expect(props.onViewStateChange).toHaveBeenCalledOnce(); + }); + it("renders diary empty, error, and removed-navigation states", () => { setDreamSubTab("diary"); setDreamDiarySubTab("dreams"); diff --git a/ui/src/pages/agents/memory/view.ts b/ui/src/pages/agents/memory/view.ts index d29b2ba5bc2f..8d3024f69a0c 100644 --- a/ui/src/pages/agents/memory/view.ts +++ b/ui/src/pages/agents/memory/view.ts @@ -22,11 +22,9 @@ type DiaryEntry = { body: string; }; -type DiaryEntryNav = { - date: string; - body: string; - page: number; -}; +type DiaryPanel = + | ReturnType + | { navigation: ReturnType; content: ReturnType }; const DIARY_START_RE = //; const DIARY_END_RE = //; @@ -87,11 +85,6 @@ function formatDiaryChipLabel(date: string): string { return `${value.getMonth() + 1}/${value.getDate()}`; } -function buildDiaryNavigation(entries: DiaryEntry[]): DiaryEntryNav[] { - const reversed = [...entries].toReversed(); - return reversed.map((entry, page) => Object.assign({}, entry, { page })); -} - type DreamingPhaseInfo = { enabled: boolean; cron: string; @@ -1112,6 +1105,29 @@ function renderWikiInsightCard(props: DreamingProps, card: WikiInsightCard) { `; } +function renderDiaryNavigation(props: DreamingProps, labels: string[], selectedPage: number) { + const state = props.viewState; + return html` +
+ ${labels.map( + (label, index) => html` + + `, + )} +
+ `; +} + function renderWikiClusterSection< Cluster extends { key: string; label: string; items: { pagePath: string }[] }, >( @@ -1127,7 +1143,7 @@ function renderWikiClusterSection< prose: (cluster: Cluster) => ReturnType; renderItem: (item: Cluster["items"][number]) => ReturnType; }, -) { +): DiaryPanel { const { clusters } = params; if (clusters.length === 0) { return html` @@ -1150,31 +1166,21 @@ function renderWikiClusterSection< ? "selected imported insight cluster" : "selected memory overview cluster", ); - return html` -
- ${clusters.map( - (entry, index) => html` - - `, - )} -
-
-
-
${params.date(cluster)}
-
${params.prose(cluster)}
-
${cluster.items.map(params.renderItem)}
-
- `; + return { + navigation: renderDiaryNavigation( + props, + clusters.map((entry) => entry.label), + clusterIndex, + ), + content: html` +
+
+
${params.date(cluster)}
+
${params.prose(cluster)}
+
${cluster.items.map(params.renderItem)}
+
+ `, + }; } function renderDiaryImportsSection(props: DreamingProps) { @@ -1262,7 +1268,7 @@ function renderWikiOverviewSection(props: DreamingProps) { }); } -function renderDreamDiaryEntries(props: DreamingProps) { +function renderDreamDiaryEntries(props: DreamingProps): DiaryPanel { const state = props.viewState; if (typeof props.dreamDiaryContent !== "string") { return html` @@ -1289,41 +1295,31 @@ function renderDreamDiaryEntries(props: DreamingProps) { `; } - const reversed = buildDiaryNavigation(entries); + const reversed = entries.toReversed(); const page = Math.max(0, Math.min(state.diaryPage, reversed.length - 1)); const entry = expectDefined(reversed[page], "selected dreaming diary entry"); - return html` -
- ${reversed.map( - (e) => html` - - `, - )} -
-
-
- ${entry.date ? html`` : nothing} -
- ${flattenDiaryBody(entry.body).map( - (para, i) => - html`

- ${unsafeHTML(toSanitizedMarkdownHtml(para))} -

`, - )} -
-
- `; + return { + navigation: renderDiaryNavigation( + props, + reversed.map((diaryEntry) => formatDiaryChipLabel(diaryEntry.date)), + page, + ), + content: html` +
+
+ ${entry.date ? html`` : nothing} +
+ ${flattenDiaryBody(entry.body).map( + (para, i) => + html`

+ ${unsafeHTML(toSanitizedMarkdownHtml(para))} +

`, + )} +
+
+ `, + }; } // ── Diary section renderer ──────────────────────────────────────────── @@ -1347,6 +1343,15 @@ function renderDiarySection(props: DreamingProps) { `; } + const diaryPanel = + activeDiarySubTab === "dreams" + ? renderDreamDiaryEntries(props) + : activeDiarySubTab === "insights" + ? renderDiaryImportsSection(props) + : renderWikiOverviewSection(props); + const diaryNavigation = "navigation" in diaryPanel ? diaryPanel.navigation : nothing; + const diaryContent = "content" in diaryPanel ? diaryPanel.content : diaryPanel; + return html`
@@ -1409,6 +1414,7 @@ function renderDiarySection(props: DreamingProps) {
${renderDiarySubtabExplainer(activeDiarySubTab)} + ${memoryWikiUnavailable ? nothing : diaryNavigation}
` - : activeDiarySubTab === "dreams" - ? renderDreamDiaryEntries(props) - : activeDiarySubTab === "insights" - ? renderDiaryImportsSection(props) - : renderWikiOverviewSection(props)} + : diaryContent} ${renderWikiPreviewOverlay(props)}