mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
49
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
49
ui/src/i18n/.i18n/raw-copy-baseline.json
generated
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user