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.
This commit is contained in:
Alexzhu
2026-06-03 15:16:32 +08:00
committed by GitHub
parent b6cee3fc35
commit 85e5d486df
8 changed files with 671 additions and 74 deletions

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<ChatItem | MessageGroup> {
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<ChatItem | Mes
text: string | null;
timestamp: number | null;
}>;
const historyStart = resolveHistoryStartIndex(history, props.showToolCalls);
const historyStart = resolveHistoryStartIndex(history, props.showToolCalls, historyRenderLimit);
const hiddenHistoryCount = countVisibleHistoryMessages(
history.slice(0, historyStart),
props.showToolCalls,

View File

@@ -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<HTMLButtonElement>(
'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<HTMLButtonElement>(
'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<HTMLButtonElement>(
'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(() => {

View File

@@ -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`
<label class="agent-chat__talk-field agent-chat__talk-field--select">
<label class="agent-chat__talk-field" data-talk-select=${params.label.toLowerCase()}>
<span>${params.label}</span>
<details class="agent-chat__talk-select" data-talk-select=${params.label.toLowerCase()}>
<summary
class="agent-chat__talk-select-trigger"
aria-label=${params.label}
title=${selectedLabel}
>
<span class="agent-chat__talk-select-label">${selectedLabel}</span>
<span class="agent-chat__talk-select-icon" aria-hidden="true">
${icons.chevronDown}
</span>
</summary>
<div class="agent-chat__talk-select-menu" role="listbox" aria-label=${params.label}>
${repeat(
params.options,
(entry) => entry.value,
(entry) => {
const isSelected = entry.value === params.value;
return html`
<button
class="agent-chat__talk-select-option ${isSelected
? "agent-chat__talk-select-option--selected"
: ""}"
data-talk-select-option=${entry.value}
role="option"
aria-selected=${isSelected ? "true" : "false"}
type="button"
@click=${(event: MouseEvent) => {
(event.currentTarget as HTMLElement)
.closest("details")
?.removeAttribute("open");
if (!isSelected) {
params.onSelect(entry.value);
}
}}
>
<span>${entry.label}</span>
${isSelected
? html`<span class="agent-chat__talk-select-check" aria-hidden="true">
${icons.check}
</span>`
: nothing}
</button>
`;
},
)}
</div>
</details>
${selectedLabel
? html`<span class="agent-chat__talk-select-label">${selectedLabel}</span>`
: nothing}
<select
.value=${params.value}
@change=${(event: Event) =>
params.onSelect((event.currentTarget as HTMLSelectElement).value)}
>
${repeat(
params.options,
(entry) => entry.value,
(entry) => html`
<option
value=${entry.value}
data-talk-select-option=${entry.value}
?selected=${entry.value === params.value}
@click=${() => params.onSelect(entry.value)}
>
${entry.label}
</option>
`,
)}
</select>
</label>
`;
}
@@ -334,10 +317,17 @@ function renderRealtimeTalkOptions(props: ChatProps) {
const sensitivityOptions = isCustomSensitivity
? [...TALK_SENSITIVITY_OPTIONS, { label: "Custom", value: "__custom" }]
: TALK_SENSITIVITY_OPTIONS;
const sensitivityLabel =
sensitivityOptions.find((entry) => entry.value === sensitivityValue)?.label ?? "Custom";
const updateSensitivity = (value: string) => {
if (value !== "__custom") {
onChange({ vadThreshold: value });
}
};
return html`
<div class="agent-chat__talk-options" aria-label="Talk options">
<div class="agent-chat__talk-options-primary">
${renderTalkSelect({
${renderNativeTalkSelect({
label: "Voice",
value: options.voice,
options: TALK_VOICE_OPTIONS,
@@ -352,33 +342,30 @@ function renderRealtimeTalkOptions(props: ChatProps) {
spellcheck="false"
/>
</label>
${renderTalkSelect({
${renderNativeTalkSelect({
label: "Sensitivity",
value: sensitivityValue,
options: sensitivityOptions,
onSelect: (vadThreshold) => {
if (vadThreshold !== "__custom") {
onChange({ vadThreshold });
}
},
selectedLabel: sensitivityLabel,
onSelect: updateSensitivity,
})}
</div>
<details class="agent-chat__talk-options-advanced">
<summary>Advanced</summary>
<div class="agent-chat__talk-options-grid">
${renderTalkSelect({
${renderNativeTalkSelect({
label: "Provider",
value: options.provider,
options: TALK_PROVIDER_OPTIONS,
onSelect: (provider) => onChange({ provider }),
})}
${renderTalkSelect({
${renderNativeTalkSelect({
label: "Transport",
value: options.transport,
options: TALK_TRANSPORT_OPTIONS,
onSelect: (transport) => onChange({ transport }),
})}
${renderTalkSelect({
${renderNativeTalkSelect({
label: "Reasoning",
value: options.reasoningEffort,
options: TALK_REASONING_OPTIONS,
@@ -469,6 +456,17 @@ interface ChatEphemeralState {
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;
}
function createChatEphemeralState(): ChatEphemeralState {
@@ -483,6 +481,14 @@ function createChatEphemeralState(): ChatEphemeralState {
searchOpen: false,
searchQuery: "",
pinnedExpanded: false,
historyRenderSessionKey: null,
historyRenderMessagesRef: null,
historyRenderMessageCount: 0,
historyRenderLimit: 0,
historyRenderLastScrollTop: null,
historyRenderExpansionFrame: null,
historyRenderAnchorAdjustment: null,
historyRenderAnchorFrame: null,
};
}
@@ -542,7 +548,8 @@ function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsP
previous.queue === next.queue &&
previous.showToolCalls === next.showToolCalls &&
previous.searchOpen === next.searchOpen &&
previous.searchQuery === next.searchQuery
previous.searchQuery === next.searchQuery &&
previous.historyRenderLimit === next.historyRenderLimit
);
}
@@ -586,6 +593,12 @@ function stableBooleanMapSignature(values: ReadonlyMap<string, boolean>): string
* Clears search/slash UI that should not survive navigation.
*/
export function resetChatViewState() {
if (vs.historyRenderExpansionFrame != null) {
cancelAnimationFrame(vs.historyRenderExpansionFrame);
}
if (vs.historyRenderAnchorFrame != null) {
cancelAnimationFrame(vs.historyRenderAnchorFrame);
}
Object.assign(vs, createChatEphemeralState());
chatItemsBySession.clear();
composerDraftMirrors.clear();
@@ -593,9 +606,149 @@ export function resetChatViewState() {
export const cleanupChatModuleState = resetChatViewState;
function resolveChatHistoryRenderCap(messageCount: number): number {
return Math.min(Math.max(0, messageCount), CHAT_HISTORY_RENDER_LIMIT);
}
function shouldRenderFullChatHistoryWindow(messageCount: number): boolean {
return (
messageCount <= INITIAL_CHAT_HISTORY_RENDER_WINDOW ||
(vs.searchOpen && vs.searchQuery.trim().length > 0)
);
}
function resolveChatHistoryRenderWindow(props: ChatProps): number {
const messages = Array.isArray(props.messages) ? props.messages : [];
const cap = resolveChatHistoryRenderCap(messages.length);
const sessionChanged = vs.historyRenderSessionKey !== props.sessionKey;
const refChanged = vs.historyRenderMessagesRef !== messages;
const previousCount = vs.historyRenderMessageCount;
if (sessionChanged || (refChanged && previousCount === 0)) {
vs.historyRenderLastScrollTop = null;
}
if (cap === 0) {
vs.historyRenderSessionKey = props.sessionKey;
vs.historyRenderMessagesRef = messages;
vs.historyRenderMessageCount = messages.length;
vs.historyRenderLimit = 0;
vs.historyRenderLastScrollTop = null;
return 0;
}
if (shouldRenderFullChatHistoryWindow(messages.length)) {
vs.historyRenderSessionKey = props.sessionKey;
vs.historyRenderMessagesRef = messages;
vs.historyRenderMessageCount = messages.length;
vs.historyRenderLimit = cap;
return cap;
}
if (sessionChanged || (refChanged && previousCount === 0)) {
vs.historyRenderLimit = Math.min(INITIAL_CHAT_HISTORY_RENDER_WINDOW, cap);
} else if (refChanged) {
const grewBy = messages.length - previousCount;
if (vs.historyRenderLimit >= previousCount) {
vs.historyRenderLimit = cap;
} else if (grewBy > 0 && grewBy <= CHAT_HISTORY_RENDER_WINDOW_BATCH) {
vs.historyRenderLimit = Math.min(cap, vs.historyRenderLimit + grewBy);
} else {
vs.historyRenderLimit = Math.min(
Math.max(vs.historyRenderLimit, INITIAL_CHAT_HISTORY_RENDER_WINDOW),
cap,
);
}
}
vs.historyRenderSessionKey = props.sessionKey;
vs.historyRenderMessagesRef = messages;
vs.historyRenderMessageCount = messages.length;
vs.historyRenderLimit = Math.min(Math.max(1, vs.historyRenderLimit), cap);
return vs.historyRenderLimit;
}
function maybeExpandChatHistoryRenderWindow(event: Event, requestUpdate: () => void) {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
const scrollTop = Math.max(0, target.scrollTop);
const previousScrollTop = vs.historyRenderLastScrollTop;
vs.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(vs.historyRenderMessageCount);
if (vs.historyRenderLimit >= cap) {
return;
}
vs.historyRenderAnchorAdjustment = {
scrollHeight: target.scrollHeight,
scrollTop,
};
scheduleChatHistoryRenderAnchorPreservation(target);
vs.historyRenderLimit = Math.min(cap, vs.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH);
requestUpdate();
}
function scheduleChatHistoryRenderAnchorPreservation(thread: HTMLElement) {
const adjustment = vs.historyRenderAnchorAdjustment;
if (!adjustment || vs.historyRenderAnchorFrame != null) {
return;
}
vs.historyRenderAnchorFrame = requestAnimationFrame(() => {
vs.historyRenderAnchorFrame = null;
vs.historyRenderAnchorAdjustment = null;
const heightDelta = thread.scrollHeight - adjustment.scrollHeight;
if (heightDelta <= 0) {
return;
}
thread.scrollTop = adjustment.scrollTop + heightDelta;
});
}
function scheduleChatHistoryRenderWindowFill(
thread: HTMLElement | null,
requestUpdate: () => void,
scrollToBottom: () => void,
) {
if (!thread || vs.historyRenderExpansionFrame != null) {
return;
}
const cap = resolveChatHistoryRenderCap(vs.historyRenderMessageCount);
if (vs.historyRenderLimit >= cap) {
return;
}
vs.historyRenderExpansionFrame = requestAnimationFrame(() => {
vs.historyRenderExpansionFrame = null;
const nextCap = resolveChatHistoryRenderCap(vs.historyRenderMessageCount);
if (vs.historyRenderLimit >= nextCap) {
return;
}
const canScroll = thread.scrollHeight - thread.clientHeight > 1;
if (canScroll) {
return;
}
vs.historyRenderLimit = Math.min(
nextCap,
vs.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH,
);
requestUpdate();
scrollToBottom();
});
}
function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = `${Math.min(Math.max(el.scrollHeight, 44), 150)}px`;
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
}
function focusComposerFromChrome(event: MouseEvent, connected: boolean) {
@@ -1393,6 +1546,7 @@ export function renderChat(props: ChatProps) {
const deleted = getDeletedMessages(props.sessionKey);
const hasAttachments = (props.attachments?.length ?? 0) > 0;
const tokens = tokenEstimate(visibleDraft);
const composerControls = props.composerControls;
const placeholder = props.connected
? hasAttachments
@@ -1404,6 +1558,7 @@ export function renderChat(props: ChatProps) {
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const displayStream = props.stream ?? null;
const historyRenderLimit = resolveChatHistoryRenderWindow(props);
const handleCodeBlockCopy = (e: Event) => {
const btn = (e.target as HTMLElement).closest(".code-block-copy");
@@ -1419,6 +1574,10 @@ export function renderChat(props: ChatProps) {
() => {},
);
};
const handleChatThreadScroll = (event: Event) => {
maybeExpandChatHistoryRenderWindow(event, requestUpdate);
props.onChatScroll?.(event);
};
const chatItems = buildCachedChatItems({
sessionKey: props.sessionKey,
@@ -1431,6 +1590,7 @@ export function renderChat(props: ChatProps) {
showToolCalls: props.showToolCalls,
searchOpen: vs.searchOpen,
searchQuery: vs.searchQuery,
historyRenderLimit,
});
syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls));
const expandedToolCards = getExpandedToolCards(props.sessionKey);
@@ -1449,7 +1609,15 @@ export function renderChat(props: ChatProps) {
class="chat-thread"
role="log"
aria-live="polite"
@scroll=${props.onChatScroll}
${ref((element) => {
const threadElement = element instanceof HTMLElement ? element : null;
scheduleChatHistoryRenderWindowFill(
threadElement,
requestUpdate,
props.onScrollToBottom ?? (() => {}),
);
})}
@scroll=${handleChatThreadScroll}
@click=${handleCodeBlockCopy}
>
<div class="chat-thread-inner">
@@ -1814,6 +1982,19 @@ export function renderChat(props: ChatProps) {
</div>
`
: nothing}
${props.focusMode && props.onToggleFocusMode
? html`
<button
class="chat-focus-exit"
type="button"
@click=${props.onToggleFocusMode}
aria-label="Exit focus mode"
title="Exit focus mode"
>
${icons.x}
</button>
`
: nothing}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class="chat-workbench">
@@ -1976,7 +2157,7 @@ export function renderChat(props: ChatProps) {
: t("chat.composer.startTalk")}
?disabled=${!props.connected}
>
${props.realtimeTalkActive ? icons.volume2 : icons.mic}
${props.realtimeTalkActive ? icons.volume2 : icons.radio}
<span class="agent-chat__control-label"
>${props.realtimeTalkActive
? t("chat.composer.stopTalk")
@@ -2002,13 +2183,13 @@ export function renderChat(props: ChatProps) {
</button>
`
: nothing}
${props.composerControls
? html`<div class="agent-chat__composer-controls">${props.composerControls}</div>`
: nothing}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
${renderChatRunStatusIndicator(composerRunStatus)}
</div>
${composerControls && composerControls !== nothing
? html`<div class="agent-chat__composer-controls">${composerControls}</div>`
: nothing}
${renderChatRunControls({
canAbort: showAbortableUi,
connected: props.connected,