fix: remove staged chat transcript rendering

This commit is contained in:
Shakker
2026-07-14 20:13:48 +01:00
committed by Shakker
parent 8121639e8d
commit 8da6548cce
13 changed files with 207 additions and 823 deletions

View File

@@ -57,9 +57,6 @@ export type ChatItem =
| { kind: "stream"; key: string; text: string; startedAt: number; isStreaming: boolean }
| { kind: "reading-indicator"; key: string; startedAt: number };
export const CHAT_HISTORY_RENDER_LIMIT = 100;
export const CHAT_HISTORY_RENDER_CHAR_BUDGET = 240_000;
export type ChatStreamSegment = {
text: string;
ts: number;

View File

@@ -48,6 +48,7 @@ type TestChatPane = HTMLElement & {
index: number,
) => Record<string, unknown> | null;
handleTranscriptScroll: (event: Event) => void;
handleTranscriptHistoryIntent: (event: Event) => void;
historyAutoLoadBlocked: boolean;
transcriptScrollTop: number | null;
syncHistoryObserver: () => void;
@@ -57,12 +58,15 @@ type TestChatPane = HTMLElement & {
loadOlderMessages: () => Promise<void>;
hasOlderMessages: () => boolean;
restoreHistoryAnchor: () => void;
pendingHistoryAnchor: { sessionKey: string; scrollHeight: number; scrollTop: number } | null;
pendingHistoryAnchor: {
sessionKey: string;
element: HTMLElement;
viewportTop: number;
} | null;
loadingOlder: boolean;
catalogCursor: string | undefined;
olderCursorsSeen: Set<string>;
olderOffsetsSeen: Set<number>;
nativeHistoryExpanded: boolean;
renderPaneHeader: (
workspace: ReturnType<typeof createSessionWorkspaceProps>,
tasks: ReturnType<typeof createBackgroundTasksProps>,
@@ -673,26 +677,28 @@ describe("chat pane catalog session lifecycle", () => {
expect(state.handleChatScroll).toHaveBeenCalledTimes(2);
});
it("re-arms a blocked auto-load when the manual fallback is clicked", async () => {
it("loads a blocked unscrollable transcript from renewed upward intent", async () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
// A short (non-scrollable) thread cannot emit a scroll event, so a blocked
// auto-load must be recoverable through the fallback's loadOlderMessages call.
state.sessionKey = "catalog:claude:gateway%3Alocal:thread-1";
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
pane.historyAutoLoadBlocked = true;
pane.loadCatalogSession = vi.fn(async () => false);
pane.hasOlderMessages = vi.fn(() => true);
pane.loadOlderMessages = vi.fn(async () => undefined);
vi.stubGlobal("IntersectionObserver", undefined);
const thread = document.createElement("div");
const event = new WheelEvent("wheel", { deltaY: -1 });
Object.defineProperty(event, "currentTarget", { value: thread });
await pane.loadOlderMessages();
pane.handleTranscriptHistoryIntent(event);
pane.handleTranscriptHistoryIntent(event);
await Promise.resolve();
// loadOlderMessages clears the block on entry, so the retry is not stranded.
expect(pane.loadCatalogSession).toHaveBeenCalledOnce();
expect(pane.historyAutoLoadBlocked).toBe(true);
expect(pane.loadOlderMessages).toHaveBeenCalledOnce();
expect(pane.historyAutoLoadBlocked).toBe(false);
});
});
describe("chat pane native history pagination", () => {
it("renders every row from a complete imported snapshot", () => {
it("does not request older rows from a complete imported snapshot", () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
state.chatHistoryPagination = {
@@ -702,7 +708,6 @@ describe("chat pane native history pagination", () => {
};
expect(pane.hasOlderMessages()).toBe(false);
expect(pane.nativeHistoryExpanded).toBe(true);
});
it("auto-loads a visible sentinel when the initial tail is not scrollable", async () => {
@@ -841,11 +846,25 @@ describe("chat pane native history pagination", () => {
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)];
state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 };
let scrollHeight = 600;
const thread = document.createElement("div");
thread.className = "chat-thread";
thread.scrollTop = 40;
Object.defineProperty(thread, "scrollHeight", { get: () => scrollHeight });
let rowTop = 20;
thread.getBoundingClientRect = () =>
({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) as DOMRect;
const row = document.createElement("div");
row.dataset.chatRowKey = "row-3";
Object.defineProperty(row, "isConnected", { value: true });
row.getBoundingClientRect = () =>
({
top: rowTop,
bottom: rowTop + 20,
left: 0,
right: 100,
width: 100,
height: 20,
}) as DOMRect;
thread.append(row);
pane.append(thread);
await pane.loadOlderMessages();
@@ -859,10 +878,10 @@ describe("chat pane native history pagination", () => {
expect(state.chatHistoryPagination).toEqual({ hasMore: false, totalMessages: 4 });
expect(pane.pendingHistoryAnchor).toEqual({
sessionKey: state.sessionKey,
scrollHeight: 600,
scrollTop: 40,
element: row,
viewportTop: 20,
});
scrollHeight = 900;
rowTop = 320;
pane.restoreHistoryAnchor();
expect(thread.scrollTop).toBe(340);
expect(pane.hasOlderMessages()).toBe(false);
@@ -894,6 +913,33 @@ describe("chat pane native history pagination", () => {
expect(pane.loadingOlder).toBe(false);
});
it("does not double-correct when native scroll anchoring preserved the visible row", () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
const thread = document.createElement("div");
thread.className = "chat-thread";
thread.scrollTop = 40;
thread.getBoundingClientRect = () =>
({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) as DOMRect;
const row = document.createElement("div");
row.dataset.chatRowKey = "row-3";
Object.defineProperty(row, "isConnected", { value: true });
row.getBoundingClientRect = () =>
({ top: 20, bottom: 40, left: 0, right: 100, width: 100, height: 20 }) as DOMRect;
thread.append(row);
pane.append(thread);
pane.pendingHistoryAnchor = {
sessionKey: state.sessionKey,
element: row,
viewportTop: 20,
};
pane.restoreHistoryAnchor();
expect(thread.scrollTop).toBe(40);
expect(pane.pendingHistoryAnchor).toBeNull();
});
it("refreshes the tail instead of mixing an older page from a replacement session", async () => {
const request = vi
.fn()

View File

@@ -147,10 +147,13 @@ type ChatPageContext = ApplicationContext;
type PaneSessionChangeOptions = { replace?: boolean };
type ChatHistoryAnchor = {
sessionKey: string;
scrollHeight: number;
scrollTop: number;
element: HTMLElement;
viewportTop: number;
};
const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500;
const CHAT_HISTORY_INTENT_EDGE_PX = 300;
const CHAT_HISTORY_INTENT_IDLE_MS = 200;
const CHAT_HISTORY_UPWARD_KEYS = new Set(["ArrowUp", "PageUp", "Home"]);
function catalogRawString(raw: unknown, keys: readonly string[]): string | null {
const record = catalogRawRecord(raw);
@@ -309,10 +312,11 @@ class ChatPane extends OpenClawLightDomElement {
private historyObserverArmed = false;
private historyAutoLoadBlocked = false;
private historyBootstrapPagesLoaded = 0;
private historyIntentConsumed = false;
private historyIntentTimer: number | null = null;
private transcriptScrollTop: number | null = null;
private pendingHistoryAnchor: ChatHistoryAnchor | null = null;
private nativePaginationSnapshot: ChatHistoryPagination | null = null;
private nativeHistoryExpanded = false;
// Older cursors already requested this session. A provider that cycles cursors
// (c1 -> c2 -> c1) on empty/duplicate pages would otherwise loop forever, since
// the sentinel never scrolls out of view when nothing new renders.
@@ -924,7 +928,6 @@ class ChatPane extends OpenClawLightDomElement {
if (pagination !== this.nativePaginationSnapshot) {
this.nativePaginationSnapshot = pagination;
this.olderOffsetsSeen.clear();
this.nativeHistoryExpanded = !pagination.hasMore && pagination.completeSnapshot === true;
}
return pagination.hasMore && !state.chatLoading;
}
@@ -935,12 +938,16 @@ class ChatPane extends OpenClawLightDomElement {
this.historyObserverArmed = false;
this.historyAutoLoadBlocked = false;
this.historyBootstrapPagesLoaded = 0;
this.historyIntentConsumed = false;
if (this.historyIntentTimer !== null) {
window.clearTimeout(this.historyIntentTimer);
this.historyIntentTimer = null;
}
this.transcriptScrollTop = null;
this.pendingHistoryAnchor = null;
this.olderCursorsSeen.clear();
this.olderOffsetsSeen.clear();
this.nativePaginationSnapshot = null;
this.nativeHistoryExpanded = false;
this.historyObserver?.disconnect();
this.historyObserver = null;
}
@@ -953,17 +960,42 @@ class ChatPane extends OpenClawLightDomElement {
this.pendingHistoryAnchor = null;
const state = this.state;
const thread = this.querySelector<HTMLElement>(".chat-thread");
if (!state || !thread || state.sessionKey !== anchor.sessionKey) {
if (
!state ||
!thread ||
state.sessionKey !== anchor.sessionKey ||
!anchor.element.isConnected
) {
return;
}
thread.scrollTop = anchor.scrollTop + (thread.scrollHeight - anchor.scrollHeight);
// Chromium usually preserves this through native scroll anchoring. Measure
// the row relative to the viewport so this only corrects engines that did not.
const nextViewportTop =
anchor.element.getBoundingClientRect().top - thread.getBoundingClientRect().top;
const delta = nextViewportTop - anchor.viewportTop;
if (Math.abs(delta) > 1) {
thread.scrollTop += delta;
}
}
private currentHistoryAnchor(sessionKey: string): ChatHistoryAnchor | null {
const thread = this.querySelector<HTMLElement>(".chat-thread");
return thread
? { sessionKey, scrollHeight: thread.scrollHeight, scrollTop: thread.scrollTop }
: null;
if (!thread) {
return null;
}
const viewportRect = thread.getBoundingClientRect();
const rows = thread.querySelectorAll<HTMLElement>("[data-chat-row-key]");
for (const element of rows) {
const rect = element.getBoundingClientRect();
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
return {
sessionKey,
element,
viewportTop: rect.top - viewportRect.top,
};
}
}
return null;
}
private syncHistoryObserver(): void {
@@ -1014,7 +1046,7 @@ class ChatPane extends OpenClawLightDomElement {
if (bootstrap) {
this.historyBootstrapPagesLoaded += 1;
}
void this.loadOlderMessages("observer");
void this.loadOlderMessages();
}
},
{ root, rootMargin: "300px 0px 0px", threshold: 0 },
@@ -1037,14 +1069,16 @@ class ChatPane extends OpenClawLightDomElement {
!this.loadingOlder &&
root !== null &&
previousScrollTop !== null &&
root.scrollTop < previousScrollTop;
root.scrollTop < previousScrollTop &&
root.scrollTop <= CHAT_HISTORY_INTENT_EDGE_PX;
const newHistoryIntent = hasUpwardIntent && this.consumeHistoryIntent();
// A failed request or exhausted bootstrap stays disarmed until renewed
// upward intent, preventing request loops without stranding older history.
if (hasUpwardIntent && this.historyAutoLoadBlocked) {
if (newHistoryIntent && this.historyAutoLoadBlocked) {
this.historyAutoLoadBlocked = false;
this.historyObserverArmed = true;
this.syncHistoryObserver();
} else if (hasUpwardIntent && !this.historyObserverArmed) {
} else if (newHistoryIntent && !this.historyObserverArmed) {
this.historyObserverArmed = true;
this.syncHistoryObserver();
}
@@ -1053,16 +1087,52 @@ class ChatPane extends OpenClawLightDomElement {
this.state?.handleChatScroll(event);
}
private async loadOlderMessages(source: "manual" | "observer" = "manual"): Promise<void> {
private consumeHistoryIntent(): boolean {
if (this.historyIntentTimer !== null) {
window.clearTimeout(this.historyIntentTimer);
}
this.historyIntentTimer = window.setTimeout(() => {
this.historyIntentTimer = null;
this.historyIntentConsumed = false;
}, CHAT_HISTORY_INTENT_IDLE_MS);
if (this.historyIntentConsumed) {
return false;
}
this.historyIntentConsumed = true;
return true;
}
private handleTranscriptHistoryIntent(event: Event): void {
const root = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const upward =
(event instanceof WheelEvent && event.deltaY < 0) ||
(event instanceof KeyboardEvent && CHAT_HISTORY_UPWARD_KEYS.has(event.key));
if (
!root ||
!upward ||
root.scrollTop > CHAT_HISTORY_INTENT_EDGE_PX ||
this.loadingOlder ||
!this.hasOlderMessages() ||
!this.consumeHistoryIntent()
) {
return;
}
this.historyAutoLoadBlocked = false;
if (typeof IntersectionObserver !== "function") {
void this.loadOlderMessages();
return;
}
this.historyObserverArmed = true;
this.syncHistoryObserver();
}
private async loadOlderMessages(): Promise<void> {
const state = this.state;
const catalogKey = state ? parseCatalogSessionKey(state.sessionKey) : null;
if (!state || this.loadingOlder || !this.hasOlderMessages()) {
return;
}
const generation = ++this.olderLoadGeneration;
if (source === "manual") {
this.historyAutoLoadBlocked = false;
}
this.loadingOlder = true;
state.requestUpdate();
let prepended = false;
@@ -1103,9 +1173,6 @@ class ChatPane extends OpenClawLightDomElement {
const messages = Array.isArray(result.messages) ? result.messages : [];
const nextMessages = this.prependUniqueNativeMessages(messages, state.chatMessages);
const grew = nextMessages.length > state.chatMessages.length;
// Native scroll-back must render the loaded prefix together with the old
// viewport; the default tail-only DOM cap would hide every prepended page.
this.nativeHistoryExpanded ||= grew;
this.pendingHistoryAnchor = grew ? this.currentHistoryAnchor(state.sessionKey) : null;
state.chatMessages = nextMessages;
const appliedPagination: ChatHistoryPagination = exhausted
@@ -1891,21 +1958,10 @@ class ChatPane extends OpenClawLightDomElement {
compactionStatus: state.compactionStatus,
fallbackStatus: state.fallbackStatus,
messages: catalogKey ? this.catalogMessages : state.chatMessages,
renderAllLoadedHistory:
!catalogKey &&
(this.nativeHistoryExpanded ||
(state.chatHistoryPagination?.hasMore === false &&
state.chatHistoryPagination.completeSnapshot === true)),
historyPagination:
catalogKey || state.chatHistoryPagination?.hasMore || this.loadingOlder
? {
loading: this.loadingOlder,
// A non-scrollable thread cannot express renewed upward intent, so
// the button remains the recovery path after failure or bootstrap.
manualFallback:
this.hasOlderMessages() &&
(typeof IntersectionObserver !== "function" || this.historyAutoLoadBlocked),
onLoadOlder: () => void this.loadOlderMessages(),
}
: undefined,
sideChatTurns: catalogKey ? [] : state.chatSideChatTurns,
@@ -2018,6 +2074,7 @@ class ChatPane extends OpenClawLightDomElement {
void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false });
},
onChatScroll: (event) => this.handleTranscriptScroll(event),
onHistoryIntent: (event) => this.handleTranscriptHistoryIntent(event),
getDraft: () => state.chatMessage,
onDraftChange: state.handleChatDraftChange,
onRequestUpdate: state.requestUpdate,

View File

@@ -1613,7 +1613,7 @@ describe("buildCachedChatItems", () => {
expect(groups).toStrictEqual([]);
});
it("renders only the last 100 history messages and shows a hidden-count notice", () => {
it("renders all loaded history through one keyed row sequence", () => {
const items = buildCachedChatItems(
createProps({
messages: Array.from({ length: 105 }, (_, index) => ({
@@ -1626,57 +1626,12 @@ describe("buildCachedChatItems", () => {
const groups = items.filter((item) => item.kind === "group");
const noticeGroup = requireGroup(items[0]);
expect(noticeGroup.messages).toHaveLength(1);
const noticeMessage = messageRecord(noticeGroup);
expect(noticeMessage.role).toBe("system");
expect(noticeMessage.content).toBe("Showing last 100 messages (5 hidden).");
expect(groups).toHaveLength(101);
expect(messageRecord(groupAt(groups, 1)).content).toBe("message 5");
expect(groups.map((group) => messageRecord(group).content).at(-1)).toBe("message 104");
});
it("renders native history beyond the tail cap after scroll-back expands it", () => {
const items = buildCachedChatItems(
createProps({
allowExpandedHistoryRenderLimit: true,
historyRenderLimit: 140,
messages: Array.from({ length: 140 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index,
})),
}),
);
const groups = items.filter((item) => item.kind === "group");
expect(groups).toHaveLength(140);
expect(groups).toHaveLength(105);
expect(messageRecord(groupAt(groups, 0)).content).toBe("message 0");
expect(messageRecord(groupAt(groups, 139)).content).toBe("message 139");
});
it("honors a smaller history render window and preserves the hidden-count notice", () => {
const items = buildCachedChatItems(
createProps({
historyRenderLimit: 30,
messages: Array.from({ length: 105 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index,
})),
}),
);
const groups = items.filter((item) => item.kind === "group");
const noticeGroup = requireGroup(items[0]);
expect(messageRecord(noticeGroup).content).toBe("Showing last 30 messages (75 hidden).");
expect(groups).toHaveLength(31);
expect(messageRecord(groupAt(groups, 1)).content).toBe("message 75");
expect(groups.map((group) => messageRecord(group).content).at(-1)).toBe("message 104");
});
it("budgets rendered history by tool-result content size", () => {
it("does not truncate loaded history by raw content size", () => {
const largeOutput = "x".repeat(100_000);
const items = buildCachedChatItems(
createProps({
@@ -1693,14 +1648,12 @@ describe("buildCachedChatItems", () => {
})),
}),
);
const groups = items.filter((item) => item.kind === "group");
const noticeGroup = requireGroup(items[0]);
expect(messageRecord(noticeGroup).content).toBe("Showing last 2 messages (4 hidden).");
expect(groups).toHaveLength(2);
expect(groupAt(groups, 1).messages).toHaveLength(2);
expect(messageRecord(groupAt(groups, 1), 0).timestamp).toBe(4);
expect(messageRecord(groupAt(groups, 1), 1).timestamp).toBe(5);
expect(groups).toHaveLength(1);
expect(groupAt(groups, 0).messages).toHaveLength(6);
expect(messageRecord(groupAt(groups, 0), 0).timestamp).toBe(0);
expect(messageRecord(groupAt(groups, 0), 5).timestamp).toBe(5);
});
it("does not crash when history contains malformed entries", () => {
@@ -2265,43 +2218,6 @@ describe("buildCachedChatItems", () => {
expect(canvasBlocksIn(groupAt(groups, 3))).toHaveLength(1);
});
it("keeps a persisted App preview with its history-cutoff assistant", () => {
const groups = messageGroups({
messages: [
{ role: "user", content: "Show the App", timestamp: 1_000 },
mcpAppResult("mcp-app-cutoff", "call-cutoff", 1_001),
{ role: "assistant", content: "Done", timestamp: 1_002 },
],
toolMessages: [],
showToolCalls: false,
historyRenderLimit: 1,
});
const assistant = groups.find((group) => group.role === "assistant");
expect(assistant).toBeDefined();
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
});
it("does not expand a persisted preview across a cutoff user turn", () => {
const firstResult = {
...mcpAppResult("mcp-app-first", "call-first", 1_001),
timestamp: undefined,
};
const groups = messageGroups({
messages: [
{ role: "user", content: "First request", timestamp: 1_000 },
firstResult,
{ role: "user", content: "Second request", timestamp: 2_000 },
{ role: "assistant", content: "Second response", timestamp: 2_001 },
],
toolMessages: [{ ...firstResult }],
showToolCalls: false,
historyRenderLimit: 2,
});
expect(groups.flatMap((group) => canvasBlocksIn(group))).toStrictEqual([]);
});
it("does not lift generic view handles from non-canvas payloads", () => {
const groups = messageGroups({
messages: [

View File

@@ -11,10 +11,6 @@ import type {
NormalizedMessage,
ToolCard,
} from "../../lib/chat/chat-types.ts";
import {
CHAT_HISTORY_RENDER_CHAR_BUDGET,
CHAT_HISTORY_RENDER_LIMIT,
} from "../../lib/chat/chat-types.ts";
import {
streamSegmentHasItemId,
streamSegmentUsesAccumulatedText,
@@ -67,8 +63,6 @@ type BuildChatItemsProps = {
loading?: boolean;
searchOpen?: boolean;
searchQuery?: string;
historyRenderLimit?: number;
allowExpandedHistoryRenderLimit?: boolean;
};
type CachedChatItems = {
@@ -1011,9 +1005,7 @@ function rawMessageTimestamp(message: unknown): number | null {
function chatItemTimestamp(item: ChatItem): number | null {
switch (item.kind) {
case "message":
return item.key === "chat:history:notice"
? Number.NEGATIVE_INFINITY
: rawMessageTimestamp(item.message);
return rawMessageTimestamp(item.message);
case "divider":
return item.timestamp;
case "stream":
@@ -1087,175 +1079,8 @@ function sortChatItemsByVisibleTime(
.map(({ item }) => item);
}
type RawContentEstimateState = {
visited: WeakSet<object>;
nodes: number;
};
const RAW_CONTENT_ESTIMATE_MAX_DEPTH = 8;
const RAW_CONTENT_ESTIMATE_MAX_NODES = 400;
function addCapped(total: number, amount: number, limit: number): number {
return Math.min(limit, total + Math.max(0, amount));
}
function estimateRawContentChars(
value: unknown,
limit: number,
state: RawContentEstimateState,
depth = 0,
): number {
if (limit <= 0) {
return 0;
}
if (typeof value === "string") {
return Math.min(value.length, limit);
}
if (!value || typeof value !== "object") {
return 0;
}
if (depth >= RAW_CONTENT_ESTIMATE_MAX_DEPTH || state.nodes >= RAW_CONTENT_ESTIMATE_MAX_NODES) {
return 0;
}
if (state.visited.has(value)) {
return 0;
}
state.visited.add(value);
state.nodes += 1;
if (Array.isArray(value)) {
let chars = 0;
for (const item of value) {
chars = addCapped(
chars,
estimateRawContentChars(item, limit - chars, state, depth + 1),
limit,
);
if (chars >= limit) {
break;
}
}
return chars;
}
const record = value as Record<string, unknown>;
let chars = 0;
for (const key of ["text", "content", "args", "arguments", "input"] as const) {
chars = addCapped(
chars,
estimateRawContentChars(record[key], limit - chars, state, depth + 1),
limit,
);
if (chars >= limit) {
break;
}
}
return chars;
}
function estimateMessageRenderChars(message: unknown, limit: number): number {
const record = asRecord(message);
if (!record) {
return 1;
}
const state: RawContentEstimateState = { visited: new WeakSet<object>(), nodes: 0 };
let chars = 0;
for (const key of ["content", "text", "args", "arguments", "input"] as const) {
chars = addCapped(chars, estimateRawContentChars(record[key], limit - chars, state), limit);
if (chars >= limit) {
break;
}
}
return Math.max(chars, 1);
}
function isHiddenToolMessage(message: unknown, showToolCalls: boolean): boolean {
if (showToolCalls) {
return false;
}
return safeNormalizeMessage(message)?.role.toLowerCase() === "toolresult";
}
function countVisibleHistoryMessages(messages: unknown[], showToolCalls: boolean): number {
let count = 0;
for (const message of messages) {
if (!isHiddenToolMessage(message, showToolCalls)) {
count += 1;
}
}
return count;
}
function resolveHistoryRenderLimit(limit: number | undefined, allowExpanded = false): number {
if (typeof limit !== "number" || !Number.isFinite(limit)) {
return CHAT_HISTORY_RENDER_LIMIT;
}
const normalized = Math.max(1, Math.floor(limit));
return allowExpanded ? normalized : Math.min(CHAT_HISTORY_RENDER_LIMIT, normalized);
}
function resolveHistoryStartIndex(
messages: unknown[],
showToolCalls: boolean,
renderLimit: number,
): number {
let visibleCount = 0;
let renderChars = 0;
let startIndex = messages.length;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (isHiddenToolMessage(message, showToolCalls)) {
continue;
}
if (visibleCount >= renderLimit) {
break;
}
const remainingBudget = Math.max(1, CHAT_HISTORY_RENDER_CHAR_BUDGET - renderChars + 1);
const messageChars = estimateMessageRenderChars(message, remainingBudget);
if (visibleCount > 0 && renderChars + messageChars > CHAT_HISTORY_RENDER_CHAR_BUDGET) {
break;
}
renderChars += messageChars;
visibleCount += 1;
startIndex = index;
}
return startIndex;
}
function expandHistoryStartForPersistedPreviews(messages: unknown[], historyStart: number): number {
const firstVisible = safeNormalizeMessage(messages[historyStart]);
if (!firstVisible || normalizeRoleForGrouping(firstVisible.role).toLowerCase() !== "assistant") {
return historyStart;
}
let expandedStart = historyStart;
for (let index = historyStart - 1; index >= 0; index -= 1) {
const message = messages[index];
const normalized = safeNormalizeMessage(message);
if (!normalized) {
continue;
}
const normalizedRole = normalized.role.toLowerCase();
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
if (role === "user" || role === "system") {
break;
}
if (normalizedRole === "toolresult" && extractChatMessagePreview(message)) {
expandedStart = index;
continue;
}
if (role === "assistant" && hasRenderableNormalizedMessage(message)) {
break;
}
}
return expandedStart;
}
function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
let items: ChatItem[] = [];
const historyRenderLimit = resolveHistoryRenderLimit(
props.historyRenderLimit,
props.allowExpandedHistoryRenderLimit,
);
const history = (Array.isArray(props.messages) ? props.messages : []).filter(
(message) => !isAssistantHeartbeatAckForDisplay(message),
);
@@ -1278,28 +1103,7 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
persistedCanvasIdentities.add(baseIdentity);
}
}
const historyStart = resolveHistoryStartIndex(history, props.showToolCalls, historyRenderLimit);
const previewHistoryStart = expandHistoryStartForPersistedPreviews(history, historyStart);
const hiddenHistoryCount = countVisibleHistoryMessages(
history.slice(0, previewHistoryStart),
props.showToolCalls,
);
const visibleHistoryCount = countVisibleHistoryMessages(
history.slice(previewHistoryStart),
props.showToolCalls,
);
if (hiddenHistoryCount > 0) {
items.push({
kind: "message",
key: "chat:history:notice",
message: {
role: "system",
content: `Showing last ${visibleHistoryCount} messages (${hiddenHistoryCount} hidden).`,
timestamp: Date.now(),
},
});
}
for (let i = previewHistoryStart; i < history.length; i++) {
for (let i = 0; i < history.length; i++) {
const msg = history[i];
const normalized = safeNormalizeMessage(msg);
if (!normalized) {
@@ -1735,9 +1539,7 @@ function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsP
previous.runWorking === next.runWorking &&
previous.loading === next.loading &&
previous.searchOpen === next.searchOpen &&
previous.searchQuery === next.searchQuery &&
previous.historyRenderLimit === next.historyRenderLimit &&
previous.allowExpandedHistoryRenderLimit === next.allowExpandedHistoryRenderLimit
previous.searchQuery === next.searchQuery
);
}

View File

@@ -696,8 +696,6 @@ describe("chat history pagination", () => {
const container = renderChatView({
historyPagination: {
loading: true,
manualFallback: false,
onLoadOlder: () => undefined,
},
});
const threadInner = requireElement(container, ".chat-thread-inner", "chat thread inner");
@@ -711,24 +709,21 @@ describe("chat history pagination", () => {
expect(sentinel.querySelector("button")).toBeNull();
});
it("keeps a manual button only when IntersectionObserver is unavailable", () => {
const onLoadOlder = vi.fn();
it("loads older history from upward wheel and keyboard intent without a button", () => {
const onHistoryIntent = vi.fn();
const container = renderChatView({
historyPagination: {
loading: false,
manualFallback: true,
onLoadOlder,
},
onHistoryIntent,
});
const button = requireElement(
container,
".chat-history-fallback",
"history fallback",
) as HTMLButtonElement;
const thread = requireElement(container, ".chat-thread", "chat thread");
const sentinel = requireElement(container, ".chat-history-sentinel", "history sentinel");
expect(button.textContent?.trim()).toBe(t("chat.loadOlder"));
button.click();
expect(onLoadOlder).toHaveBeenCalledTimes(1);
expect(sentinel.querySelector("button")).toBeNull();
thread.dispatchEvent(new WheelEvent("wheel", { deltaY: -1, bubbles: true }));
thread.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }));
expect(onHistoryIntent).toHaveBeenCalledTimes(2);
});
});
@@ -955,25 +950,8 @@ describe("chat code-block copy", () => {
});
});
describe("chat history render window", () => {
it("starts freshly loaded large histories with a small render window", () => {
const messages = Array.from({ length: 80 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index,
}));
renderChatView({ messages });
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
messages,
historyRenderLimit: 30,
}),
);
});
it("expands the history render window when the user scrolls to the top", () => {
describe("chat transcript rendering", () => {
it("passes the full loaded history to one render path and leaves scroll ownership to the pane", () => {
const messages = Array.from({ length: 80 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
@@ -983,205 +961,17 @@ describe("chat history render window", () => {
const onChatScroll = vi.fn();
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
thread.scrollTop = 120;
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
thread.scrollTop = 0;
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
expect(onChatScroll).toHaveBeenCalledTimes(2);
buildChatItemsMock.mockClear();
renderChatView({ messages, onRequestUpdate, onChatScroll });
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
messages,
historyRenderLimit: 60,
}),
);
});
it("preserves the visible anchor across repeated top-scroll expansion", () => {
const messages = Array.from({ length: 80 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index,
}));
const onRequestUpdate = vi.fn();
const onChatScroll = vi.fn();
const frameCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
frameCallbacks.push(callback);
return frameCallbacks.length;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
Object.defineProperties(thread, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 300 },
});
thread.scrollTop = 0;
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
Object.defineProperty(thread, "scrollHeight", { configurable: true, value: 600 });
buildChatItemsMock.mockClear();
renderChatView({ messages, onRequestUpdate, onChatScroll });
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
messages,
historyRenderLimit: 60,
}),
);
const firstExpandedThread = requireElement(
container,
".chat-thread",
"chat thread",
) as HTMLElement;
Object.defineProperties(firstExpandedThread, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 600 },
});
for (const callback of frameCallbacks.splice(0)) {
callback(0);
}
expect(firstExpandedThread.scrollTop).toBe(300);
firstExpandedThread.scrollTop = 0;
firstExpandedThread.dispatchEvent(new Event("scroll", { bubbles: true }));
buildChatItemsMock.mockClear();
renderChatView({ messages, onRequestUpdate, onChatScroll });
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
messages,
historyRenderLimit: 80,
}),
);
const secondExpandedThread = requireElement(
container,
".chat-thread",
"chat thread",
) as HTMLElement;
Object.defineProperties(secondExpandedThread, {
clientHeight: { configurable: true, value: 100 },
scrollHeight: { configurable: true, value: 900 },
});
for (const callback of frameCallbacks.splice(0)) {
callback(0);
}
expect(secondExpandedThread.scrollTop).toBe(300);
expect(onRequestUpdate).toHaveBeenCalledTimes(2);
expect(onChatScroll).toHaveBeenCalledTimes(2);
});
it("does not expand the history render window for bottom auto-scrolls inside the top threshold", () => {
const messages = Array.from({ length: 80 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index,
}));
const onRequestUpdate = vi.fn();
const onChatScroll = vi.fn();
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
thread.scrollTop = 30;
const input = buildChatItemsMock.mock.lastCall?.[0] as Record<string, unknown>;
expect(input.messages).toBe(messages);
expect(input).not.toHaveProperty("historyRenderLimit");
onRequestUpdate.mockClear();
const thread = requireElement(container, ".chat-thread", "chat thread");
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
expect(onChatScroll).toHaveBeenCalledOnce();
expect(onRequestUpdate).not.toHaveBeenCalled();
expect(onChatScroll).toHaveBeenCalledTimes(1);
buildChatItemsMock.mockClear();
const rerenderedContainer = renderChatView({ messages, onRequestUpdate, onChatScroll });
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
messages,
historyRenderLimit: 30,
}),
);
const rerenderedThread = requireElement(
rerenderedContainer,
".chat-thread",
"chat thread",
) as HTMLElement;
rerenderedThread.scrollTop = 0;
rerenderedThread.dispatchEvent(new Event("scroll", { bubbles: true }));
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
expect(onChatScroll).toHaveBeenCalledTimes(2);
});
it("expands the history render window when the thread is already at the top", () => {
const messages = Array.from({ length: 80 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index,
}));
const onRequestUpdate = vi.fn();
const onChatScroll = vi.fn();
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
thread.scrollTop = 0;
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
expect(onChatScroll).toHaveBeenCalledTimes(1);
});
it("expands the render window after render when the initial window cannot scroll", () => {
const messages = Array.from({ length: 80 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
timestamp: index,
}));
const onRequestUpdate = vi.fn();
const onScrollToBottom = vi.fn();
const frameCallbacks: FrameRequestCallback[] = [];
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
frameCallbacks.push(callback);
return frameCallbacks.length;
}),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
renderChatView({ messages, onRequestUpdate, onScrollToBottom });
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
messages,
historyRenderLimit: 30,
}),
);
expect(frameCallbacks).toHaveLength(1);
itemAt(frameCallbacks, 0, "history growth frame")(0);
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
expect(onScrollToBottom).toHaveBeenCalledTimes(1);
buildChatItemsMock.mockClear();
renderChatView({ messages, onRequestUpdate, onScrollToBottom });
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
messages,
historyRenderLimit: 60,
}),
);
});
});

View File

@@ -83,10 +83,7 @@ export type ChatProps = {
messages: unknown[];
historyPagination?: {
loading: boolean;
manualFallback: boolean;
onLoadOlder: () => void;
};
renderAllLoadedHistory?: boolean;
sideChatTurns?: ChatSideResult[];
sideChatPending?: ChatSideResultPending | null;
sideChatHidden?: boolean;
@@ -156,6 +153,7 @@ export type ChatProps = {
onQueueRetry?: (id: string) => void;
onQueueSteer?: (id: string) => void;
onGoalCommand?: (command: string) => void;
onHistoryIntent?: (event: Event) => void;
/** Sends a detached /btw side question (selection popup or side-chat
* follow-up). `displayQuestion` overrides the pending-turn display when the
* command embeds carried follow-up context; `onSendRejected` lets the panel
@@ -265,7 +263,6 @@ export function renderChat(props: ChatProps) {
sessionKey: props.sessionKey,
loading: props.loading,
historyPagination: props.historyPagination,
renderAllLoadedHistory: props.renderAllLoadedHistory,
messages: props.messages,
toolMessages: props.toolMessages,
streamSegments: props.streamSegments,
@@ -297,8 +294,8 @@ export function renderChat(props: ChatProps) {
onOpenSessionCheckpoints: props.onOpenSessionCheckpoints,
onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded,
onRequestUpdate: requestUpdate,
onScrollToBottom: props.onScrollToBottom,
onChatScroll: props.onChatScroll,
onHistoryIntent: props.onHistoryIntent,
onDraftChange: props.onDraftChange,
getDraft: props.getDraft,
onSend: props.onSend,

View File

@@ -6,7 +6,7 @@ export function renderChatDivider(
onOpenSessionCheckpoints?: () => void | Promise<void>,
) {
return html`
<div class="chat-divider" data-ts=${String(item.timestamp)}>
<div class="chat-divider" data-chat-row-key=${item.key} data-ts=${String(item.timestamp)}>
<div
class="chat-divider__rule"
role="separator"

View File

@@ -535,6 +535,7 @@ describe("grouped chat rendering", () => {
const bubble = expectElement(container, ".chat-bubble", HTMLElement);
expect(bubble.classList.contains("fade-in")).toBe(false);
expect(expectElement(container, ".chat-group", HTMLElement).dataset.chatRowKey).toBeTruthy();
});
it("uses the displayed answer for assistant message actions", () => {

View File

@@ -584,7 +584,10 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt
: renderChatAvatar("assistant", assistant, undefined, basePath, authToken);
return html`
<div class="chat-group assistant ${indicatorOnly ? "chat-group--working" : ""}">
<div
class="chat-group assistant ${indicatorOnly ? "chat-group--working" : ""}"
data-chat-row-key=${parts[0]?.key ?? nothing}
>
${avatar}
<div class="chat-group-messages">
${parts.map((part) =>
@@ -622,13 +625,13 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt
* the turn's done indicator; the expanded groups render after this row.
*/
export function renderWorkGroupSummary(
item: { durationMs: number | null; hasError: boolean },
item: { key: string; durationMs: number | null; hasError: boolean },
opts: { expanded: boolean; onToggle: () => void },
) {
const duration = formatDurationCompact(item.durationMs, { spaced: true });
const label = duration ? t("chat.workRun.workedFor", { duration }) : t("chat.workRun.worked");
return html`
<div class="chat-group tool chat-group--work">
<div class="chat-group tool chat-group--work" data-chat-row-key=${item.key}>
<span class="chat-work-group__gutter" aria-hidden="true"></span>
<div class="chat-group-messages">
<div class="chat-activity-group chat-work-group ${opts.expanded ? "is-open" : ""}">
@@ -787,7 +790,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? hasError;
return html`
<div class="chat-group tool chat-group--activity">
<div class="chat-group tool chat-group--activity" data-chat-row-key=${group.key}>
${renderChatAvatar(
group.role,
{
@@ -865,7 +868,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
const footerActionDetails = messageActionDetails[lastMessageIndex] ?? null;
return html`
<div class="chat-group ${roleClass}">
<div class="chat-group ${roleClass}" data-chat-row-key=${group.key}>
${renderChatAvatar(
group.role,
{

View File

@@ -2,7 +2,6 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { html, nothing, type TemplateResult } from "lit";
import { guard } from "lit/directives/guard.js";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js";
import type { SessionsListResult } from "../../../api/types.ts";
@@ -15,7 +14,6 @@ import {
markdownFileLinkFromEvent,
} from "../../../components/markdown.ts";
import { i18n, t } from "../../../i18n/index.ts";
import { CHAT_HISTORY_RENDER_LIMIT } from "../../../lib/chat/chat-types.ts";
import type {
ChatQueueItem,
ChatStreamSegment,
@@ -65,9 +63,6 @@ import { renderWelcomeState, resolveAssistantDisplayAvatar } from "./chat-welcom
const pinnedMessagesMap = new Map<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
const INITIAL_CHAT_HISTORY_RENDER_WINDOW = 30;
const CHAT_HISTORY_RENDER_WINDOW_BATCH = 30;
const CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX = 48;
type ReplyTarget = {
messageId: string;
@@ -79,17 +74,6 @@ type ChatThreadState = {
searchOpen: boolean;
searchQuery: string;
pinnedExpanded: boolean;
historyRenderSessionKey: string | null;
historyRenderMessagesRef: unknown[] | null;
historyRenderMessageCount: number;
historyRenderLimit: number;
historyRenderLastScrollTop: number | null;
historyRenderExpansionFrame: number | null;
historyRenderAnchorAdjustment: {
scrollHeight: number;
scrollTop: number;
} | null;
historyRenderAnchorFrame: number | null;
transcriptRenderDependencies: readonly unknown[];
transcriptRenderContext: object;
};
@@ -100,10 +84,7 @@ type ChatThreadProps = {
loading: boolean;
historyPagination?: {
loading: boolean;
manualFallback: boolean;
onLoadOlder: () => void;
};
renderAllLoadedHistory?: boolean;
messages: unknown[];
toolMessages: unknown[];
streamSegments: ChatStreamSegment[];
@@ -139,8 +120,8 @@ type ChatThreadProps = {
onOpenSessionCheckpoints?: () => void | Promise<void>;
onAssistantAttachmentLoaded?: () => void;
onRequestUpdate?: () => void;
onScrollToBottom?: () => void;
onChatScroll?: (event: Event) => void;
onHistoryIntent?: (event: Event) => void;
onDraftChange: (next: string) => void;
/** Current composer draft; the selection popup preserves it when prefilling. */
getDraft?: () => string;
@@ -164,14 +145,6 @@ function createChatThreadState(): ChatThreadState {
searchOpen: false,
searchQuery: "",
pinnedExpanded: false,
historyRenderSessionKey: null,
historyRenderMessagesRef: null,
historyRenderMessageCount: 0,
historyRenderLimit: 0,
historyRenderLastScrollTop: null,
historyRenderExpansionFrame: null,
historyRenderAnchorAdjustment: null,
historyRenderAnchorFrame: null,
transcriptRenderDependencies: [],
transcriptRenderContext: {},
};
@@ -214,17 +187,6 @@ export function resetChatThreadPresentationState(paneId?: string) {
// The selection popup is body-portaled; pane teardown/route changes must
// drop it so it cannot outlive the render that owns its callbacks.
removeChatSelectionPopup();
const states = paneId
? ([threadStates.get(paneId)].filter(Boolean) as ChatThreadState[])
: [...threadStates.values()];
for (const state of states) {
if (state.historyRenderExpansionFrame != null) {
cancelAnimationFrame(state.historyRenderExpansionFrame);
}
if (state.historyRenderAnchorFrame != null) {
cancelAnimationFrame(state.historyRenderAnchorFrame);
}
}
if (paneId) {
threadStates.delete(paneId);
} else {
@@ -233,158 +195,6 @@ export function resetChatThreadPresentationState(paneId?: string) {
}
}
function resolveChatHistoryRenderCap(messageCount: number, renderAllLoadedHistory = false): number {
const count = Math.max(0, messageCount);
return renderAllLoadedHistory ? count : Math.min(count, CHAT_HISTORY_RENDER_LIMIT);
}
function shouldRenderFullChatHistoryWindow(state: ChatThreadState, messageCount: number): boolean {
return (
messageCount <= INITIAL_CHAT_HISTORY_RENDER_WINDOW ||
(state.searchOpen && state.searchQuery.trim().length > 0)
);
}
function resolveChatHistoryRenderWindow(
props: Pick<ChatThreadProps, "paneId" | "sessionKey" | "messages" | "renderAllLoadedHistory">,
) {
const state = getChatThreadState(props.paneId);
const messages = Array.isArray(props.messages) ? props.messages : [];
const cap = resolveChatHistoryRenderCap(messages.length, props.renderAllLoadedHistory);
const sessionChanged = state.historyRenderSessionKey !== props.sessionKey;
const refChanged = state.historyRenderMessagesRef !== messages;
const previousCount = state.historyRenderMessageCount;
if (sessionChanged || (refChanged && previousCount === 0)) {
state.historyRenderLastScrollTop = null;
}
if (cap === 0) {
state.historyRenderSessionKey = props.sessionKey;
state.historyRenderMessagesRef = messages;
state.historyRenderMessageCount = messages.length;
state.historyRenderLimit = 0;
state.historyRenderLastScrollTop = null;
return 0;
}
if (props.renderAllLoadedHistory || shouldRenderFullChatHistoryWindow(state, messages.length)) {
state.historyRenderSessionKey = props.sessionKey;
state.historyRenderMessagesRef = messages;
state.historyRenderMessageCount = messages.length;
state.historyRenderLimit = cap;
return cap;
}
if (sessionChanged || (refChanged && previousCount === 0)) {
state.historyRenderLimit = Math.min(INITIAL_CHAT_HISTORY_RENDER_WINDOW, cap);
} else if (refChanged) {
const grewBy = messages.length - previousCount;
if (state.historyRenderLimit >= previousCount) {
state.historyRenderLimit = cap;
} else if (grewBy > 0 && grewBy <= CHAT_HISTORY_RENDER_WINDOW_BATCH) {
state.historyRenderLimit = Math.min(cap, state.historyRenderLimit + grewBy);
} else {
state.historyRenderLimit = Math.min(
Math.max(state.historyRenderLimit, INITIAL_CHAT_HISTORY_RENDER_WINDOW),
cap,
);
}
}
state.historyRenderSessionKey = props.sessionKey;
state.historyRenderMessagesRef = messages;
state.historyRenderMessageCount = messages.length;
state.historyRenderLimit = Math.min(Math.max(1, state.historyRenderLimit), cap);
return state.historyRenderLimit;
}
function maybeExpandChatHistoryRenderWindow(
state: ChatThreadState,
event: Event,
requestUpdate: () => void,
) {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
const scrollTop = Math.max(0, target.scrollTop);
const previousScrollTop = state.historyRenderLastScrollTop;
state.historyRenderLastScrollTop = scrollTop;
const distanceFromBottom = Math.max(0, target.scrollHeight - scrollTop - target.clientHeight);
const isTop = scrollTop <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX;
const isBottomAutoScroll =
scrollTop > 0 && distanceFromBottom <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX;
const isTopScrollUp =
isTop &&
(scrollTop === 0 ||
(!isBottomAutoScroll && (previousScrollTop == null || scrollTop < previousScrollTop)));
if (!isTopScrollUp) {
return;
}
const cap = resolveChatHistoryRenderCap(state.historyRenderMessageCount);
if (state.historyRenderLimit >= cap) {
return;
}
state.historyRenderAnchorAdjustment = {
scrollHeight: target.scrollHeight,
scrollTop,
};
scheduleChatHistoryRenderAnchorPreservation(state, target);
state.historyRenderLimit = Math.min(
cap,
state.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH,
);
requestUpdate();
}
function scheduleChatHistoryRenderAnchorPreservation(state: ChatThreadState, thread: HTMLElement) {
const adjustment = state.historyRenderAnchorAdjustment;
if (!adjustment || state.historyRenderAnchorFrame != null) {
return;
}
state.historyRenderAnchorFrame = requestAnimationFrame(() => {
state.historyRenderAnchorFrame = null;
state.historyRenderAnchorAdjustment = null;
const heightDelta = thread.scrollHeight - adjustment.scrollHeight;
if (heightDelta <= 0) {
return;
}
thread.scrollTop = adjustment.scrollTop + heightDelta;
});
}
function scheduleChatHistoryRenderWindowFill(
state: ChatThreadState,
thread: HTMLElement | null,
requestUpdate: () => void,
scrollToBottom: () => void,
) {
if (!thread || state.historyRenderExpansionFrame != null) {
return;
}
const cap = resolveChatHistoryRenderCap(state.historyRenderMessageCount);
if (state.historyRenderLimit >= cap) {
return;
}
state.historyRenderExpansionFrame = requestAnimationFrame(() => {
state.historyRenderExpansionFrame = null;
const nextCap = resolveChatHistoryRenderCap(state.historyRenderMessageCount);
if (state.historyRenderLimit >= nextCap) {
return;
}
const canScroll = thread.scrollHeight - thread.clientHeight > 1;
if (canScroll) {
return;
}
state.historyRenderLimit = Math.min(
nextCap,
state.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH,
);
requestUpdate();
scrollToBottom();
});
}
export function renderChatSearchBar(
paneId: string,
requestUpdate: () => void,
@@ -785,7 +595,6 @@ export function renderChatThread(props: ChatThreadProps) {
name: props.assistantName,
avatar: resolveAssistantDisplayAvatar(props),
};
const historyRenderLimit = resolveChatHistoryRenderWindow(props);
const deleted = getDeletedMessages(props.sessionKey);
const locale = i18n.getLocale();
const chatItems = buildCachedChatItems({
@@ -805,8 +614,6 @@ export function renderChatThread(props: ChatThreadProps) {
loading: props.loading,
searchOpen: state.searchOpen,
searchQuery: state.searchQuery,
historyRenderLimit,
allowExpandedHistoryRenderLimit: props.renderAllLoadedHistory,
});
syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls));
const expandedToolCards = getExpandedToolCards(props.sessionKey);
@@ -837,26 +644,15 @@ export function renderChatThread(props: ChatThreadProps) {
const showLoadingSkeleton = props.loading && chatItems.length === 0;
const threadContextWindow =
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null;
const handleChatThreadScroll = (event: Event) => {
maybeExpandChatHistoryRenderWindow(state, event, requestUpdate);
props.onChatScroll?.(event);
};
return html`
<div
class="chat-thread ${isDirectThread ? "chat-thread--direct" : ""}"
role="log"
aria-live="polite"
${ref((element) => {
const threadElement = element instanceof HTMLElement ? element : null;
scheduleChatHistoryRenderWindowFill(
state,
threadElement,
requestUpdate,
props.onScrollToBottom ?? (() => {}),
);
})}
@scroll=${handleChatThreadScroll}
tabindex="0"
@scroll=${props.onChatScroll}
@wheel=${props.onHistoryIntent}
@keydown=${props.onHistoryIntent}
@mousedown=${beginNativeWindowDragFromTopInset}
@click=${(event: Event) => {
handleMarkdownCodeBlockCopy(event);
@@ -879,17 +675,7 @@ export function renderChatThread(props: ChatThreadProps) {
<span>${t("common.loading")}</span>
</div>
`
: props.historyPagination.manualFallback
? html`
<button
class="btn btn--sm chat-history-fallback"
type="button"
@click=${props.historyPagination.onLoadOlder}
>
${t("chat.loadOlder")}
</button>
`
: nothing}
: nothing}
</div>
`
: nothing}

View File

@@ -1,7 +1,8 @@
// Control UI chat module implements input history behavior.
import { CHAT_HISTORY_RENDER_LIMIT } from "../../lib/chat/chat-types.ts";
import { extractText } from "../../lib/chat/message-extract.ts";
const CHAT_INPUT_HISTORY_LIMIT = 100;
type ChatLocalInputHistoryEntry = {
text: string;
ts: number;
@@ -60,8 +61,8 @@ function collectUserInputHistory(
if (messages.length === 0 && localEntries.length === 0) {
return [];
}
// Keep input recall aligned with what chat UI renders: only consider the visible history window.
const start = Math.max(0, messages.length - CHAT_HISTORY_RENDER_LIMIT);
// Bound input recall independently from the transcript's loaded rendering depth.
const start = Math.max(0, messages.length - CHAT_INPUT_HISTORY_LIMIT);
const candidates: Array<{ text: string; ts: number }> = [...localEntries];
for (let i = messages.length - 1; i >= start; i--) {
const message = messages[i];
@@ -109,7 +110,7 @@ export function recordNonTranscriptInputHistory(state: ChatInputHistoryState, te
state.chatLocalInputHistoryBySession[state.sessionKey] = [
{ text: trimmed, ts: Date.now() },
...sessionEntries,
].slice(0, CHAT_HISTORY_RENDER_LIMIT);
].slice(0, CHAT_INPUT_HISTORY_LIMIT);
}
export function resetChatInputHistoryNavigation(state: ChatInputHistoryState) {

View File

@@ -518,18 +518,6 @@ img.chat-avatar {
box-shadow: none;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Message metadata (tokens, cost, model, context %) ── */
.msg-meta {
display: inline-flex;