From 8da6548ccebdf554e4b5eb71f536040ed179d7fa Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 14 Jul 2026 20:13:48 +0100 Subject: [PATCH] fix: remove staged chat transcript rendering --- ui/src/lib/chat/chat-types.ts | 3 - ui/src/pages/chat/chat-pane.test.ts | 84 ++++-- ui/src/pages/chat/chat-pane.ts | 121 ++++++--- ui/src/pages/chat/chat-thread.test.ts | 100 +------ ui/src/pages/chat/chat-thread.ts | 204 +------------- ui/src/pages/chat/chat-view.test.ts | 248 ++---------------- ui/src/pages/chat/chat-view.ts | 7 +- ui/src/pages/chat/components/chat-divider.ts | 2 +- .../chat/components/chat-message.test.ts | 1 + ui/src/pages/chat/components/chat-message.ts | 13 +- ui/src/pages/chat/components/chat-thread.ts | 226 +--------------- ui/src/pages/chat/input-history.ts | 9 +- ui/src/styles/chat/grouped.css | 12 - 13 files changed, 207 insertions(+), 823 deletions(-) diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index cfc42c74e48e..0236533a2137 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -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; diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 41c212a13563..7931109dd8c9 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -48,6 +48,7 @@ type TestChatPane = HTMLElement & { index: number, ) => Record | 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; 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; olderOffsetsSeen: Set; - nativeHistoryExpanded: boolean; renderPaneHeader: ( workspace: ReturnType, tasks: ReturnType, @@ -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() diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 2e538e1ab264..4731e9913869 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -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(".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(".chat-thread"); - return thread - ? { sessionKey, scrollHeight: thread.scrollHeight, scrollTop: thread.scrollTop } - : null; + if (!thread) { + return null; + } + const viewportRect = thread.getBoundingClientRect(); + const rows = thread.querySelectorAll("[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 { + 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 { 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, diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 32e1246d75a8..7bda56ee8622 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -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: [ diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index a2778bbee35f..09580fded4e7 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -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; - 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; - 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(), 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 { 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 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 ); } diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 483b3669b68f..1b5d8ea51520 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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; + 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, - }), - ); }); }); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 04d0157bec9f..0b7a8197f3b1 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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, diff --git a/ui/src/pages/chat/components/chat-divider.ts b/ui/src/pages/chat/components/chat-divider.ts index 00ffb4c2c54c..76d21224b326 100644 --- a/ui/src/pages/chat/components/chat-divider.ts +++ b/ui/src/pages/chat/components/chat-divider.ts @@ -6,7 +6,7 @@ export function renderChatDivider( onOpenSessionCheckpoints?: () => void | Promise, ) { return html` -
+