mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(ui): keep dream diary navigation visible while scrolling (#118054)
This commit is contained in:
committed by
GitHub
parent
4b134104fb
commit
a3198023ea
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user