From 85e5d486df116fab3443973ac4e180f01244510d Mon Sep 17 00:00:00 2001 From: Alexzhu Date: Wed, 3 Jun 2026 15:16:32 +0800 Subject: [PATCH] perf(control-ui): render chat history incrementally Render dashboard chat history incrementally; preserve Talk settings callback contracts, native Talk select labels, and raw-copy baseline after rebase. --- ui/src/i18n/.i18n/raw-copy-baseline.json | 49 ++++ ui/src/styles/chat/layout.css | 14 +- ui/src/ui/app.talk.test.ts | 25 ++ ui/src/ui/app.ts | 4 +- ui/src/ui/chat/build-chat-items.test.ts | 21 ++ ui/src/ui/chat/build-chat-items.ts | 19 +- ui/src/ui/views/chat.test.ts | 296 +++++++++++++++++++++ ui/src/ui/views/chat.ts | 317 ++++++++++++++++++----- 8 files changed, 671 insertions(+), 74 deletions(-) diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 12ab01f77268..afa6b4c9fcb2 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -1,6 +1,27 @@ { "version": 1, "entries": [ + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/ui/app-render.ts", + "text": "Workshop view" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "title", + "path": "ui/src/ui/app-render.ts", + "text": "Board view" + }, + { + "count": 1, + "kind": "html-attribute", + "name": "title", + "path": "ui/src/ui/app-render.ts", + "text": "Today view" + }, { "count": 1, "kind": "html-text", @@ -8,6 +29,13 @@ "path": "ui/src/ui/app-render.ts", "text": "⌘K" }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/ui/app-render.ts", + "text": "Board" + }, { "count": 1, "kind": "html-text", @@ -15,6 +43,13 @@ "path": "ui/src/ui/app-render.ts", "text": "OpenClaw" }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/ui/app-render.ts", + "text": "Today" + }, { "count": 1, "kind": "object-property", @@ -1478,6 +1513,13 @@ "path": "ui/src/ui/views/chat.ts", "text": "Dismiss error" }, + { + "count": 1, + "kind": "html-attribute", + "name": "aria-label", + "path": "ui/src/ui/views/chat.ts", + "text": "Exit focus mode" + }, { "count": 1, "kind": "html-attribute", @@ -1555,6 +1597,13 @@ "path": "ui/src/ui/views/chat.ts", "text": "Dismiss error" }, + { + "count": 1, + "kind": "html-attribute", + "name": "title", + "path": "ui/src/ui/views/chat.ts", + "text": "Exit focus mode" + }, { "count": 1, "kind": "html-attribute", diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 5cdbb00645a1..708d21254125 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1014,7 +1014,8 @@ color: var(--muted); } -.agent-chat__talk-options input { +.agent-chat__talk-options input, +.agent-chat__talk-options select { width: 100%; min-width: 0; height: 34px; @@ -1029,7 +1030,16 @@ box-sizing: border-box; } -.agent-chat__talk-options input:focus { +.agent-chat__talk-options select { + appearance: none; + padding-right: 26px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-position: right 8px center; + background-repeat: no-repeat; +} + +.agent-chat__talk-options input:focus, +.agent-chat__talk-options select:focus { outline: none; box-shadow: var(--focus-ring); } diff --git a/ui/src/ui/app.talk.test.ts b/ui/src/ui/app.talk.test.ts index a7087afebe2d..62d0170e3dff 100644 --- a/ui/src/ui/app.talk.test.ts +++ b/ui/src/ui/app.talk.test.ts @@ -152,4 +152,29 @@ describe("OpenClawApp Talk controls", () => { expect(app.chatError).toBe("voice provider missing"); expect(stopMock).toHaveBeenCalledOnce(); }); + + it("keeps the Talk options toggle inside the open-panel click guard", async () => { + await import("./app.ts"); + const app = document.createElement("openclaw-app"); + const guardHost = app as unknown as { + chatMobileControlsPointerdownHandler: (event: Event) => void; + realtimeTalkOptionsOpen: boolean; + }; + const toggle = document.createElement("button"); + toggle.setAttribute("aria-label", "Talk options"); + app.append(toggle); + + guardHost.realtimeTalkOptionsOpen = true; + guardHost.chatMobileControlsPointerdownHandler({ + composedPath: () => [toggle, app, document, window], + } as unknown as Event); + + expect(guardHost.realtimeTalkOptionsOpen).toBe(true); + + guardHost.chatMobileControlsPointerdownHandler({ + composedPath: () => [document, window], + } as unknown as Event); + + expect(guardHost.realtimeTalkOptionsOpen).toBe(false); + }); }); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 0419d8213f60..4cd02aa61739 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -760,7 +760,9 @@ export class OpenClawApp extends LitElement { }); if (this.realtimeTalkOptionsOpen) { const insideTalkOptions = Array.from( - this.querySelectorAll(".agent-chat__talk-options, [aria-label='Talk settings']"), + this.querySelectorAll( + ".agent-chat__talk-options, [aria-label='Talk settings'], [aria-label='Talk options']", + ), ).some((node) => path.includes(node)); if (!insideTalkOptions) { this.realtimeTalkOptionsOpen = false; diff --git a/ui/src/ui/chat/build-chat-items.test.ts b/ui/src/ui/chat/build-chat-items.test.ts index ec8f15d4f66b..d8630885563b 100644 --- a/ui/src/ui/chat/build-chat-items.test.ts +++ b/ui/src/ui/chat/build-chat-items.test.ts @@ -260,6 +260,27 @@ describe("buildChatItems", () => { expect(messageRecord(groups[groups.length - 1]).content).toBe("message 104"); }); + it("honors a smaller history render window and preserves the hidden-count notice", () => { + const items = buildChatItems( + 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(groups[1]).content).toBe("message 75"); + expect(messageRecord(groups[groups.length - 1]).content).toBe("message 104"); + }); + it("budgets rendered history by tool-result content size", () => { const largeOutput = "x".repeat(100_000); const items = buildChatItems( diff --git a/ui/src/ui/chat/build-chat-items.ts b/ui/src/ui/chat/build-chat-items.ts index d31c5f03e722..455e91da8663 100644 --- a/ui/src/ui/chat/build-chat-items.ts +++ b/ui/src/ui/chat/build-chat-items.ts @@ -23,6 +23,7 @@ export type BuildChatItemsProps = { showToolCalls: boolean; searchOpen?: boolean; searchQuery?: string; + historyRenderLimit?: number; }; function appendCanvasBlockToAssistantMessage( @@ -468,7 +469,18 @@ function countVisibleHistoryMessages(messages: unknown[], showToolCalls: boolean return count; } -function resolveHistoryStartIndex(messages: unknown[], showToolCalls: boolean): number { +function resolveHistoryRenderLimit(limit: number | undefined): number { + if (typeof limit !== "number" || !Number.isFinite(limit)) { + return CHAT_HISTORY_RENDER_LIMIT; + } + return Math.max(1, Math.min(CHAT_HISTORY_RENDER_LIMIT, Math.floor(limit))); +} + +function resolveHistoryStartIndex( + messages: unknown[], + showToolCalls: boolean, + renderLimit: number, +): number { let visibleCount = 0; let renderChars = 0; let startIndex = messages.length; @@ -477,7 +489,7 @@ function resolveHistoryStartIndex(messages: unknown[], showToolCalls: boolean): if (isHiddenToolMessage(message, showToolCalls)) { continue; } - if (visibleCount >= CHAT_HISTORY_RENDER_LIMIT) { + if (visibleCount >= renderLimit) { break; } const remainingBudget = Math.max(1, CHAT_HISTORY_RENDER_CHAR_BUDGET - renderChars + 1); @@ -494,6 +506,7 @@ function resolveHistoryStartIndex(messages: unknown[], showToolCalls: boolean): export function buildChatItems(props: BuildChatItemsProps): Array { let items: ChatItem[] = []; + const historyRenderLimit = resolveHistoryRenderLimit(props.historyRenderLimit); const history = (Array.isArray(props.messages) ? props.messages : []).filter( (message) => !isAssistantHeartbeatAckForDisplay(message), ); @@ -505,7 +518,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array; - const historyStart = resolveHistoryStartIndex(history, props.showToolCalls); + const historyStart = resolveHistoryStartIndex(history, props.showToolCalls, historyRenderLimit); const hiddenHistoryCount = countVisibleHistoryMessages( history.slice(0, historyStart), props.showToolCalls, diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index d0cfab509a7d..87a06cacaea1 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -601,6 +601,236 @@ describe("chat compaction divider", () => { }); }); +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", () => { + 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 = 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; + thread.dispatchEvent(new Event("scroll", { bubbles: true })); + + 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); + + frameCallbacks[0](0); + + expect(onRequestUpdate).toHaveBeenCalledTimes(1); + expect(onScrollToBottom).toHaveBeenCalledTimes(1); + + buildChatItemsMock.mockClear(); + renderChatView({ messages, onRequestUpdate, onScrollToBottom }); + + expect(buildChatItemsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + messages, + historyRenderLimit: 60, + }), + ); + }); +}); + describe("chat goal status", () => { it("renders the active session goal inside the composer", () => { const container = renderChatView({ @@ -676,6 +906,72 @@ describe("chat composer workbench", () => { expect(onOpenFile).toHaveBeenCalledWith("AGENTS.md"); }); + + it("keeps the secondary New session and Export controls suppressed in the composer", () => { + const container = renderChatView({ + messages: [{ role: "assistant", content: "ready" }], + }); + + const toolbarRight = container.querySelector(".agent-chat__toolbar-right"); + expect(toolbarRight).not.toBeNull(); + const labels = Array.from(toolbarRight?.querySelectorAll("button") ?? []).map((button) => + button.getAttribute("aria-label"), + ); + expect(labels).not.toContain(t("chat.runControls.newSession")); + expect(labels).not.toContain(t("chat.runControls.exportChat")); + }); + + it("exposes aria-expanded on the Talk settings button reflecting open state", () => { + const collapsed = renderChatView({ + onToggleRealtimeTalk: () => undefined, + onToggleRealtimeTalkOptions: () => undefined, + realtimeTalkOptionsOpen: false, + }); + const collapsedBtn = collapsed.querySelector( + 'button[aria-label="Talk settings"]', + ); + expect(collapsedBtn).not.toBeNull(); + expect(collapsedBtn?.getAttribute("aria-expanded")).toBe("false"); + + const expanded = renderChatView({ + onToggleRealtimeTalk: () => undefined, + onToggleRealtimeTalkOptions: () => undefined, + realtimeTalkOptionsOpen: true, + }); + const expandedBtn = expanded.querySelector( + 'button[aria-label="Talk settings"]', + ); + expect(expandedBtn?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("renders Talk settings from its own callback contract", () => { + const onToggleRealtimeTalkOptions = vi.fn(); + const container = renderChatView({ + onToggleRealtimeTalk: undefined, + onToggleRealtimeTalkOptions, + realtimeTalkOptionsOpen: false, + }); + + const settings = container.querySelector( + 'button[aria-label="Talk settings"]', + ); + expect(settings).not.toBeNull(); + expect(container.querySelector('button[aria-label="Start Talk"]')).toBeNull(); + + settings?.click(); + + expect(onToggleRealtimeTalkOptions).toHaveBeenCalledOnce(); + }); + + it("does not render a dead Talk settings button without its callback", () => { + const container = renderChatView({ + onToggleRealtimeTalk: () => undefined, + realtimeTalkOptionsOpen: true, + }); + + expect(container.querySelector('button[aria-label="Start Talk"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="Talk settings"]')).toBeNull(); + }); }); afterEach(() => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index c6f15007e3d9..46bff3581b72 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -27,6 +27,7 @@ import { renderReadingIndicatorGroup, renderStreamingGroup, } from "../chat/grouped-render.ts"; +import { CHAT_HISTORY_RENDER_LIMIT } from "../chat/history-limits.ts"; import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../chat/input-history.ts"; import { PinnedMessages } from "../chat/pinned-messages.ts"; import { getPinnedMessageSummary } from "../chat/pinned-summary.ts"; @@ -126,6 +127,7 @@ export type ChatProps = { disabledReason: string | null; error: string | null; sessions: SessionsListResult | null; + focusMode?: boolean; sidebarOpen?: boolean; sidebarContent?: SidebarContent | null; sidebarError?: string | null; @@ -145,6 +147,7 @@ export type ChatProps = { showNewMessages?: boolean; onScrollToBottom?: () => void; onRefresh: () => void; + onToggleFocusMode?: () => void; getDraft?: () => string; onDraftChange: (next: string) => void; onRequestUpdate?: () => void; @@ -234,6 +237,9 @@ const TALK_REASONING_OPTIONS: TalkSelectOption[] = [ { label: "Medium", value: "medium" }, { label: "High", value: "high" }, ]; +const INITIAL_CHAT_HISTORY_RENDER_WINDOW = 30; +const CHAT_HISTORY_RENDER_WINDOW_BATCH = 30; +const CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX = 48; function getPinnedMessages(sessionKey: string): PinnedMessages { return getOrCreateSessionCacheValue( @@ -251,64 +257,41 @@ function getDeletedMessages(sessionKey: string): DeletedMessages { ); } -function renderTalkSelect(params: { +function renderNativeTalkSelect(params: { label: string; value: string; options: TalkSelectOption[]; onSelect: (value: string) => void; + selectedLabel?: string; }) { - const selected = params.options.find((entry) => entry.value === params.value); - const selectedLabel = selected?.label ?? params.value; + const selectedLabel = + params.selectedLabel ?? params.options.find((entry) => entry.value === params.value)?.label; return html` -