fix(ui): keep dream diary navigation visible while scrolling (#118054)

This commit is contained in:
Peter Steinberger
2026-08-02 10:27:48 -07:00
committed by GitHub
parent 4b134104fb
commit a3198023ea
3 changed files with 227 additions and 74 deletions
@@ -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<typeof renderDreaming>[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<HTMLElement>(".dreams-diary");
const navigation = host.querySelector<HTMLElement>(".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<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
const olderDate = expectDefined(
navigation!.querySelectorAll<HTMLButtonElement>(".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");
});
});
+45
View File
@@ -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<HTMLButtonElement>(".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");
+76 -74
View File
@@ -22,11 +22,9 @@ type DiaryEntry = {
body: string;
};
type DiaryEntryNav = {
date: string;
body: string;
page: number;
};
type DiaryPanel =
| ReturnType<typeof html>
| { navigation: ReturnType<typeof html>; content: ReturnType<typeof html> };
const DIARY_START_RE = /<!--\s*openclaw:dreaming:diary:start\s*-->/;
const DIARY_END_RE = /<!--\s*openclaw:dreaming:diary:end\s*-->/;
@@ -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`
<div class="dreams-diary__daychips">
${labels.map(
(label, index) => html`
<button
class="dreams-diary__day-chip ${index === selectedPage
? "dreams-diary__day-chip--active"
: ""}"
@click=${() => {
setDiaryPage(state, index, labels.length);
props.onViewStateChange();
}}
>
${label}
</button>
`,
)}
</div>
`;
}
function renderWikiClusterSection<
Cluster extends { key: string; label: string; items: { pagePath: string }[] },
>(
@@ -1127,7 +1143,7 @@ function renderWikiClusterSection<
prose: (cluster: Cluster) => ReturnType<typeof html>;
renderItem: (item: Cluster["items"][number]) => ReturnType<typeof html>;
},
) {
): 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`
<div class="dreams-diary__daychips">
${clusters.map(
(entry, index) => html`
<button
class="dreams-diary__day-chip ${index === clusterIndex
? "dreams-diary__day-chip--active"
: ""}"
@click=${() => {
setDiaryPage(state, index, clusters.length);
props.onViewStateChange();
}}
>
${entry.label}
</button>
`,
)}
</div>
<article class="dreams-diary__entry" key="${params.kind}-${cluster.key}">
<div class="dreams-diary__accent"></div>
<div class="dreams-diary__date">${params.date(cluster)}</div>
<div class="dreams-diary__prose">${params.prose(cluster)}</div>
<div class="dreams-diary__insights">${cluster.items.map(params.renderItem)}</div>
</article>
`;
return {
navigation: renderDiaryNavigation(
props,
clusters.map((entry) => entry.label),
clusterIndex,
),
content: html`
<article class="dreams-diary__entry" key="${params.kind}-${cluster.key}">
<div class="dreams-diary__accent"></div>
<div class="dreams-diary__date">${params.date(cluster)}</div>
<div class="dreams-diary__prose">${params.prose(cluster)}</div>
<div class="dreams-diary__insights">${cluster.items.map(params.renderItem)}</div>
</article>
`,
};
}
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`
<div class="dreams-diary__daychips">
${reversed.map(
(e) => html`
<button
class="dreams-diary__day-chip ${e.page === page
? "dreams-diary__day-chip--active"
: ""}"
@click=${() => {
setDiaryPage(state, e.page, reversed.length);
props.onViewStateChange();
}}
>
${formatDiaryChipLabel(e.date)}
</button>
`,
)}
</div>
<article class="dreams-diary__entry" key="${page}">
<div class="dreams-diary__accent"></div>
${entry.date ? html`<time class="dreams-diary__date">${entry.date}</time>` : nothing}
<div class="dreams-diary__prose">
${flattenDiaryBody(entry.body).map(
(para, i) =>
html`<p class="dreams-diary__para" style="animation-delay: ${0.3 + i * 0.15}s;">
${unsafeHTML(toSanitizedMarkdownHtml(para))}
</p>`,
)}
</div>
</article>
`;
return {
navigation: renderDiaryNavigation(
props,
reversed.map((diaryEntry) => formatDiaryChipLabel(diaryEntry.date)),
page,
),
content: html`
<article class="dreams-diary__entry" key="${page}">
<div class="dreams-diary__accent"></div>
${entry.date ? html`<time class="dreams-diary__date">${entry.date}</time>` : nothing}
<div class="dreams-diary__prose">
${flattenDiaryBody(entry.body).map(
(para, i) =>
html`<p class="dreams-diary__para" style="animation-delay: ${0.3 + i * 0.15}s;">
${unsafeHTML(toSanitizedMarkdownHtml(para))}
</p>`,
)}
</div>
</article>
`,
};
}
// ── 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`
<section class="dreams-diary">
<div class="dreams-diary__chrome">
@@ -1409,6 +1414,7 @@ function renderDiarySection(props: DreamingProps) {
</button>
</div>
${renderDiarySubtabExplainer(activeDiarySubTab)}
${memoryWikiUnavailable ? nothing : diaryNavigation}
</div>
<div
@@ -1437,11 +1443,7 @@ function renderDiarySection(props: DreamingProps) {
</div>
</div>
`
: activeDiarySubTab === "dreams"
? renderDreamDiaryEntries(props)
: activeDiarySubTab === "insights"
? renderDiaryImportsSection(props)
: renderWikiOverviewSection(props)}
: diaryContent}
</div>
${renderWikiPreviewOverlay(props)}
</section>