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
This commit is contained in:
Peter Steinberger
2026-08-16 16:04:30 -07:00
committed by GitHub
parent 6bddfed530
commit 06fa48099e
11 changed files with 787 additions and 428 deletions
+94 -244
View File
@@ -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<VisibleVirtualRow> {
return thread.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const row = Array.from(
element.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<VirtualRowPaintResult> {
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<HTMLButtonElement>(".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<void>((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();
});
@@ -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<VisibleVirtualRow> {
return thread.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const row = Array.from(
element.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<VirtualRowPaintResult> {
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,
});
}
+3
View File
@@ -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: {
+145
View File
@@ -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<boolean>;
showEarlierMessages: () => Promise<void>;
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<typeof vi.fn>, 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<boolean>();
const committed = createDeferred<boolean>();
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)],
+41 -179
View File
@@ -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<boolean> | 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<void> {
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<ChatMessageGetResult>("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<void> {
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<void> {
const state = this.state;
const root = this.querySelector<HTMLElement>(".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<boolean> {
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) {
+6 -1
View File
@@ -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,
@@ -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<boolean>;
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<void> {
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<ChatMessageGetResult>("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<void> {
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();
}
}
}
}
+55
View File
@@ -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(),
});
+24 -3
View File
@@ -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<void>;
onGatewayQuestionSkip?: (id: string) => void | Promise<void>;
messages: unknown[];
historyPagination?: { loading: boolean };
historyPagination?: ChatThreadProps["historyPagination"];
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
stream: string | null;
@@ -452,6 +452,27 @@ export function renderChat(props: ChatProps) {
</div>
`
: nothing;
const earlierHistoryButton = props.historyPagination?.hasMore
? html`
<button
class="btn btn--sm chat-history-available"
type="button"
aria-busy=${props.historyPagination.loading ? "true" : "false"}
aria-label=${t("chat.thread.showEarlier")}
@click=${props.historyPagination.onShowEarlier}
>
${props.historyPagination.loading
? html`<span class="session-run-spinner" aria-hidden="true"></span>`
: nothing}
<span role="status">
${props.historyPagination.loading
? t("chat.thread.loadingEarlier")
: t("chat.thread.earlierHistoryAvailable")}
</span>
<strong>${t("chat.thread.showEarlier")}</strong>
</button>
`
: nothing;
return html`
<section
@@ -506,7 +527,7 @@ export function renderChat(props: ChatProps) {
${props.header ?? nothing} ${renderChatViewNotices(props)}
${renderTranscriptSearch(props.paneId, requestUpdate)}
<div class="chat-main__conversation">
${thread} ${scrollToBottomButton}
${thread} ${earlierHistoryButton} ${scrollToBottomButton}
${props.inlineApproval && props.onApprovalDecision
? html`<div class="chat-inline-approval">
${renderExecApprovalCard({
@@ -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[];
+17
View File
@@ -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 {