From 06fa48099ee4e025d2572fd44995a4493b6b98f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 16:04:30 -0700 Subject: [PATCH] fix(ui): reveal clipped session history (#124866) * fix(ui): reveal clipped session history Show a persistent earlier-history action for paginated sessions and reveal loaded content without chaining observer fetches. Related: #110771 * refactor(ui): split chat history support --- ui/src/e2e/claude-sessions.e2e.test.ts | 338 +++++------------- ui/src/e2e/virtual-row-anchor.test-support.ts | 214 +++++++++++ ui/src/i18n/locales/en.ts | 3 + ui/src/pages/chat/chat-pane-history.test.ts | 145 ++++++++ ui/src/pages/chat/chat-pane-history.ts | 220 +++--------- ui/src/pages/chat/chat-pane-render.ts | 7 +- .../pages/chat/chat-pane-reply-navigation.ts | 183 ++++++++++ ui/src/pages/chat/chat-view.test.ts | 55 +++ ui/src/pages/chat/chat-view.ts | 27 +- .../components/chat-thread-interactions.ts | 6 +- ui/src/styles/chat/layout.css | 17 + 11 files changed, 787 insertions(+), 428 deletions(-) create mode 100644 ui/src/e2e/virtual-row-anchor.test-support.ts create mode 100644 ui/src/pages/chat/chat-pane-reply-navigation.ts diff --git a/ui/src/e2e/claude-sessions.e2e.test.ts b/ui/src/e2e/claude-sessions.e2e.test.ts index 192f97f4d8b9..1bf4e050ab21 100644 --- a/ui/src/e2e/claude-sessions.e2e.test.ts +++ b/ui/src/e2e/claude-sessions.e2e.test.ts @@ -4,6 +4,14 @@ import type { Locator, Page } from "playwright"; import { expect, it } from "vitest"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; +import { + captureTopVisibleVirtualRow, + expectPaintedVirtualRowAnchor, + startVirtualRowPaintProbe, + stopVirtualRowPaintProbe, + type VirtualRowPaintResult, + waitForPaintedVirtualRowAnchor, +} from "./virtual-row-anchor.test-support.ts"; const suite = createControlUiE2eSuite({ name: "Claude native session catalog", @@ -11,215 +19,6 @@ const suite = createControlUiE2eSuite({ unavailableMessage: (executablePath) => `Playwright Chromium is unavailable at ${executablePath}`, }); -type VisibleVirtualRow = { - index: number; - key: string; - totalSize: number; - viewportTop: number; -}; - -type VirtualRowPaintSample = { - index: number | null; - intersectsViewport: boolean; - totalSize: number; - viewportTop: number | null; -}; - -type VirtualRowPaintProbe = { - frameIds: number[]; - observer: MutationObserver; - pendingSamples: number; - samples: VirtualRowPaintSample[]; - timerIds: number[]; -}; - -type VirtualRowPaintResult = { - pending: boolean; - samples: VirtualRowPaintSample[]; -}; - -async function captureTopVisibleVirtualRow(thread: Locator): Promise { - return thread.evaluate((element) => { - const viewport = element.getBoundingClientRect(); - const row = Array.from( - element.querySelectorAll(".chat-virtual-row[data-virtual-row-key]"), - ).find((candidate) => { - const rect = candidate.getBoundingClientRect(); - return ( - candidate.dataset.virtualRowKey !== "history" && - rect.bottom > viewport.top && - rect.top < viewport.bottom - ); - }); - if (!row) { - throw new Error("expected a visible virtual transcript row"); - } - const index = Number.parseInt(row.dataset.index ?? "", 10); - if (!Number.isFinite(index)) { - throw new Error("expected the virtual transcript anchor to expose its row index"); - } - return { - index, - key: row.dataset.virtualRowKey ?? "", - totalSize: - element.querySelector(".chat-virtual-sizer")?.getBoundingClientRect().height ?? - 0, - viewportTop: row.getBoundingClientRect().top - viewport.top, - }; - }); -} - -async function startVirtualRowPaintProbe(thread: Locator, anchor: VisibleVirtualRow) { - await thread.evaluate((element, expected) => { - const target = globalThis as typeof globalThis & { - chatPrependPaintProbe?: VirtualRowPaintProbe; - }; - const staleProbe = target.chatPrependPaintProbe; - if (staleProbe) { - staleProbe.observer.disconnect(); - staleProbe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId)); - staleProbe.timerIds.forEach((timerId) => clearTimeout(timerId)); - delete target.chatPrependPaintProbe; - } - const probe: VirtualRowPaintProbe = { - frameIds: [], - observer: new MutationObserver(() => undefined), - pendingSamples: 0, - samples: [], - timerIds: [], - }; - const sample = () => { - const viewport = element.getBoundingClientRect(); - const row = Array.from( - element.querySelectorAll(".chat-virtual-row[data-virtual-row-key]"), - ).find( - (candidate) => - candidate.dataset.virtualRowKey !== "history" && - candidate.dataset.virtualRowKey === expected.key, - ); - const rect = row?.getBoundingClientRect(); - const index = row ? Number.parseInt(row.dataset.index ?? "", 10) : Number.NaN; - probe.samples.push({ - index: Number.isFinite(index) ? index : null, - intersectsViewport: Boolean( - rect && rect.bottom > viewport.top && rect.top < viewport.bottom, - ), - totalSize: - element.querySelector(".chat-virtual-sizer")?.getBoundingClientRect() - .height ?? 0, - viewportTop: rect ? rect.top - viewport.top : null, - }); - }; - const removePendingId = (ids: number[], id: number) => { - const index = ids.indexOf(id); - if (index !== -1) { - ids.splice(index, 1); - } - }; - const scheduleSample = () => { - // Each mutation batch owns a post-paint sample; later mutations must not - // cancel an earlier frame that could expose a visible anchor jump. - probe.pendingSamples += 1; - const firstFrame = requestAnimationFrame(() => { - removePendingId(probe.frameIds, firstFrame); - const secondFrame = requestAnimationFrame(() => { - removePendingId(probe.frameIds, secondFrame); - const timerId = window.setTimeout(() => { - removePendingId(probe.timerIds, timerId); - sample(); - probe.pendingSamples -= 1; - }, 0); - probe.timerIds.push(timerId); - }); - probe.frameIds.push(secondFrame); - }); - probe.frameIds.push(firstFrame); - }; - probe.observer = new MutationObserver(scheduleSample); - probe.observer.observe(element, { - attributeFilter: ["style"], - attributes: true, - childList: true, - subtree: true, - }); - target.chatPrependPaintProbe = probe; - }, anchor); -} - -async function readVirtualRowPaintProbe(thread: Locator) { - return thread.evaluate(() => { - const probe = ( - globalThis as typeof globalThis & { - chatPrependPaintProbe?: VirtualRowPaintProbe; - } - ).chatPrependPaintProbe; - if (!probe) { - throw new Error("expected an active virtual row paint probe"); - } - return { - pendingSamples: probe.pendingSamples, - samples: probe.samples, - }; - }); -} - -async function stopVirtualRowPaintProbe(thread: Locator): Promise { - return thread.evaluate(() => { - const target = globalThis as typeof globalThis & { - chatPrependPaintProbe?: VirtualRowPaintProbe; - }; - const probe = target.chatPrependPaintProbe; - if (!probe) { - throw new Error("expected an active virtual row paint probe"); - } - const pending = probe.pendingSamples > 0; - probe.observer.disconnect(); - probe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId)); - probe.timerIds.forEach((timerId) => clearTimeout(timerId)); - delete target.chatPrependPaintProbe; - return { pending, samples: probe.samples }; - }); -} - -function virtualRowAnchorStatus(anchor: VisibleVirtualRow, samples: VirtualRowPaintSample[]) { - return { - advanced: samples.some( - (sample) => - (sample.index !== null && sample.index > anchor.index) || - sample.totalSize > anchor.totalSize, - ), - anchored: samples.every( - (sample) => - sample.viewportTop !== null && Math.abs(sample.viewportTop - anchor.viewportTop) <= 2, - ), - present: samples.length > 0 && samples.every((sample) => sample.viewportTop !== null), - visible: samples.every((sample) => sample.intersectsViewport), - }; -} - -async function waitForPaintedVirtualRowAnchor(thread: Locator, anchor: VisibleVirtualRow) { - await expect - .poll(async () => { - const probe = await readVirtualRowPaintProbe(thread); - return probe.pendingSamples === 0 && virtualRowAnchorStatus(anchor, probe.samples).advanced; - }) - .toBe(true); -} - -function expectPaintedVirtualRowAnchor(anchor: VisibleVirtualRow, result: VirtualRowPaintResult) { - const evidence = JSON.stringify({ anchor, ...result }); - expect( - { pending: result.pending, ...virtualRowAnchorStatus(anchor, result.samples) }, - evidence, - ).toEqual({ - pending: false, - advanced: true, - anchored: true, - present: true, - visible: true, - }); -} - function resumableClaudeCatalog() { return { catalogs: [ @@ -717,7 +516,7 @@ suite.define(() => { response: { hostId: "node:devbox", threadId: "remote-thread", - items: [{ id: "u1", type: "userMessage", text: "older question" }], + items: [{ id: "a0", type: "agentMessage", text: "older question" }], }, }, { @@ -789,7 +588,9 @@ suite.define(() => { .poll(() => gateway.getRequests("sessions.catalog.read").then((requests) => requests.length)) .toBe(initialReadCount + 1); await catalogPane.locator(".chat-history-loading").waitFor(); - expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0); + const showEarlier = catalogPane.getByRole("button", { name: "Show earlier" }); + await showEarlier.waitFor(); + expect(await showEarlier.getAttribute("aria-busy")).toBe("true"); const anchor = await captureTopVisibleVirtualRow(thread); await startVirtualRowPaintProbe(thread, anchor); let paintResult: VirtualRowPaintResult; @@ -804,6 +605,7 @@ suite.define(() => { ) .toBe(41); await page.clock.runFor(100); + await waitForPaintedVirtualRowAnchor(thread, anchor); } finally { paintResult = await stopVirtualRowPaintProbe(thread); } @@ -854,12 +656,12 @@ suite.define(() => { await expect.poll(() => page.getByText("older question", { exact: true }).count()).toBe(1); await page.clock.runFor(500); expect(await catalogPane.locator(".chat-history-loading").count()).toBe(0); - expect(await catalogPane.getByRole("button", { name: "Load older" }).count()).toBe(0); + expect(await catalogPane.getByRole("button", { name: "Show earlier" }).count()).toBe(0); expect(await gateway.getRequests("sessions.catalog.read")).toHaveLength(exhaustedReadCount); await page.close(); }); - it("auto-loads older native history with a spinner and stable viewport", async () => { + it("shows loaded native history before fetching and revealing an earlier page", async () => { const page = await suite.browser.newPage({ viewport: { width: 1280, height: 800 } }); const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); const historyMessage = (seq: number, prefix: string) => ({ @@ -917,45 +719,76 @@ suite.define(() => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); + const showEarlier = page.getByRole("button", { name: "Show earlier" }); + if (artifactDir) { + await fs.mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + path: path.join(artifactDir, "00-native-history-available.png"), + fullPage: true, + }); + } + const initialRequestCount = (await gateway.getRequests("chat.history")).length; + const tailAnchor = await captureTopVisibleVirtualRow(thread); + const initialScrollTop = await thread.evaluate((element) => element.scrollTop); + await showEarlier.click(); + await expect + .poll(() => thread.evaluate((element) => element.scrollTop)) + .toBeLessThan(initialScrollTop); + const earlierAnchor = await captureTopVisibleVirtualRow(thread); + expect(earlierAnchor.index).toBeLessThan(tailAnchor.index); + expect(await gateway.getRequests("chat.history")).toHaveLength(initialRequestCount); await gateway.deferNext("chat.history"); await thread.evaluate((element) => { element.scrollTop = 0; - element.dispatchEvent(new Event("scroll")); + element.parentElement?.querySelector(".chat-history-available")?.click(); }); - await page.locator('.chat-virtual-row:not([data-virtual-row-key="history"])').first().waitFor(); await gateway.waitForRequest("chat.history"); await page.locator(".chat-history-loading").waitFor(); + expect(await showEarlier.getAttribute("aria-busy")).toBe("true"); if (artifactDir) { - await fs.mkdir(artifactDir, { recursive: true }); await page.screenshot({ path: path.join(artifactDir, "01-native-history-loading.png"), fullPage: true, }); } - const anchor = await captureTopVisibleVirtualRow(thread); - await startVirtualRowPaintProbe(thread, anchor); - let paintResult: VirtualRowPaintResult; - try { - await gateway.resolveDeferred("chat.history"); - await expect - .poll(() => - page - .locator("openclaw-chat-pane") - .evaluate( - (element) => - (element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages - .length, - ), - ) - .toBe(140); - await waitForPaintedVirtualRowAnchor(thread, anchor); - } finally { - paintResult = await stopVirtualRowPaintProbe(thread); - } - expectPaintedVirtualRowAnchor(anchor, paintResult); + await gateway.rejectDeferred("chat.history", { + code: "UNAVAILABLE", + message: "history unavailable", + retryable: true, + }); + await expect.poll(() => page.locator(".chat-history-loading").count()).toBe(0); + expect(await showEarlier.getAttribute("aria-busy")).toBe("false"); + const failedRequestCount = (await gateway.getRequests("chat.history")).length; + await gateway.deferNext("chat.history"); + await showEarlier.click(); + await gateway.waitForRequest("chat.history"); + await page.locator(".chat-history-loading").waitFor(); + expect(await gateway.getRequests("chat.history")).toHaveLength(failedRequestCount + 1); + await gateway.resolveDeferred("chat.history", { + messages: older, + hasMore: true, + nextOffset: 140, + totalMessages: 180, + sessionId: "native-scrollback", + thinkingLevel: null, + }); + await expect + .poll(() => + page + .locator("openclaw-chat-pane") + .evaluate( + (element) => + (element as HTMLElement & { state: { chatMessages: unknown[] } }).state.chatMessages + .length, + ), + ) + .toBe(140); + const firstOlderMessage = page.getByText(/^older native message 1\n/); + await firstOlderMessage.waitFor(); + await expect.poll(() => thread.evaluate((element) => element.scrollTop)).toBeLessThanOrEqual(1); if (artifactDir) { await page.screenshot({ - path: path.join(artifactDir, "02-native-history-prepended-stable.png"), + path: path.join(artifactDir, "02-native-history-prepended-visible.png"), fullPage: true, }); } @@ -963,15 +796,32 @@ suite.define(() => { limit: 100, offset: 100, }); - const exhaustedRequestCount = (await gateway.getRequests("chat.history")).length; - await thread.evaluate((element) => { - element.scrollTop = 0; - element.dispatchEvent(new Event("scroll")); + const firstPageRequestCount = (await gateway.getRequests("chat.history")).length; + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + expect(await gateway.getRequests("chat.history")).toHaveLength(firstPageRequestCount); + await gateway.deferNext("chat.history"); + await showEarlier.click(); + await gateway.waitForRequest("chat.history"); + expect((await gateway.getRequests("chat.history")).at(-1)?.params).toMatchObject({ + limit: 100, + offset: 140, + }); + await gateway.resolveDeferred("chat.history", { + messages: [], + hasMore: false, + totalMessages: 180, + sessionId: "native-scrollback", + thinkingLevel: null, }); - await page.getByText(/^older native message 1\n/).waitFor(); await expect.poll(() => page.locator(".chat-history-sentinel").count()).toBe(0); + expect(await page.getByRole("button", { name: "Show earlier" }).count()).toBe(0); expect(await page.locator(".chat-history-loading").count()).toBe(0); - expect(await gateway.getRequests("chat.history")).toHaveLength(exhaustedRequestCount); + expect(await gateway.getRequests("chat.history")).toHaveLength(firstPageRequestCount + 1); await page.close(); }); diff --git a/ui/src/e2e/virtual-row-anchor.test-support.ts b/ui/src/e2e/virtual-row-anchor.test-support.ts new file mode 100644 index 000000000000..f7decd267206 --- /dev/null +++ b/ui/src/e2e/virtual-row-anchor.test-support.ts @@ -0,0 +1,214 @@ +import type { Locator } from "playwright"; +import { expect } from "vitest"; + +type VisibleVirtualRow = { + index: number; + key: string; + totalSize: number; + viewportTop: number; +}; + +type VirtualRowPaintSample = { + index: number | null; + intersectsViewport: boolean; + totalSize: number; + viewportTop: number | null; +}; + +type VirtualRowPaintProbe = { + frameIds: number[]; + observer: MutationObserver; + pendingSamples: number; + samples: VirtualRowPaintSample[]; + timerIds: number[]; +}; + +export type VirtualRowPaintResult = { + pending: boolean; + samples: VirtualRowPaintSample[]; +}; + +export async function captureTopVisibleVirtualRow(thread: Locator): Promise { + return thread.evaluate((element) => { + const viewport = element.getBoundingClientRect(); + const row = Array.from( + element.querySelectorAll(".chat-virtual-row[data-virtual-row-key]"), + ).find((candidate) => { + const rect = candidate.getBoundingClientRect(); + return ( + candidate.dataset.virtualRowKey !== "history" && + rect.bottom > viewport.top && + rect.top < viewport.bottom + ); + }); + if (!row) { + throw new Error("expected a visible virtual transcript row"); + } + const index = Number.parseInt(row.dataset.index ?? "", 10); + if (!Number.isFinite(index)) { + throw new Error("expected the virtual transcript anchor to expose its row index"); + } + return { + index, + key: row.dataset.virtualRowKey ?? "", + totalSize: + element.querySelector(".chat-virtual-sizer")?.getBoundingClientRect().height ?? + 0, + viewportTop: row.getBoundingClientRect().top - viewport.top, + }; + }); +} + +export async function startVirtualRowPaintProbe(thread: Locator, anchor: VisibleVirtualRow) { + await thread.evaluate((element, expected) => { + const target = globalThis as typeof globalThis & { + chatPrependPaintProbe?: VirtualRowPaintProbe; + }; + const staleProbe = target.chatPrependPaintProbe; + if (staleProbe) { + staleProbe.observer.disconnect(); + staleProbe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId)); + staleProbe.timerIds.forEach((timerId) => clearTimeout(timerId)); + delete target.chatPrependPaintProbe; + } + const probe: VirtualRowPaintProbe = { + frameIds: [], + observer: new MutationObserver(() => undefined), + pendingSamples: 0, + samples: [], + timerIds: [], + }; + const sample = () => { + const viewport = element.getBoundingClientRect(); + const row = Array.from( + element.querySelectorAll(".chat-virtual-row[data-virtual-row-key]"), + ).find( + (candidate) => + candidate.dataset.virtualRowKey !== "history" && + candidate.dataset.virtualRowKey === expected.key, + ); + const rect = row?.getBoundingClientRect(); + const index = row ? Number.parseInt(row.dataset.index ?? "", 10) : Number.NaN; + probe.samples.push({ + index: Number.isFinite(index) ? index : null, + intersectsViewport: Boolean( + rect && rect.bottom > viewport.top && rect.top < viewport.bottom, + ), + totalSize: + element.querySelector(".chat-virtual-sizer")?.getBoundingClientRect() + .height ?? 0, + viewportTop: rect ? rect.top - viewport.top : null, + }); + }; + const removePendingId = (ids: number[], id: number) => { + const index = ids.indexOf(id); + if (index !== -1) { + ids.splice(index, 1); + } + }; + const scheduleSample = () => { + // Each mutation batch owns a post-paint sample; later mutations must not + // cancel an earlier frame that could expose a visible anchor jump. + probe.pendingSamples += 1; + const firstFrame = requestAnimationFrame(() => { + removePendingId(probe.frameIds, firstFrame); + const secondFrame = requestAnimationFrame(() => { + removePendingId(probe.frameIds, secondFrame); + const timerId = window.setTimeout(() => { + removePendingId(probe.timerIds, timerId); + sample(); + probe.pendingSamples -= 1; + }, 0); + probe.timerIds.push(timerId); + }); + probe.frameIds.push(secondFrame); + }); + probe.frameIds.push(firstFrame); + }; + probe.observer = new MutationObserver(scheduleSample); + probe.observer.observe(element, { + attributeFilter: ["style"], + attributes: true, + childList: true, + subtree: true, + }); + target.chatPrependPaintProbe = probe; + }, anchor); +} + +async function readVirtualRowPaintProbe(thread: Locator) { + return thread.evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { + chatPrependPaintProbe?: VirtualRowPaintProbe; + } + ).chatPrependPaintProbe; + if (!probe) { + throw new Error("expected an active virtual row paint probe"); + } + return { + pendingSamples: probe.pendingSamples, + samples: probe.samples, + }; + }); +} + +export async function stopVirtualRowPaintProbe(thread: Locator): Promise { + return thread.evaluate(() => { + const target = globalThis as typeof globalThis & { + chatPrependPaintProbe?: VirtualRowPaintProbe; + }; + const probe = target.chatPrependPaintProbe; + if (!probe) { + throw new Error("expected an active virtual row paint probe"); + } + const pending = probe.pendingSamples > 0; + probe.observer.disconnect(); + probe.frameIds.forEach((frameId) => cancelAnimationFrame(frameId)); + probe.timerIds.forEach((timerId) => clearTimeout(timerId)); + delete target.chatPrependPaintProbe; + return { pending, samples: probe.samples }; + }); +} + +function virtualRowAnchorStatus(anchor: VisibleVirtualRow, samples: VirtualRowPaintSample[]) { + return { + advanced: samples.some( + (sample) => + (sample.index !== null && sample.index > anchor.index) || + sample.totalSize > anchor.totalSize, + ), + anchored: samples.every( + (sample) => + sample.viewportTop !== null && Math.abs(sample.viewportTop - anchor.viewportTop) <= 2, + ), + present: samples.length > 0 && samples.every((sample) => sample.viewportTop !== null), + visible: samples.every((sample) => sample.intersectsViewport), + }; +} + +export async function waitForPaintedVirtualRowAnchor(thread: Locator, anchor: VisibleVirtualRow) { + await expect + .poll(async () => { + const probe = await readVirtualRowPaintProbe(thread); + return probe.pendingSamples === 0 && virtualRowAnchorStatus(anchor, probe.samples).advanced; + }) + .toBe(true); +} + +export function expectPaintedVirtualRowAnchor( + anchor: VisibleVirtualRow, + result: VirtualRowPaintResult, +) { + const evidence = JSON.stringify({ anchor, ...result }); + expect( + { pending: result.pending, ...virtualRowAnchorStatus(anchor, result.samples) }, + evidence, + ).toEqual({ + pending: false, + advanced: true, + anchored: true, + present: true, + visible: true, + }); +} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 09983fdb3668..15059074e319 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5345,6 +5345,9 @@ export const en: TranslationMap = { searchPlaceholder: "Search messages...", closeSearch: "Close search", loading: "Loading chat", + earlierHistoryAvailable: "Earlier history available", + showEarlier: "Show earlier", + loadingEarlier: "Loading earlier history…", noMatches: "No matching messages", }, pairingQrExpired: { diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts index c04d46d06913..41430064c308 100644 --- a/ui/src/pages/chat/chat-pane-history.test.ts +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -11,6 +11,7 @@ import { loadChatHistory } from "./chat-history.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; type TestChatPane = HTMLElement & { + catalogCursor: string | undefined; catalogMessages: unknown[]; context: ApplicationContext; state: ChatPageHost; @@ -25,6 +26,7 @@ type TestChatPane = HTMLElement & { prependUniqueNativeMessages: (messages: unknown[], current: unknown[]) => unknown[]; prependUniqueCatalogMessages: (messages: unknown[]) => unknown[]; loadOlderMessages: () => Promise; + showEarlierMessages: () => Promise; requestReplyMessage: (messageId: string) => void; readReplyMessage: (messageId: string) => unknown; openReplyMessage: (messageId: string) => void; @@ -39,6 +41,7 @@ type TestChatPane = HTMLElement & { activeSessionKey: string | null; pendingScrollOffsetFor: (sessionKey: string) => number | null; revealMessage: (messageId: string) => boolean; + scrollToOffset: (offset: number) => void; }; }; @@ -128,6 +131,30 @@ function nativeHistorySeq(message: unknown): number | undefined { return typeof metadata?.seq === "number" ? metadata.seq : undefined; } +function appendChatThread( + pane: TestChatPane, + options: { clientHeight?: number; scrollHeight?: number; scrollTop?: number } = {}, +) { + const thread = document.createElement("div"); + thread.className = "chat-thread"; + thread.scrollTop = options.scrollTop ?? 0; + Object.defineProperty(thread, "clientHeight", { value: options.clientHeight ?? 500 }); + Object.defineProperty(thread, "scrollHeight", { value: options.scrollHeight ?? 2_000 }); + pane.append(thread); + return thread; +} + +function createNativeShowEarlierPane(request: ReturnType, scrollTop = 0) { + const client = { request } as unknown as GatewayBrowserClient; + const result = createTestChatPane({ client, sessions: {} as SessionCapability }); + result.state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; + result.state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + const thread = appendChatThread(result.pane, { scrollTop }); + vi.spyOn(result.pane, "updateComplete", "get").mockReturnValue(Promise.resolve(true)); + const scrollToOffset = vi.spyOn(result.pane.transcript, "scrollToOffset"); + return { ...result, scrollToOffset, thread }; +} + describe("chat pane native history pagination", () => { it("resolves an unloaded reply preview through chat.message.get", async () => { const message = { @@ -249,6 +276,124 @@ describe("chat pane native history pagination", () => { expect(pane.hasOlderMessages()).toBe(false); }); + it("shows already-loaded earlier history one viewport up without requesting a page", async () => { + const request = vi.fn(); + const { pane, thread } = createNativeShowEarlierPane(request, 1_200); + + await pane.showEarlierMessages(); + + expect(thread.scrollTop).toBe(700); + expect(request).not.toHaveBeenCalled(); + }); + + it("loads at the top through the canonical path and reveals the prepended window", async () => { + const request = vi.fn(async () => ({ + messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], + hasMore: true, + nextOffset: 4, + totalMessages: 6, + })); + const { pane, scrollToOffset, state } = createNativeShowEarlierPane(request); + + await pane.showEarlierMessages(); + + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: state.sessionKey, + limit: 100, + offset: 2, + }); + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); + expect(scrollToOffset).toHaveBeenCalledWith(0); + expect(pane.transcriptScrollTop).toBe(0); + expect(pane.historyObserverArmed).toBe(false); + expect(pane.historyAutoLoadBlocked).toBe(true); + }); + + it("reveals a final catalog page even when its cursor is exhausted", async () => { + const request = vi.fn(async () => ({ + hostId: "gateway:local", + threadId: "thread-1", + items: [{ id: "u1", type: "userMessage", text: "oldest catalog message" }], + })); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const key = "catalog:claude:gateway%3Alocal:thread-1"; + state.sessionKey = key; + pane.sessionKey = key; + pane.catalogCursor = "final-page"; + appendChatThread(pane); + vi.spyOn(pane, "updateComplete", "get").mockReturnValue(Promise.resolve(true)); + const scrollToOffset = vi.spyOn(pane.transcript, "scrollToOffset"); + + await pane.showEarlierMessages(); + + expect(request).toHaveBeenCalledWith( + "sessions.catalog.read", + expect.objectContaining({ cursor: "final-page" }), + ); + expect(pane.catalogMessages).toHaveLength(1); + expect(pane.catalogCursor).toBeUndefined(); + expect(scrollToOffset).toHaveBeenCalledWith(0); + }); + + it("keeps the viewport and pagination retryable when the older load fails", async () => { + const request = vi.fn(async () => { + throw new Error("history unavailable"); + }); + const { pane, scrollToOffset, state, thread } = createNativeShowEarlierPane(request); + + await pane.showEarlierMessages(); + + expect(thread.scrollTop).toBe(0); + expect(state.chatHistoryPagination).toMatchObject({ hasMore: true }); + expect(state.lastError).toBe("history unavailable"); + expect(scrollToOffset).not.toHaveBeenCalled(); + }); + + it("joins an in-flight canonical load before revealing its earlier window", async () => { + const deferred = createDeferred<{ + messages: unknown[]; + hasMore: boolean; + totalMessages: number; + }>(); + const request = vi.fn(() => deferred.promise); + const { pane, scrollToOffset } = createNativeShowEarlierPane(request); + + const automaticLoad = pane.loadOlderMessages(); + const manualNavigation = pane.showEarlierMessages(); + deferred.resolve({ + messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], + hasMore: false, + totalMessages: 4, + }); + await Promise.all([automaticLoad, manualNavigation]); + + expect(request).toHaveBeenCalledOnce(); + expect(scrollToOffset).toHaveBeenCalledOnce(); + expect(scrollToOffset).toHaveBeenCalledWith(0); + }); + + it("does not navigate a replacement session after an older load settles", async () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatHistoryPagination = { hasMore: true, nextOffset: 2 }; + appendChatThread(pane); + const loaded = createDeferred(); + const committed = createDeferred(); + vi.spyOn(pane, "loadOlderMessages").mockReturnValue(loaded.promise); + vi.spyOn(pane, "updateComplete", "get").mockReturnValue(committed.promise); + const scrollToOffset = vi.spyOn(pane.transcript, "scrollToOffset"); + + const navigation = pane.showEarlierMessages(); + loaded.resolve(true); + await Promise.resolve(); + state.sessionKey = "agent:main:replacement"; + committed.resolve(true); + await navigation; + + expect(scrollToOffset).not.toHaveBeenCalled(); + }); + it("auto-loads a visible sentinel when the initial tail is not scrollable", async () => { const request = vi.fn(async () => ({ messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], diff --git a/ui/src/pages/chat/chat-pane-history.ts b/ui/src/pages/chat/chat-pane-history.ts index 544b4bd3e0b2..830aac346ec0 100644 --- a/ui/src/pages/chat/chat-pane-history.ts +++ b/ui/src/pages/chat/chat-pane-history.ts @@ -1,12 +1,8 @@ -import type { - ChatMessageGetResult, - SessionsCatalogContinueResult, -} from "../../../../packages/gateway-protocol/src/index.js"; +import type { SessionsCatalogContinueResult } from "../../../../packages/gateway-protocol/src/index.js"; import { COMMAND_PALETTE_TARGET_EVENT, type CommandPaletteTargetDetail, } from "../../components/command-palette-contract.ts"; -import { t } from "../../i18n/index.ts"; import { formatUiError } from "../../lib/format-error.ts"; import { announceCatalogSessionContinued, @@ -30,7 +26,7 @@ import { rewindChatHistory, switchChatHistoryBranch, } from "./chat-history.ts"; -import { ChatPaneSession } from "./chat-pane-session.ts"; +import { ChatPaneReplyNavigation } from "./chat-pane-reply-navigation.ts"; import { CHAT_HISTORY_BOOTSTRAP_PAGE_LIMIT, CHAT_HISTORY_INTENT_EDGE_PX, @@ -40,9 +36,7 @@ import { clearPaneSessionHandoff, preparePaneSessionHandoff, } from "./chat-pane-shared.ts"; -import type { ChatPageHost } from "./chat-state-host.ts"; import { resolveChatAgentId } from "./chat-state-route.ts"; -import { persistedMessageEntryId } from "./chat-thread.ts"; import { persistChatComposerState } from "./composer-persistence.ts"; import { captureChatSessionScrollPosition, @@ -50,175 +44,9 @@ import { scheduleChatScroll, } from "./scroll.ts"; -export abstract class ChatPaneHistory extends ChatPaneSession { +export abstract class ChatPaneHistory extends ChatPaneReplyNavigation { private activeCatalogContinuation: symbol | null = null; private activeOlderLoad: Promise | null = null; - private activeReplyNavigation: symbol | null = null; - private replyNavigationSessionKey: string | null = null; - protected replyNavigationId: string | null = null; - protected replyMessageRevision = 0; - private readonly replyMessages = new Map< - string, - { client: object; settled?: boolean; message?: unknown } - >(); - - protected readonly readReplyMessage = (messageId: string): unknown => { - const state = this.state; - if (!state) { - return undefined; - } - return this.replyMessages.get(this.replyMessageCacheKey(state.sessionKey, messageId))?.message; - }; - - protected readonly requestReplyMessage = (messageId: string): void => { - void this.loadReplyMessage(messageId); - }; - - protected readonly openReplyMessage = (messageId: string): void => { - void this.navigateToReplyMessage(messageId); - }; - - private replyMessageCacheKey(sessionKey: string, messageId: string): string { - const state = this.state; - const agentId = state ? scopedAgentParamsForSession(state, sessionKey).agentId : undefined; - return `${sessionKey}\u0000${agentId ?? ""}\u0000${messageId}`; - } - - private async loadReplyMessage(messageId: string): Promise { - const scope = this.captureConnectionScope(); - if (!scope || parseCatalogSessionKey(scope.state.sessionKey)) { - return; - } - const sessionKey = scope.state.sessionKey; - const agentId = scopedAgentParamsForSession(scope.state, sessionKey).agentId; - const cacheKey = this.replyMessageCacheKey(sessionKey, messageId); - const cached = this.replyMessages.get(cacheKey); - if (cached && (cached.client === scope.client || cached.settled)) { - return; - } - while (this.replyMessages.size >= 256) { - this.replyMessages.delete(this.replyMessages.keys().next().value!); - } - this.replyMessages.set(cacheKey, { client: scope.client }); - try { - const result = await scope.client.request("chat.message.get", { - sessionKey, - ...(agentId ? { agentId } : {}), - messageId, - maxChars: 500, - }); - const pending = this.replyMessages.get(cacheKey); - if (pending?.client !== scope.client || pending.settled) { - return; - } - this.replyMessages.set( - cacheKey, - result.ok && result.message - ? { client: scope.client, settled: true, message: result.message } - : { client: scope.client, settled: true }, - ); - } catch { - const pending = this.replyMessages.get(cacheKey); - if (pending?.client !== scope.client || pending.settled) { - return; - } - this.replyMessages.delete(cacheKey); - } - this.replyMessageRevision += 1; - if ( - this.isConnectionScopeCurrent(scope) && - areUiSessionKeysEquivalent(scope.state.sessionKey, sessionKey) - ) { - this.requestUpdate(); - } - } - - private replyNavigationIsCurrent( - navigation: symbol, - state: ChatPageHost, - sessionKey: string, - sessionId: string, - ): boolean { - return ( - this.activeReplyNavigation === navigation && - this.state === state && - areUiSessionKeysEquivalent(state.sessionKey, sessionKey) && - (!sessionId || state.currentSessionId === sessionId) - ); - } - - protected currentReplyNavigationId(sessionKey: string): string | null { - return this.replyNavigationSessionKey && - areUiSessionKeysEquivalent(this.replyNavigationSessionKey, sessionKey) - ? this.replyNavigationId - : null; - } - - protected currentReplyMessageAccess(sessionKey: string) { - return { - revision: this.replyMessageRevision, - navigationId: this.currentReplyNavigationId(sessionKey), - read: this.readReplyMessage, - request: this.requestReplyMessage, - open: this.openReplyMessage, - }; - } - - private async navigateToReplyMessage(messageId: string): Promise { - const state = this.state; - if (!state || parseCatalogSessionKey(state.sessionKey)) { - return; - } - const sessionKey = state.sessionKey; - const sessionId = state.currentSessionId?.trim() ?? ""; - const navigation = Symbol("reply-navigation"); - this.activeReplyNavigation = navigation; - this.replyNavigationSessionKey = sessionKey; - this.replyNavigationId = messageId; - this.requestUpdate(); - try { - while ( - !state.chatMessages.some((message) => persistedMessageEntryId(message) === messageId) - ) { - if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { - return; - } - if (!state.chatHistoryPagination?.hasMore) { - if (this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { - state.lastError = t("chat.messages.originalUnavailable"); - state.requestUpdate?.(); - } - return; - } - const loaded = await this.loadOlderMessages(); - if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { - return; - } - if (!loaded) { - if (!state.chatHistoryPagination?.hasMore && !state.lastError) { - state.lastError = t("chat.messages.originalUnavailable"); - state.requestUpdate?.(); - } - return; - } - } - if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { - return; - } - this.requestUpdate(); - await this.updateComplete; - if (this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { - this.transcript.revealMessage(messageId); - } - } finally { - if (this.activeReplyNavigation === navigation) { - this.activeReplyNavigation = null; - this.replyNavigationSessionKey = null; - this.replyNavigationId = null; - this.requestUpdate(); - } - } - } protected hasOlderMessages(): boolean { const state = this.state; @@ -239,9 +67,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession { protected resetOlderMessagesViewport(): void { this.olderLoadGeneration += 1; this.activeOlderLoad = null; - this.activeReplyNavigation = null; - this.replyNavigationSessionKey = null; - this.replyNavigationId = null; + this.resetReplyNavigation(); this.loadingOlder = false; this.historyObserverArmed = false; this.historyAutoLoadBlocked = false; @@ -441,6 +267,40 @@ export abstract class ChatPaneHistory extends ChatPaneSession { this.syncHistoryObserver(); } + protected async showEarlierMessages(): Promise { + const state = this.state; + const root = this.querySelector(".chat-thread"); + if (!state || !root) { + return; + } + if (root.scrollTop > CHAT_HISTORY_INTENT_EDGE_PX) { + const nextScrollTop = Math.max(0, root.scrollTop - root.clientHeight); + // Keep the observer's intent tracker aligned so this explicit page-up + // cannot masquerade as a user scroll and trigger an older-page load. + this.transcriptScrollTop = nextScrollTop; + root.scrollTop = nextScrollTop; + return; + } + const sessionKey = state.sessionKey; + const sessionStillCurrent = () => + this.state === state && areUiSessionKeysEquivalent(state.sessionKey, sessionKey); + const loaded = await this.loadOlderMessages(); + if (!loaded || !sessionStillCurrent()) { + return; + } + await this.updateComplete; + if (!sessionStillCurrent()) { + return; + } + // The explicit reveal can leave the sentinel visible. Disarm it before the + // programmatic jump so one click cannot chain another automatic page load. + this.transcriptScrollTop = 0; + this.historyObserverArmed = false; + this.historyAutoLoadBlocked = this.hasOlderMessages(); + this.clearHistoryObserver(); + this.transcript.scrollToOffset(0); + } + protected async loadOlderMessages(): Promise { if (this.activeOlderLoad) { return this.activeOlderLoad; @@ -468,7 +328,9 @@ export abstract class ChatPaneHistory extends ChatPaneSession { let prepended = false; try { if (catalogKey) { - prepended = await this.loadCatalogSession(catalogKey, true); + const previousCount = this.catalogMessages.length; + const progressed = await this.loadCatalogSession(catalogKey, true); + prepended = progressed || this.catalogMessages.length > previousCount; } else { const pagination = state.chatHistoryPagination; if (!pagination?.hasMore) { diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index ece017ff2b56..37f051bda2d5 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -283,6 +283,9 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { }); const attachmentReads = this.chatState.attachmentReads; const attachmentReadSignal = attachmentReads.readSignal; + const historyHasMore = catalogKey + ? Boolean(this.catalogCursor) + : state.chatHistoryPagination?.hasMore === true; const sessionActionCallbacks = createChatPaneSessionActionCallbacks({ getSnapshot: () => this.context.gateway.snapshot, hasLocalRun: () => Boolean(state.chatRunId), @@ -334,9 +337,11 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { onGatewayQuestionSkip: (id) => cancelQuestionPrompt(this.questionPromptState, id), messages: catalogKey ? this.catalogMessages : state.chatMessages, historyPagination: - catalogKey || state.chatHistoryPagination?.hasMore || this.loadingOlder + historyHasMore || this.loadingOlder ? { + hasMore: historyHasMore, loading: this.loadingOlder, + onShowEarlier: () => void this.showEarlierMessages(), } : undefined, toolMessages: catalogKey ? [] : state.chatToolMessages, diff --git a/ui/src/pages/chat/chat-pane-reply-navigation.ts b/ui/src/pages/chat/chat-pane-reply-navigation.ts new file mode 100644 index 000000000000..541e5389f2b3 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-reply-navigation.ts @@ -0,0 +1,183 @@ +import type { ChatMessageGetResult } from "../../../../packages/gateway-protocol/src/index.js"; +import { t } from "../../i18n/index.ts"; +import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts"; +import { scopedAgentParamsForSession } from "../../lib/sessions/index.ts"; +import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; +import { ChatPaneSession } from "./chat-pane-session.ts"; +import type { ChatPageHost } from "./chat-state-host.ts"; +import { persistedMessageEntryId } from "./chat-thread.ts"; + +export abstract class ChatPaneReplyNavigation extends ChatPaneSession { + private activeReplyNavigation: symbol | null = null; + private replyNavigationSessionKey: string | null = null; + protected replyNavigationId: string | null = null; + protected replyMessageRevision = 0; + private readonly replyMessages = new Map< + string, + { client: object; settled?: boolean; message?: unknown } + >(); + + protected abstract loadOlderMessages(): Promise; + + protected readonly readReplyMessage = (messageId: string): unknown => { + const state = this.state; + if (!state) { + return undefined; + } + return this.replyMessages.get(this.replyMessageCacheKey(state.sessionKey, messageId))?.message; + }; + + protected readonly requestReplyMessage = (messageId: string): void => { + void this.loadReplyMessage(messageId); + }; + + protected readonly openReplyMessage = (messageId: string): void => { + void this.navigateToReplyMessage(messageId); + }; + + private replyMessageCacheKey(sessionKey: string, messageId: string): string { + const state = this.state; + const agentId = state ? scopedAgentParamsForSession(state, sessionKey).agentId : undefined; + return `${sessionKey}\u0000${agentId ?? ""}\u0000${messageId}`; + } + + private async loadReplyMessage(messageId: string): Promise { + const scope = this.captureConnectionScope(); + if (!scope || parseCatalogSessionKey(scope.state.sessionKey)) { + return; + } + const sessionKey = scope.state.sessionKey; + const agentId = scopedAgentParamsForSession(scope.state, sessionKey).agentId; + const cacheKey = this.replyMessageCacheKey(sessionKey, messageId); + const cached = this.replyMessages.get(cacheKey); + if (cached && (cached.client === scope.client || cached.settled)) { + return; + } + while (this.replyMessages.size >= 256) { + this.replyMessages.delete(this.replyMessages.keys().next().value!); + } + this.replyMessages.set(cacheKey, { client: scope.client }); + try { + const result = await scope.client.request("chat.message.get", { + sessionKey, + ...(agentId ? { agentId } : {}), + messageId, + maxChars: 500, + }); + const pending = this.replyMessages.get(cacheKey); + if (pending?.client !== scope.client || pending.settled) { + return; + } + this.replyMessages.set( + cacheKey, + result.ok && result.message + ? { client: scope.client, settled: true, message: result.message } + : { client: scope.client, settled: true }, + ); + } catch { + const pending = this.replyMessages.get(cacheKey); + if (pending?.client !== scope.client || pending.settled) { + return; + } + this.replyMessages.delete(cacheKey); + } + this.replyMessageRevision += 1; + if ( + this.isConnectionScopeCurrent(scope) && + areUiSessionKeysEquivalent(scope.state.sessionKey, sessionKey) + ) { + this.requestUpdate(); + } + } + + private replyNavigationIsCurrent( + navigation: symbol, + state: ChatPageHost, + sessionKey: string, + sessionId: string, + ): boolean { + return ( + this.activeReplyNavigation === navigation && + this.state === state && + areUiSessionKeysEquivalent(state.sessionKey, sessionKey) && + (!sessionId || state.currentSessionId === sessionId) + ); + } + + protected currentReplyNavigationId(sessionKey: string): string | null { + return this.replyNavigationSessionKey && + areUiSessionKeysEquivalent(this.replyNavigationSessionKey, sessionKey) + ? this.replyNavigationId + : null; + } + + protected currentReplyMessageAccess(sessionKey: string) { + return { + revision: this.replyMessageRevision, + navigationId: this.currentReplyNavigationId(sessionKey), + read: this.readReplyMessage, + request: this.requestReplyMessage, + open: this.openReplyMessage, + }; + } + + protected resetReplyNavigation(): void { + this.activeReplyNavigation = null; + this.replyNavigationSessionKey = null; + this.replyNavigationId = null; + } + + private async navigateToReplyMessage(messageId: string): Promise { + const state = this.state; + if (!state || parseCatalogSessionKey(state.sessionKey)) { + return; + } + const sessionKey = state.sessionKey; + const sessionId = state.currentSessionId?.trim() ?? ""; + const navigation = Symbol("reply-navigation"); + this.activeReplyNavigation = navigation; + this.replyNavigationSessionKey = sessionKey; + this.replyNavigationId = messageId; + this.requestUpdate(); + try { + while ( + !state.chatMessages.some((message) => persistedMessageEntryId(message) === messageId) + ) { + if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + return; + } + if (!state.chatHistoryPagination?.hasMore) { + if (this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + state.lastError = t("chat.messages.originalUnavailable"); + state.requestUpdate?.(); + } + return; + } + const loaded = await this.loadOlderMessages(); + if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + return; + } + if (!loaded) { + if (!state.chatHistoryPagination?.hasMore && !state.lastError) { + state.lastError = t("chat.messages.originalUnavailable"); + state.requestUpdate?.(); + } + return; + } + } + if (!this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + return; + } + this.requestUpdate(); + await this.updateComplete; + if (this.replyNavigationIsCurrent(navigation, state, sessionKey, sessionId)) { + this.transcript.revealMessage(messageId); + } + } finally { + if (this.activeReplyNavigation === navigation) { + this.resetReplyNavigation(); + this.requestUpdate(); + } + } + } +} diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 5e91f2d9c0a2..021ab21216ed 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -1134,10 +1134,61 @@ describe("chat conversation width", () => { }); describe("chat history pagination", () => { + it("keeps earlier history discoverable and retryable until the transcript is exhausted", () => { + const onShowEarlier = vi.fn(); + const container = document.createElement("div"); + renderChatInto(container, { + historyPagination: { hasMore: true, loading: false, onShowEarlier }, + }); + + const button = requireElement( + container, + ".chat-history-available", + "earlier history action", + ) as HTMLButtonElement; + expect(button.textContent).toContain("Earlier history available"); + expect(button.textContent).toContain("Show earlier"); + expect(button.closest(".chat-main__conversation")).not.toBeNull(); + expect(button.closest(".chat-thread")).toBeNull(); + button.click(); + expect(onShowEarlier).toHaveBeenCalledOnce(); + + renderChatInto(container, { + historyPagination: { hasMore: true, loading: true, onShowEarlier }, + }); + const loadingButton = requireElement( + container, + ".chat-history-available", + "loading earlier history action", + ) as HTMLButtonElement; + expect(loadingButton.textContent).toContain("Loading earlier history"); + expect(loadingButton.querySelector(".session-run-spinner")).not.toBeNull(); + expect(loadingButton.disabled).toBe(false); + loadingButton.click(); + expect(onShowEarlier).toHaveBeenCalledTimes(2); + + renderChatInto(container, { + historyPagination: { hasMore: true, loading: false, onShowEarlier }, + }); + const retryButton = requireElement( + container, + ".chat-history-available", + "retry earlier history action", + ) as HTMLButtonElement; + retryButton.click(); + expect(onShowEarlier).toHaveBeenCalledTimes(3); + + renderChatInto(container); + expect(container.querySelector(".chat-history-available")).toBeNull(); + expect(container.querySelector(".chat-history-sentinel")).toBeNull(); + }); + it("renders the auto-load sentinel and a spinner while older history loads", () => { const container = renderChatView({ historyPagination: { + hasMore: true, loading: true, + onShowEarlier: vi.fn(), }, }); const threadInner = requireElement(container, ".chat-thread-inner", "chat thread inner"); @@ -1155,7 +1206,9 @@ describe("chat history pagination", () => { const onHistoryIntent = vi.fn(); const container = renderChatView({ historyPagination: { + hasMore: true, loading: false, + onShowEarlier: vi.fn(), }, onHistoryIntent, }); @@ -1173,7 +1226,9 @@ describe("chat history pagination", () => { try { renderChatView({ historyPagination: { + hasMore: true, loading: false, + onShowEarlier: vi.fn(), }, onHistoryIntent: vi.fn(), }); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 18ebeef71959..91797ac66261 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -51,7 +51,7 @@ import type { SidebarContent, SidebarFullMessageLoader } from "./components/chat import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts"; import { renderChatTaskSuggestionTray } from "./components/chat-task-suggestions.ts"; import type { ChatTaskSuggestionTrayProps } from "./components/chat-task-suggestions.ts"; -import type { ReplyMessageAccess } from "./components/chat-thread-interactions.ts"; +import type { ChatThreadProps, ReplyMessageAccess } from "./components/chat-thread-interactions.ts"; import { renderTranscriptSearch, toggleTranscriptSearch, @@ -101,7 +101,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps & ) => void | Promise; onGatewayQuestionSkip?: (id: string) => void | Promise; messages: unknown[]; - historyPagination?: { loading: boolean }; + historyPagination?: ChatThreadProps["historyPagination"]; toolMessages: unknown[]; streamSegments: ChatStreamSegment[]; stream: string | null; @@ -452,6 +452,27 @@ export function renderChat(props: ChatProps) { ` : nothing; + const earlierHistoryButton = props.historyPagination?.hasMore + ? html` + + ` + : nothing; return html`
- ${thread} ${scrollToBottomButton} + ${thread} ${earlierHistoryButton} ${scrollToBottomButton} ${props.inlineApproval && props.onApprovalDecision ? html`
${renderExecApprovalCard({ diff --git a/ui/src/pages/chat/components/chat-thread-interactions.ts b/ui/src/pages/chat/components/chat-thread-interactions.ts index c24c3ed76911..dae609f3d853 100644 --- a/ui/src/pages/chat/components/chat-thread-interactions.ts +++ b/ui/src/pages/chat/components/chat-thread-interactions.ts @@ -60,7 +60,11 @@ export type ChatThreadProps = { boardProvider?: BoardProvider; announceTranscript?: boolean; loading: boolean; - historyPagination?: { loading: boolean }; + historyPagination?: { + hasMore: boolean; + loading: boolean; + onShowEarlier: () => void; + }; messages: unknown[]; toolMessages: unknown[]; streamSegments: ChatStreamSegment[]; diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 3fd6d71846f3..6ea65fad5ce9 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -458,6 +458,23 @@ openclaw-chat-page { font-size: 12px; } +.chat-history-available { + position: absolute; + z-index: 10; + top: 10px; + left: 50%; + max-width: calc(100% - 2 * var(--chat-thread-gutter)); + border-radius: var(--radius-full); + background: var(--panel-strong); + box-shadow: var(--shadow-sm); + color: var(--muted); + transform: translateX(-50%); +} + +.chat-history-available strong { + color: var(--text); +} + /* The zero-height anchor keeps the affordance out of the flex flow so showing it never shrinks the transcript or moves the composer. */ .chat-scroll-to-bottom-wrap {