mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
6bddfed530
commit
06fa48099e
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user