mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 10:51:15 +00:00
fix: remove staged chat transcript rendering
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -48,6 +48,7 @@ type TestChatPane = HTMLElement & {
|
||||
index: number,
|
||||
) => Record<string, unknown> | null;
|
||||
handleTranscriptScroll: (event: Event) => void;
|
||||
handleTranscriptHistoryIntent: (event: Event) => void;
|
||||
historyAutoLoadBlocked: boolean;
|
||||
transcriptScrollTop: number | null;
|
||||
syncHistoryObserver: () => void;
|
||||
@@ -57,12 +58,15 @@ type TestChatPane = HTMLElement & {
|
||||
loadOlderMessages: () => Promise<void>;
|
||||
hasOlderMessages: () => boolean;
|
||||
restoreHistoryAnchor: () => void;
|
||||
pendingHistoryAnchor: { sessionKey: string; scrollHeight: number; scrollTop: number } | null;
|
||||
pendingHistoryAnchor: {
|
||||
sessionKey: string;
|
||||
element: HTMLElement;
|
||||
viewportTop: number;
|
||||
} | null;
|
||||
loadingOlder: boolean;
|
||||
catalogCursor: string | undefined;
|
||||
olderCursorsSeen: Set<string>;
|
||||
olderOffsetsSeen: Set<number>;
|
||||
nativeHistoryExpanded: boolean;
|
||||
renderPaneHeader: (
|
||||
workspace: ReturnType<typeof createSessionWorkspaceProps>,
|
||||
tasks: ReturnType<typeof createBackgroundTasksProps>,
|
||||
@@ -673,26 +677,28 @@ describe("chat pane catalog session lifecycle", () => {
|
||||
expect(state.handleChatScroll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-arms a blocked auto-load when the manual fallback is clicked", async () => {
|
||||
it("loads a blocked unscrollable transcript from renewed upward intent", async () => {
|
||||
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
|
||||
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
||||
// A short (non-scrollable) thread cannot emit a scroll event, so a blocked
|
||||
// auto-load must be recoverable through the fallback's loadOlderMessages call.
|
||||
state.sessionKey = "catalog:claude:gateway%3Alocal:thread-1";
|
||||
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
||||
pane.historyAutoLoadBlocked = true;
|
||||
pane.loadCatalogSession = vi.fn(async () => false);
|
||||
pane.hasOlderMessages = vi.fn(() => true);
|
||||
pane.loadOlderMessages = vi.fn(async () => undefined);
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
const thread = document.createElement("div");
|
||||
const event = new WheelEvent("wheel", { deltaY: -1 });
|
||||
Object.defineProperty(event, "currentTarget", { value: thread });
|
||||
|
||||
await pane.loadOlderMessages();
|
||||
pane.handleTranscriptHistoryIntent(event);
|
||||
pane.handleTranscriptHistoryIntent(event);
|
||||
await Promise.resolve();
|
||||
|
||||
// loadOlderMessages clears the block on entry, so the retry is not stranded.
|
||||
expect(pane.loadCatalogSession).toHaveBeenCalledOnce();
|
||||
expect(pane.historyAutoLoadBlocked).toBe(true);
|
||||
expect(pane.loadOlderMessages).toHaveBeenCalledOnce();
|
||||
expect(pane.historyAutoLoadBlocked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat pane native history pagination", () => {
|
||||
it("renders every row from a complete imported snapshot", () => {
|
||||
it("does not request older rows from a complete imported snapshot", () => {
|
||||
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
|
||||
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
||||
state.chatHistoryPagination = {
|
||||
@@ -702,7 +708,6 @@ describe("chat pane native history pagination", () => {
|
||||
};
|
||||
|
||||
expect(pane.hasOlderMessages()).toBe(false);
|
||||
expect(pane.nativeHistoryExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-loads a visible sentinel when the initial tail is not scrollable", async () => {
|
||||
@@ -841,11 +846,25 @@ describe("chat pane native history pagination", () => {
|
||||
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
||||
state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)];
|
||||
state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 };
|
||||
let scrollHeight = 600;
|
||||
const thread = document.createElement("div");
|
||||
thread.className = "chat-thread";
|
||||
thread.scrollTop = 40;
|
||||
Object.defineProperty(thread, "scrollHeight", { get: () => scrollHeight });
|
||||
let rowTop = 20;
|
||||
thread.getBoundingClientRect = () =>
|
||||
({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) as DOMRect;
|
||||
const row = document.createElement("div");
|
||||
row.dataset.chatRowKey = "row-3";
|
||||
Object.defineProperty(row, "isConnected", { value: true });
|
||||
row.getBoundingClientRect = () =>
|
||||
({
|
||||
top: rowTop,
|
||||
bottom: rowTop + 20,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
height: 20,
|
||||
}) as DOMRect;
|
||||
thread.append(row);
|
||||
pane.append(thread);
|
||||
|
||||
await pane.loadOlderMessages();
|
||||
@@ -859,10 +878,10 @@ describe("chat pane native history pagination", () => {
|
||||
expect(state.chatHistoryPagination).toEqual({ hasMore: false, totalMessages: 4 });
|
||||
expect(pane.pendingHistoryAnchor).toEqual({
|
||||
sessionKey: state.sessionKey,
|
||||
scrollHeight: 600,
|
||||
scrollTop: 40,
|
||||
element: row,
|
||||
viewportTop: 20,
|
||||
});
|
||||
scrollHeight = 900;
|
||||
rowTop = 320;
|
||||
pane.restoreHistoryAnchor();
|
||||
expect(thread.scrollTop).toBe(340);
|
||||
expect(pane.hasOlderMessages()).toBe(false);
|
||||
@@ -894,6 +913,33 @@ describe("chat pane native history pagination", () => {
|
||||
expect(pane.loadingOlder).toBe(false);
|
||||
});
|
||||
|
||||
it("does not double-correct when native scroll anchoring preserved the visible row", () => {
|
||||
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
|
||||
const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability });
|
||||
const thread = document.createElement("div");
|
||||
thread.className = "chat-thread";
|
||||
thread.scrollTop = 40;
|
||||
thread.getBoundingClientRect = () =>
|
||||
({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) as DOMRect;
|
||||
const row = document.createElement("div");
|
||||
row.dataset.chatRowKey = "row-3";
|
||||
Object.defineProperty(row, "isConnected", { value: true });
|
||||
row.getBoundingClientRect = () =>
|
||||
({ top: 20, bottom: 40, left: 0, right: 100, width: 100, height: 20 }) as DOMRect;
|
||||
thread.append(row);
|
||||
pane.append(thread);
|
||||
pane.pendingHistoryAnchor = {
|
||||
sessionKey: state.sessionKey,
|
||||
element: row,
|
||||
viewportTop: 20,
|
||||
};
|
||||
|
||||
pane.restoreHistoryAnchor();
|
||||
|
||||
expect(thread.scrollTop).toBe(40);
|
||||
expect(pane.pendingHistoryAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes the tail instead of mixing an older page from a replacement session", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
|
||||
@@ -147,10 +147,13 @@ type ChatPageContext = ApplicationContext;
|
||||
type PaneSessionChangeOptions = { replace?: boolean };
|
||||
type ChatHistoryAnchor = {
|
||||
sessionKey: string;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
element: HTMLElement;
|
||||
viewportTop: number;
|
||||
};
|
||||
const CATALOG_TOOL_RESULT_PREVIEW_MAX_CHARS = 500;
|
||||
const CHAT_HISTORY_INTENT_EDGE_PX = 300;
|
||||
const CHAT_HISTORY_INTENT_IDLE_MS = 200;
|
||||
const CHAT_HISTORY_UPWARD_KEYS = new Set(["ArrowUp", "PageUp", "Home"]);
|
||||
|
||||
function catalogRawString(raw: unknown, keys: readonly string[]): string | null {
|
||||
const record = catalogRawRecord(raw);
|
||||
@@ -309,10 +312,11 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
private historyObserverArmed = false;
|
||||
private historyAutoLoadBlocked = false;
|
||||
private historyBootstrapPagesLoaded = 0;
|
||||
private historyIntentConsumed = false;
|
||||
private historyIntentTimer: number | null = null;
|
||||
private transcriptScrollTop: number | null = null;
|
||||
private pendingHistoryAnchor: ChatHistoryAnchor | null = null;
|
||||
private nativePaginationSnapshot: ChatHistoryPagination | null = null;
|
||||
private nativeHistoryExpanded = false;
|
||||
// Older cursors already requested this session. A provider that cycles cursors
|
||||
// (c1 -> c2 -> c1) on empty/duplicate pages would otherwise loop forever, since
|
||||
// the sentinel never scrolls out of view when nothing new renders.
|
||||
@@ -924,7 +928,6 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
if (pagination !== this.nativePaginationSnapshot) {
|
||||
this.nativePaginationSnapshot = pagination;
|
||||
this.olderOffsetsSeen.clear();
|
||||
this.nativeHistoryExpanded = !pagination.hasMore && pagination.completeSnapshot === true;
|
||||
}
|
||||
return pagination.hasMore && !state.chatLoading;
|
||||
}
|
||||
@@ -935,12 +938,16 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
this.historyObserverArmed = false;
|
||||
this.historyAutoLoadBlocked = false;
|
||||
this.historyBootstrapPagesLoaded = 0;
|
||||
this.historyIntentConsumed = false;
|
||||
if (this.historyIntentTimer !== null) {
|
||||
window.clearTimeout(this.historyIntentTimer);
|
||||
this.historyIntentTimer = null;
|
||||
}
|
||||
this.transcriptScrollTop = null;
|
||||
this.pendingHistoryAnchor = null;
|
||||
this.olderCursorsSeen.clear();
|
||||
this.olderOffsetsSeen.clear();
|
||||
this.nativePaginationSnapshot = null;
|
||||
this.nativeHistoryExpanded = false;
|
||||
this.historyObserver?.disconnect();
|
||||
this.historyObserver = null;
|
||||
}
|
||||
@@ -953,17 +960,42 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
this.pendingHistoryAnchor = null;
|
||||
const state = this.state;
|
||||
const thread = this.querySelector<HTMLElement>(".chat-thread");
|
||||
if (!state || !thread || state.sessionKey !== anchor.sessionKey) {
|
||||
if (
|
||||
!state ||
|
||||
!thread ||
|
||||
state.sessionKey !== anchor.sessionKey ||
|
||||
!anchor.element.isConnected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
thread.scrollTop = anchor.scrollTop + (thread.scrollHeight - anchor.scrollHeight);
|
||||
// Chromium usually preserves this through native scroll anchoring. Measure
|
||||
// the row relative to the viewport so this only corrects engines that did not.
|
||||
const nextViewportTop =
|
||||
anchor.element.getBoundingClientRect().top - thread.getBoundingClientRect().top;
|
||||
const delta = nextViewportTop - anchor.viewportTop;
|
||||
if (Math.abs(delta) > 1) {
|
||||
thread.scrollTop += delta;
|
||||
}
|
||||
}
|
||||
|
||||
private currentHistoryAnchor(sessionKey: string): ChatHistoryAnchor | null {
|
||||
const thread = this.querySelector<HTMLElement>(".chat-thread");
|
||||
return thread
|
||||
? { sessionKey, scrollHeight: thread.scrollHeight, scrollTop: thread.scrollTop }
|
||||
: null;
|
||||
if (!thread) {
|
||||
return null;
|
||||
}
|
||||
const viewportRect = thread.getBoundingClientRect();
|
||||
const rows = thread.querySelectorAll<HTMLElement>("[data-chat-row-key]");
|
||||
for (const element of rows) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
|
||||
return {
|
||||
sessionKey,
|
||||
element,
|
||||
viewportTop: rect.top - viewportRect.top,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private syncHistoryObserver(): void {
|
||||
@@ -1014,7 +1046,7 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
if (bootstrap) {
|
||||
this.historyBootstrapPagesLoaded += 1;
|
||||
}
|
||||
void this.loadOlderMessages("observer");
|
||||
void this.loadOlderMessages();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: "300px 0px 0px", threshold: 0 },
|
||||
@@ -1037,14 +1069,16 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
!this.loadingOlder &&
|
||||
root !== null &&
|
||||
previousScrollTop !== null &&
|
||||
root.scrollTop < previousScrollTop;
|
||||
root.scrollTop < previousScrollTop &&
|
||||
root.scrollTop <= CHAT_HISTORY_INTENT_EDGE_PX;
|
||||
const newHistoryIntent = hasUpwardIntent && this.consumeHistoryIntent();
|
||||
// A failed request or exhausted bootstrap stays disarmed until renewed
|
||||
// upward intent, preventing request loops without stranding older history.
|
||||
if (hasUpwardIntent && this.historyAutoLoadBlocked) {
|
||||
if (newHistoryIntent && this.historyAutoLoadBlocked) {
|
||||
this.historyAutoLoadBlocked = false;
|
||||
this.historyObserverArmed = true;
|
||||
this.syncHistoryObserver();
|
||||
} else if (hasUpwardIntent && !this.historyObserverArmed) {
|
||||
} else if (newHistoryIntent && !this.historyObserverArmed) {
|
||||
this.historyObserverArmed = true;
|
||||
this.syncHistoryObserver();
|
||||
}
|
||||
@@ -1053,16 +1087,52 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
this.state?.handleChatScroll(event);
|
||||
}
|
||||
|
||||
private async loadOlderMessages(source: "manual" | "observer" = "manual"): Promise<void> {
|
||||
private consumeHistoryIntent(): boolean {
|
||||
if (this.historyIntentTimer !== null) {
|
||||
window.clearTimeout(this.historyIntentTimer);
|
||||
}
|
||||
this.historyIntentTimer = window.setTimeout(() => {
|
||||
this.historyIntentTimer = null;
|
||||
this.historyIntentConsumed = false;
|
||||
}, CHAT_HISTORY_INTENT_IDLE_MS);
|
||||
if (this.historyIntentConsumed) {
|
||||
return false;
|
||||
}
|
||||
this.historyIntentConsumed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleTranscriptHistoryIntent(event: Event): void {
|
||||
const root = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
const upward =
|
||||
(event instanceof WheelEvent && event.deltaY < 0) ||
|
||||
(event instanceof KeyboardEvent && CHAT_HISTORY_UPWARD_KEYS.has(event.key));
|
||||
if (
|
||||
!root ||
|
||||
!upward ||
|
||||
root.scrollTop > CHAT_HISTORY_INTENT_EDGE_PX ||
|
||||
this.loadingOlder ||
|
||||
!this.hasOlderMessages() ||
|
||||
!this.consumeHistoryIntent()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.historyAutoLoadBlocked = false;
|
||||
if (typeof IntersectionObserver !== "function") {
|
||||
void this.loadOlderMessages();
|
||||
return;
|
||||
}
|
||||
this.historyObserverArmed = true;
|
||||
this.syncHistoryObserver();
|
||||
}
|
||||
|
||||
private async loadOlderMessages(): Promise<void> {
|
||||
const state = this.state;
|
||||
const catalogKey = state ? parseCatalogSessionKey(state.sessionKey) : null;
|
||||
if (!state || this.loadingOlder || !this.hasOlderMessages()) {
|
||||
return;
|
||||
}
|
||||
const generation = ++this.olderLoadGeneration;
|
||||
if (source === "manual") {
|
||||
this.historyAutoLoadBlocked = false;
|
||||
}
|
||||
this.loadingOlder = true;
|
||||
state.requestUpdate();
|
||||
let prepended = false;
|
||||
@@ -1103,9 +1173,6 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
||||
const nextMessages = this.prependUniqueNativeMessages(messages, state.chatMessages);
|
||||
const grew = nextMessages.length > state.chatMessages.length;
|
||||
// Native scroll-back must render the loaded prefix together with the old
|
||||
// viewport; the default tail-only DOM cap would hide every prepended page.
|
||||
this.nativeHistoryExpanded ||= grew;
|
||||
this.pendingHistoryAnchor = grew ? this.currentHistoryAnchor(state.sessionKey) : null;
|
||||
state.chatMessages = nextMessages;
|
||||
const appliedPagination: ChatHistoryPagination = exhausted
|
||||
@@ -1891,21 +1958,10 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
compactionStatus: state.compactionStatus,
|
||||
fallbackStatus: state.fallbackStatus,
|
||||
messages: catalogKey ? this.catalogMessages : state.chatMessages,
|
||||
renderAllLoadedHistory:
|
||||
!catalogKey &&
|
||||
(this.nativeHistoryExpanded ||
|
||||
(state.chatHistoryPagination?.hasMore === false &&
|
||||
state.chatHistoryPagination.completeSnapshot === true)),
|
||||
historyPagination:
|
||||
catalogKey || state.chatHistoryPagination?.hasMore || this.loadingOlder
|
||||
? {
|
||||
loading: this.loadingOlder,
|
||||
// A non-scrollable thread cannot express renewed upward intent, so
|
||||
// the button remains the recovery path after failure or bootstrap.
|
||||
manualFallback:
|
||||
this.hasOlderMessages() &&
|
||||
(typeof IntersectionObserver !== "function" || this.historyAutoLoadBlocked),
|
||||
onLoadOlder: () => void this.loadOlderMessages(),
|
||||
}
|
||||
: undefined,
|
||||
sideChatTurns: catalogKey ? [] : state.chatSideChatTurns,
|
||||
@@ -2018,6 +2074,7 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false });
|
||||
},
|
||||
onChatScroll: (event) => this.handleTranscriptScroll(event),
|
||||
onHistoryIntent: (event) => this.handleTranscriptHistoryIntent(event),
|
||||
getDraft: () => state.chatMessage,
|
||||
onDraftChange: state.handleChatDraftChange,
|
||||
onRequestUpdate: state.requestUpdate,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -11,10 +11,6 @@ import type {
|
||||
NormalizedMessage,
|
||||
ToolCard,
|
||||
} from "../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
CHAT_HISTORY_RENDER_CHAR_BUDGET,
|
||||
CHAT_HISTORY_RENDER_LIMIT,
|
||||
} from "../../lib/chat/chat-types.ts";
|
||||
import {
|
||||
streamSegmentHasItemId,
|
||||
streamSegmentUsesAccumulatedText,
|
||||
@@ -67,8 +63,6 @@ type BuildChatItemsProps = {
|
||||
loading?: boolean;
|
||||
searchOpen?: boolean;
|
||||
searchQuery?: string;
|
||||
historyRenderLimit?: number;
|
||||
allowExpandedHistoryRenderLimit?: boolean;
|
||||
};
|
||||
|
||||
type CachedChatItems = {
|
||||
@@ -1011,9 +1005,7 @@ function rawMessageTimestamp(message: unknown): number | null {
|
||||
function chatItemTimestamp(item: ChatItem): number | null {
|
||||
switch (item.kind) {
|
||||
case "message":
|
||||
return item.key === "chat:history:notice"
|
||||
? Number.NEGATIVE_INFINITY
|
||||
: rawMessageTimestamp(item.message);
|
||||
return rawMessageTimestamp(item.message);
|
||||
case "divider":
|
||||
return item.timestamp;
|
||||
case "stream":
|
||||
@@ -1087,175 +1079,8 @@ function sortChatItemsByVisibleTime(
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
type RawContentEstimateState = {
|
||||
visited: WeakSet<object>;
|
||||
nodes: number;
|
||||
};
|
||||
|
||||
const RAW_CONTENT_ESTIMATE_MAX_DEPTH = 8;
|
||||
const RAW_CONTENT_ESTIMATE_MAX_NODES = 400;
|
||||
|
||||
function addCapped(total: number, amount: number, limit: number): number {
|
||||
return Math.min(limit, total + Math.max(0, amount));
|
||||
}
|
||||
|
||||
function estimateRawContentChars(
|
||||
value: unknown,
|
||||
limit: number,
|
||||
state: RawContentEstimateState,
|
||||
depth = 0,
|
||||
): number {
|
||||
if (limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return Math.min(value.length, limit);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return 0;
|
||||
}
|
||||
if (depth >= RAW_CONTENT_ESTIMATE_MAX_DEPTH || state.nodes >= RAW_CONTENT_ESTIMATE_MAX_NODES) {
|
||||
return 0;
|
||||
}
|
||||
if (state.visited.has(value)) {
|
||||
return 0;
|
||||
}
|
||||
state.visited.add(value);
|
||||
state.nodes += 1;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let chars = 0;
|
||||
for (const item of value) {
|
||||
chars = addCapped(
|
||||
chars,
|
||||
estimateRawContentChars(item, limit - chars, state, depth + 1),
|
||||
limit,
|
||||
);
|
||||
if (chars >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
let chars = 0;
|
||||
for (const key of ["text", "content", "args", "arguments", "input"] as const) {
|
||||
chars = addCapped(
|
||||
chars,
|
||||
estimateRawContentChars(record[key], limit - chars, state, depth + 1),
|
||||
limit,
|
||||
);
|
||||
if (chars >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
function estimateMessageRenderChars(message: unknown, limit: number): number {
|
||||
const record = asRecord(message);
|
||||
if (!record) {
|
||||
return 1;
|
||||
}
|
||||
const state: RawContentEstimateState = { visited: new WeakSet<object>(), nodes: 0 };
|
||||
let chars = 0;
|
||||
for (const key of ["content", "text", "args", "arguments", "input"] as const) {
|
||||
chars = addCapped(chars, estimateRawContentChars(record[key], limit - chars, state), limit);
|
||||
if (chars >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Math.max(chars, 1);
|
||||
}
|
||||
|
||||
function isHiddenToolMessage(message: unknown, showToolCalls: boolean): boolean {
|
||||
if (showToolCalls) {
|
||||
return false;
|
||||
}
|
||||
return safeNormalizeMessage(message)?.role.toLowerCase() === "toolresult";
|
||||
}
|
||||
|
||||
function countVisibleHistoryMessages(messages: unknown[], showToolCalls: boolean): number {
|
||||
let count = 0;
|
||||
for (const message of messages) {
|
||||
if (!isHiddenToolMessage(message, showToolCalls)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function resolveHistoryRenderLimit(limit: number | undefined, allowExpanded = false): number {
|
||||
if (typeof limit !== "number" || !Number.isFinite(limit)) {
|
||||
return CHAT_HISTORY_RENDER_LIMIT;
|
||||
}
|
||||
const normalized = Math.max(1, Math.floor(limit));
|
||||
return allowExpanded ? normalized : Math.min(CHAT_HISTORY_RENDER_LIMIT, normalized);
|
||||
}
|
||||
|
||||
function resolveHistoryStartIndex(
|
||||
messages: unknown[],
|
||||
showToolCalls: boolean,
|
||||
renderLimit: number,
|
||||
): number {
|
||||
let visibleCount = 0;
|
||||
let renderChars = 0;
|
||||
let startIndex = messages.length;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (isHiddenToolMessage(message, showToolCalls)) {
|
||||
continue;
|
||||
}
|
||||
if (visibleCount >= renderLimit) {
|
||||
break;
|
||||
}
|
||||
const remainingBudget = Math.max(1, CHAT_HISTORY_RENDER_CHAR_BUDGET - renderChars + 1);
|
||||
const messageChars = estimateMessageRenderChars(message, remainingBudget);
|
||||
if (visibleCount > 0 && renderChars + messageChars > CHAT_HISTORY_RENDER_CHAR_BUDGET) {
|
||||
break;
|
||||
}
|
||||
renderChars += messageChars;
|
||||
visibleCount += 1;
|
||||
startIndex = index;
|
||||
}
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
function expandHistoryStartForPersistedPreviews(messages: unknown[], historyStart: number): number {
|
||||
const firstVisible = safeNormalizeMessage(messages[historyStart]);
|
||||
if (!firstVisible || normalizeRoleForGrouping(firstVisible.role).toLowerCase() !== "assistant") {
|
||||
return historyStart;
|
||||
}
|
||||
let expandedStart = historyStart;
|
||||
for (let index = historyStart - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const normalized = safeNormalizeMessage(message);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const normalizedRole = normalized.role.toLowerCase();
|
||||
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
|
||||
if (role === "user" || role === "system") {
|
||||
break;
|
||||
}
|
||||
if (normalizedRole === "toolresult" && extractChatMessagePreview(message)) {
|
||||
expandedStart = index;
|
||||
continue;
|
||||
}
|
||||
if (role === "assistant" && hasRenderableNormalizedMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return expandedStart;
|
||||
}
|
||||
|
||||
function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
|
||||
let items: ChatItem[] = [];
|
||||
const historyRenderLimit = resolveHistoryRenderLimit(
|
||||
props.historyRenderLimit,
|
||||
props.allowExpandedHistoryRenderLimit,
|
||||
);
|
||||
const history = (Array.isArray(props.messages) ? props.messages : []).filter(
|
||||
(message) => !isAssistantHeartbeatAckForDisplay(message),
|
||||
);
|
||||
@@ -1278,28 +1103,7 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
|
||||
persistedCanvasIdentities.add(baseIdentity);
|
||||
}
|
||||
}
|
||||
const historyStart = resolveHistoryStartIndex(history, props.showToolCalls, historyRenderLimit);
|
||||
const previewHistoryStart = expandHistoryStartForPersistedPreviews(history, historyStart);
|
||||
const hiddenHistoryCount = countVisibleHistoryMessages(
|
||||
history.slice(0, previewHistoryStart),
|
||||
props.showToolCalls,
|
||||
);
|
||||
const visibleHistoryCount = countVisibleHistoryMessages(
|
||||
history.slice(previewHistoryStart),
|
||||
props.showToolCalls,
|
||||
);
|
||||
if (hiddenHistoryCount > 0) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: "chat:history:notice",
|
||||
message: {
|
||||
role: "system",
|
||||
content: `Showing last ${visibleHistoryCount} messages (${hiddenHistoryCount} hidden).`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let i = previewHistoryStart; i < history.length; i++) {
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const msg = history[i];
|
||||
const normalized = safeNormalizeMessage(msg);
|
||||
if (!normalized) {
|
||||
@@ -1735,9 +1539,7 @@ function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsP
|
||||
previous.runWorking === next.runWorking &&
|
||||
previous.loading === next.loading &&
|
||||
previous.searchOpen === next.searchOpen &&
|
||||
previous.searchQuery === next.searchQuery &&
|
||||
previous.historyRenderLimit === next.historyRenderLimit &&
|
||||
previous.allowExpandedHistoryRenderLimit === next.allowExpandedHistoryRenderLimit
|
||||
previous.searchQuery === next.searchQuery
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -696,8 +696,6 @@ describe("chat history pagination", () => {
|
||||
const container = renderChatView({
|
||||
historyPagination: {
|
||||
loading: true,
|
||||
manualFallback: false,
|
||||
onLoadOlder: () => undefined,
|
||||
},
|
||||
});
|
||||
const threadInner = requireElement(container, ".chat-thread-inner", "chat thread inner");
|
||||
@@ -711,24 +709,21 @@ describe("chat history pagination", () => {
|
||||
expect(sentinel.querySelector("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a manual button only when IntersectionObserver is unavailable", () => {
|
||||
const onLoadOlder = vi.fn();
|
||||
it("loads older history from upward wheel and keyboard intent without a button", () => {
|
||||
const onHistoryIntent = vi.fn();
|
||||
const container = renderChatView({
|
||||
historyPagination: {
|
||||
loading: false,
|
||||
manualFallback: true,
|
||||
onLoadOlder,
|
||||
},
|
||||
onHistoryIntent,
|
||||
});
|
||||
const button = requireElement(
|
||||
container,
|
||||
".chat-history-fallback",
|
||||
"history fallback",
|
||||
) as HTMLButtonElement;
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread");
|
||||
const sentinel = requireElement(container, ".chat-history-sentinel", "history sentinel");
|
||||
|
||||
expect(button.textContent?.trim()).toBe(t("chat.loadOlder"));
|
||||
button.click();
|
||||
expect(onLoadOlder).toHaveBeenCalledTimes(1);
|
||||
expect(sentinel.querySelector("button")).toBeNull();
|
||||
thread.dispatchEvent(new WheelEvent("wheel", { deltaY: -1, bubbles: true }));
|
||||
thread.dispatchEvent(new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }));
|
||||
expect(onHistoryIntent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -955,25 +950,8 @@ describe("chat code-block copy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat history render window", () => {
|
||||
it("starts freshly loaded large histories with a small render window", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message ${index}`,
|
||||
timestamp: index,
|
||||
}));
|
||||
|
||||
renderChatView({ messages });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
historyRenderLimit: 30,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("expands the history render window when the user scrolls to the top", () => {
|
||||
describe("chat transcript rendering", () => {
|
||||
it("passes the full loaded history to one render path and leaves scroll ownership to the pane", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message ${index}`,
|
||||
@@ -983,205 +961,17 @@ describe("chat history render window", () => {
|
||||
const onChatScroll = vi.fn();
|
||||
|
||||
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
|
||||
thread.scrollTop = 120;
|
||||
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
thread.scrollTop = 0;
|
||||
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onChatScroll).toHaveBeenCalledTimes(2);
|
||||
|
||||
buildChatItemsMock.mockClear();
|
||||
renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
historyRenderLimit: 60,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the visible anchor across repeated top-scroll expansion", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message ${index}`,
|
||||
timestamp: index,
|
||||
}));
|
||||
const onRequestUpdate = vi.fn();
|
||||
const onChatScroll = vi.fn();
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
|
||||
Object.defineProperties(thread, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 300 },
|
||||
});
|
||||
thread.scrollTop = 0;
|
||||
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
Object.defineProperty(thread, "scrollHeight", { configurable: true, value: 600 });
|
||||
buildChatItemsMock.mockClear();
|
||||
renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
historyRenderLimit: 60,
|
||||
}),
|
||||
);
|
||||
const firstExpandedThread = requireElement(
|
||||
container,
|
||||
".chat-thread",
|
||||
"chat thread",
|
||||
) as HTMLElement;
|
||||
Object.defineProperties(firstExpandedThread, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 600 },
|
||||
});
|
||||
for (const callback of frameCallbacks.splice(0)) {
|
||||
callback(0);
|
||||
}
|
||||
expect(firstExpandedThread.scrollTop).toBe(300);
|
||||
|
||||
firstExpandedThread.scrollTop = 0;
|
||||
firstExpandedThread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
buildChatItemsMock.mockClear();
|
||||
renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
historyRenderLimit: 80,
|
||||
}),
|
||||
);
|
||||
const secondExpandedThread = requireElement(
|
||||
container,
|
||||
".chat-thread",
|
||||
"chat thread",
|
||||
) as HTMLElement;
|
||||
Object.defineProperties(secondExpandedThread, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 900 },
|
||||
});
|
||||
for (const callback of frameCallbacks.splice(0)) {
|
||||
callback(0);
|
||||
}
|
||||
expect(secondExpandedThread.scrollTop).toBe(300);
|
||||
expect(onRequestUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(onChatScroll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not expand the history render window for bottom auto-scrolls inside the top threshold", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message ${index}`,
|
||||
timestamp: index,
|
||||
}));
|
||||
const onRequestUpdate = vi.fn();
|
||||
const onChatScroll = vi.fn();
|
||||
|
||||
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
|
||||
thread.scrollTop = 30;
|
||||
|
||||
const input = buildChatItemsMock.mock.lastCall?.[0] as Record<string, unknown>;
|
||||
expect(input.messages).toBe(messages);
|
||||
expect(input).not.toHaveProperty("historyRenderLimit");
|
||||
|
||||
onRequestUpdate.mockClear();
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread");
|
||||
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
expect(onChatScroll).toHaveBeenCalledOnce();
|
||||
expect(onRequestUpdate).not.toHaveBeenCalled();
|
||||
expect(onChatScroll).toHaveBeenCalledTimes(1);
|
||||
|
||||
buildChatItemsMock.mockClear();
|
||||
const rerenderedContainer = renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
historyRenderLimit: 30,
|
||||
}),
|
||||
);
|
||||
|
||||
const rerenderedThread = requireElement(
|
||||
rerenderedContainer,
|
||||
".chat-thread",
|
||||
"chat thread",
|
||||
) as HTMLElement;
|
||||
rerenderedThread.scrollTop = 0;
|
||||
rerenderedThread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onChatScroll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("expands the history render window when the thread is already at the top", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message ${index}`,
|
||||
timestamp: index,
|
||||
}));
|
||||
const onRequestUpdate = vi.fn();
|
||||
const onChatScroll = vi.fn();
|
||||
|
||||
const container = renderChatView({ messages, onRequestUpdate, onChatScroll });
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread") as HTMLElement;
|
||||
thread.scrollTop = 0;
|
||||
thread.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
|
||||
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onChatScroll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("expands the render window after render when the initial window cannot scroll", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message ${index}`,
|
||||
timestamp: index,
|
||||
}));
|
||||
const onRequestUpdate = vi.fn();
|
||||
const onScrollToBottom = vi.fn();
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
renderChatView({ messages, onRequestUpdate, onScrollToBottom });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
historyRenderLimit: 30,
|
||||
}),
|
||||
);
|
||||
expect(frameCallbacks).toHaveLength(1);
|
||||
|
||||
itemAt(frameCallbacks, 0, "history growth frame")(0);
|
||||
|
||||
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(onScrollToBottom).toHaveBeenCalledTimes(1);
|
||||
|
||||
buildChatItemsMock.mockClear();
|
||||
renderChatView({ messages, onRequestUpdate, onScrollToBottom });
|
||||
|
||||
expect(buildChatItemsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
messages,
|
||||
historyRenderLimit: 60,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ export function renderChatDivider(
|
||||
onOpenSessionCheckpoints?: () => void | Promise<void>,
|
||||
) {
|
||||
return html`
|
||||
<div class="chat-divider" data-ts=${String(item.timestamp)}>
|
||||
<div class="chat-divider" data-chat-row-key=${item.key} data-ts=${String(item.timestamp)}>
|
||||
<div
|
||||
class="chat-divider__rule"
|
||||
role="separator"
|
||||
|
||||
@@ -535,6 +535,7 @@ describe("grouped chat rendering", () => {
|
||||
|
||||
const bubble = expectElement(container, ".chat-bubble", HTMLElement);
|
||||
expect(bubble.classList.contains("fade-in")).toBe(false);
|
||||
expect(expectElement(container, ".chat-group", HTMLElement).dataset.chatRowKey).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the displayed answer for assistant message actions", () => {
|
||||
|
||||
@@ -584,7 +584,10 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt
|
||||
: renderChatAvatar("assistant", assistant, undefined, basePath, authToken);
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant ${indicatorOnly ? "chat-group--working" : ""}">
|
||||
<div
|
||||
class="chat-group assistant ${indicatorOnly ? "chat-group--working" : ""}"
|
||||
data-chat-row-key=${parts[0]?.key ?? nothing}
|
||||
>
|
||||
${avatar}
|
||||
<div class="chat-group-messages">
|
||||
${parts.map((part) =>
|
||||
@@ -622,13 +625,13 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt
|
||||
* the turn's done indicator; the expanded groups render after this row.
|
||||
*/
|
||||
export function renderWorkGroupSummary(
|
||||
item: { durationMs: number | null; hasError: boolean },
|
||||
item: { key: string; durationMs: number | null; hasError: boolean },
|
||||
opts: { expanded: boolean; onToggle: () => void },
|
||||
) {
|
||||
const duration = formatDurationCompact(item.durationMs, { spaced: true });
|
||||
const label = duration ? t("chat.workRun.workedFor", { duration }) : t("chat.workRun.worked");
|
||||
return html`
|
||||
<div class="chat-group tool chat-group--work">
|
||||
<div class="chat-group tool chat-group--work" data-chat-row-key=${item.key}>
|
||||
<span class="chat-work-group__gutter" aria-hidden="true"></span>
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-activity-group chat-work-group ${opts.expanded ? "is-open" : ""}">
|
||||
@@ -787,7 +790,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? hasError;
|
||||
|
||||
return html`
|
||||
<div class="chat-group tool chat-group--activity">
|
||||
<div class="chat-group tool chat-group--activity" data-chat-row-key=${group.key}>
|
||||
${renderChatAvatar(
|
||||
group.role,
|
||||
{
|
||||
@@ -865,7 +868,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
const footerActionDetails = messageActionDetails[lastMessageIndex] ?? null;
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
<div class="chat-group ${roleClass}" data-chat-row-key=${group.key}>
|
||||
${renderChatAvatar(
|
||||
group.role,
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { guard } from "lit/directives/guard.js";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { classifySessionKind } from "../../../../../src/sessions/classify-session-kind.js";
|
||||
import type { SessionsListResult } from "../../../api/types.ts";
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
markdownFileLinkFromEvent,
|
||||
} from "../../../components/markdown.ts";
|
||||
import { i18n, t } from "../../../i18n/index.ts";
|
||||
import { CHAT_HISTORY_RENDER_LIMIT } from "../../../lib/chat/chat-types.ts";
|
||||
import type {
|
||||
ChatQueueItem,
|
||||
ChatStreamSegment,
|
||||
@@ -65,9 +63,6 @@ import { renderWelcomeState, resolveAssistantDisplayAvatar } from "./chat-welcom
|
||||
|
||||
const pinnedMessagesMap = new Map<string, PinnedMessages>();
|
||||
const deletedMessagesMap = new Map<string, DeletedMessages>();
|
||||
const INITIAL_CHAT_HISTORY_RENDER_WINDOW = 30;
|
||||
const CHAT_HISTORY_RENDER_WINDOW_BATCH = 30;
|
||||
const CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX = 48;
|
||||
|
||||
type ReplyTarget = {
|
||||
messageId: string;
|
||||
@@ -79,17 +74,6 @@ type ChatThreadState = {
|
||||
searchOpen: boolean;
|
||||
searchQuery: string;
|
||||
pinnedExpanded: boolean;
|
||||
historyRenderSessionKey: string | null;
|
||||
historyRenderMessagesRef: unknown[] | null;
|
||||
historyRenderMessageCount: number;
|
||||
historyRenderLimit: number;
|
||||
historyRenderLastScrollTop: number | null;
|
||||
historyRenderExpansionFrame: number | null;
|
||||
historyRenderAnchorAdjustment: {
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
} | null;
|
||||
historyRenderAnchorFrame: number | null;
|
||||
transcriptRenderDependencies: readonly unknown[];
|
||||
transcriptRenderContext: object;
|
||||
};
|
||||
@@ -100,10 +84,7 @@ type ChatThreadProps = {
|
||||
loading: boolean;
|
||||
historyPagination?: {
|
||||
loading: boolean;
|
||||
manualFallback: boolean;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
renderAllLoadedHistory?: boolean;
|
||||
messages: unknown[];
|
||||
toolMessages: unknown[];
|
||||
streamSegments: ChatStreamSegment[];
|
||||
@@ -139,8 +120,8 @@ type ChatThreadProps = {
|
||||
onOpenSessionCheckpoints?: () => void | Promise<void>;
|
||||
onAssistantAttachmentLoaded?: () => void;
|
||||
onRequestUpdate?: () => void;
|
||||
onScrollToBottom?: () => void;
|
||||
onChatScroll?: (event: Event) => void;
|
||||
onHistoryIntent?: (event: Event) => void;
|
||||
onDraftChange: (next: string) => void;
|
||||
/** Current composer draft; the selection popup preserves it when prefilling. */
|
||||
getDraft?: () => string;
|
||||
@@ -164,14 +145,6 @@ function createChatThreadState(): ChatThreadState {
|
||||
searchOpen: false,
|
||||
searchQuery: "",
|
||||
pinnedExpanded: false,
|
||||
historyRenderSessionKey: null,
|
||||
historyRenderMessagesRef: null,
|
||||
historyRenderMessageCount: 0,
|
||||
historyRenderLimit: 0,
|
||||
historyRenderLastScrollTop: null,
|
||||
historyRenderExpansionFrame: null,
|
||||
historyRenderAnchorAdjustment: null,
|
||||
historyRenderAnchorFrame: null,
|
||||
transcriptRenderDependencies: [],
|
||||
transcriptRenderContext: {},
|
||||
};
|
||||
@@ -214,17 +187,6 @@ export function resetChatThreadPresentationState(paneId?: string) {
|
||||
// The selection popup is body-portaled; pane teardown/route changes must
|
||||
// drop it so it cannot outlive the render that owns its callbacks.
|
||||
removeChatSelectionPopup();
|
||||
const states = paneId
|
||||
? ([threadStates.get(paneId)].filter(Boolean) as ChatThreadState[])
|
||||
: [...threadStates.values()];
|
||||
for (const state of states) {
|
||||
if (state.historyRenderExpansionFrame != null) {
|
||||
cancelAnimationFrame(state.historyRenderExpansionFrame);
|
||||
}
|
||||
if (state.historyRenderAnchorFrame != null) {
|
||||
cancelAnimationFrame(state.historyRenderAnchorFrame);
|
||||
}
|
||||
}
|
||||
if (paneId) {
|
||||
threadStates.delete(paneId);
|
||||
} else {
|
||||
@@ -233,158 +195,6 @@ export function resetChatThreadPresentationState(paneId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveChatHistoryRenderCap(messageCount: number, renderAllLoadedHistory = false): number {
|
||||
const count = Math.max(0, messageCount);
|
||||
return renderAllLoadedHistory ? count : Math.min(count, CHAT_HISTORY_RENDER_LIMIT);
|
||||
}
|
||||
|
||||
function shouldRenderFullChatHistoryWindow(state: ChatThreadState, messageCount: number): boolean {
|
||||
return (
|
||||
messageCount <= INITIAL_CHAT_HISTORY_RENDER_WINDOW ||
|
||||
(state.searchOpen && state.searchQuery.trim().length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveChatHistoryRenderWindow(
|
||||
props: Pick<ChatThreadProps, "paneId" | "sessionKey" | "messages" | "renderAllLoadedHistory">,
|
||||
) {
|
||||
const state = getChatThreadState(props.paneId);
|
||||
const messages = Array.isArray(props.messages) ? props.messages : [];
|
||||
const cap = resolveChatHistoryRenderCap(messages.length, props.renderAllLoadedHistory);
|
||||
const sessionChanged = state.historyRenderSessionKey !== props.sessionKey;
|
||||
const refChanged = state.historyRenderMessagesRef !== messages;
|
||||
const previousCount = state.historyRenderMessageCount;
|
||||
if (sessionChanged || (refChanged && previousCount === 0)) {
|
||||
state.historyRenderLastScrollTop = null;
|
||||
}
|
||||
|
||||
if (cap === 0) {
|
||||
state.historyRenderSessionKey = props.sessionKey;
|
||||
state.historyRenderMessagesRef = messages;
|
||||
state.historyRenderMessageCount = messages.length;
|
||||
state.historyRenderLimit = 0;
|
||||
state.historyRenderLastScrollTop = null;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (props.renderAllLoadedHistory || shouldRenderFullChatHistoryWindow(state, messages.length)) {
|
||||
state.historyRenderSessionKey = props.sessionKey;
|
||||
state.historyRenderMessagesRef = messages;
|
||||
state.historyRenderMessageCount = messages.length;
|
||||
state.historyRenderLimit = cap;
|
||||
return cap;
|
||||
}
|
||||
|
||||
if (sessionChanged || (refChanged && previousCount === 0)) {
|
||||
state.historyRenderLimit = Math.min(INITIAL_CHAT_HISTORY_RENDER_WINDOW, cap);
|
||||
} else if (refChanged) {
|
||||
const grewBy = messages.length - previousCount;
|
||||
if (state.historyRenderLimit >= previousCount) {
|
||||
state.historyRenderLimit = cap;
|
||||
} else if (grewBy > 0 && grewBy <= CHAT_HISTORY_RENDER_WINDOW_BATCH) {
|
||||
state.historyRenderLimit = Math.min(cap, state.historyRenderLimit + grewBy);
|
||||
} else {
|
||||
state.historyRenderLimit = Math.min(
|
||||
Math.max(state.historyRenderLimit, INITIAL_CHAT_HISTORY_RENDER_WINDOW),
|
||||
cap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.historyRenderSessionKey = props.sessionKey;
|
||||
state.historyRenderMessagesRef = messages;
|
||||
state.historyRenderMessageCount = messages.length;
|
||||
state.historyRenderLimit = Math.min(Math.max(1, state.historyRenderLimit), cap);
|
||||
return state.historyRenderLimit;
|
||||
}
|
||||
|
||||
function maybeExpandChatHistoryRenderWindow(
|
||||
state: ChatThreadState,
|
||||
event: Event,
|
||||
requestUpdate: () => void,
|
||||
) {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const scrollTop = Math.max(0, target.scrollTop);
|
||||
const previousScrollTop = state.historyRenderLastScrollTop;
|
||||
state.historyRenderLastScrollTop = scrollTop;
|
||||
const distanceFromBottom = Math.max(0, target.scrollHeight - scrollTop - target.clientHeight);
|
||||
const isTop = scrollTop <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX;
|
||||
const isBottomAutoScroll =
|
||||
scrollTop > 0 && distanceFromBottom <= CHAT_HISTORY_RENDER_EXPAND_SCROLL_TOP_PX;
|
||||
const isTopScrollUp =
|
||||
isTop &&
|
||||
(scrollTop === 0 ||
|
||||
(!isBottomAutoScroll && (previousScrollTop == null || scrollTop < previousScrollTop)));
|
||||
if (!isTopScrollUp) {
|
||||
return;
|
||||
}
|
||||
const cap = resolveChatHistoryRenderCap(state.historyRenderMessageCount);
|
||||
if (state.historyRenderLimit >= cap) {
|
||||
return;
|
||||
}
|
||||
state.historyRenderAnchorAdjustment = {
|
||||
scrollHeight: target.scrollHeight,
|
||||
scrollTop,
|
||||
};
|
||||
scheduleChatHistoryRenderAnchorPreservation(state, target);
|
||||
state.historyRenderLimit = Math.min(
|
||||
cap,
|
||||
state.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH,
|
||||
);
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
function scheduleChatHistoryRenderAnchorPreservation(state: ChatThreadState, thread: HTMLElement) {
|
||||
const adjustment = state.historyRenderAnchorAdjustment;
|
||||
if (!adjustment || state.historyRenderAnchorFrame != null) {
|
||||
return;
|
||||
}
|
||||
state.historyRenderAnchorFrame = requestAnimationFrame(() => {
|
||||
state.historyRenderAnchorFrame = null;
|
||||
state.historyRenderAnchorAdjustment = null;
|
||||
const heightDelta = thread.scrollHeight - adjustment.scrollHeight;
|
||||
if (heightDelta <= 0) {
|
||||
return;
|
||||
}
|
||||
thread.scrollTop = adjustment.scrollTop + heightDelta;
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleChatHistoryRenderWindowFill(
|
||||
state: ChatThreadState,
|
||||
thread: HTMLElement | null,
|
||||
requestUpdate: () => void,
|
||||
scrollToBottom: () => void,
|
||||
) {
|
||||
if (!thread || state.historyRenderExpansionFrame != null) {
|
||||
return;
|
||||
}
|
||||
const cap = resolveChatHistoryRenderCap(state.historyRenderMessageCount);
|
||||
if (state.historyRenderLimit >= cap) {
|
||||
return;
|
||||
}
|
||||
state.historyRenderExpansionFrame = requestAnimationFrame(() => {
|
||||
state.historyRenderExpansionFrame = null;
|
||||
const nextCap = resolveChatHistoryRenderCap(state.historyRenderMessageCount);
|
||||
if (state.historyRenderLimit >= nextCap) {
|
||||
return;
|
||||
}
|
||||
const canScroll = thread.scrollHeight - thread.clientHeight > 1;
|
||||
if (canScroll) {
|
||||
return;
|
||||
}
|
||||
state.historyRenderLimit = Math.min(
|
||||
nextCap,
|
||||
state.historyRenderLimit + CHAT_HISTORY_RENDER_WINDOW_BATCH,
|
||||
);
|
||||
requestUpdate();
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
export function renderChatSearchBar(
|
||||
paneId: string,
|
||||
requestUpdate: () => void,
|
||||
@@ -785,7 +595,6 @@ export function renderChatThread(props: ChatThreadProps) {
|
||||
name: props.assistantName,
|
||||
avatar: resolveAssistantDisplayAvatar(props),
|
||||
};
|
||||
const historyRenderLimit = resolveChatHistoryRenderWindow(props);
|
||||
const deleted = getDeletedMessages(props.sessionKey);
|
||||
const locale = i18n.getLocale();
|
||||
const chatItems = buildCachedChatItems({
|
||||
@@ -805,8 +614,6 @@ export function renderChatThread(props: ChatThreadProps) {
|
||||
loading: props.loading,
|
||||
searchOpen: state.searchOpen,
|
||||
searchQuery: state.searchQuery,
|
||||
historyRenderLimit,
|
||||
allowExpandedHistoryRenderLimit: props.renderAllLoadedHistory,
|
||||
});
|
||||
syncToolCardExpansionState(props.sessionKey, chatItems, Boolean(props.autoExpandToolCalls));
|
||||
const expandedToolCards = getExpandedToolCards(props.sessionKey);
|
||||
@@ -837,26 +644,15 @@ export function renderChatThread(props: ChatThreadProps) {
|
||||
const showLoadingSkeleton = props.loading && chatItems.length === 0;
|
||||
const threadContextWindow =
|
||||
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null;
|
||||
const handleChatThreadScroll = (event: Event) => {
|
||||
maybeExpandChatHistoryRenderWindow(state, event, requestUpdate);
|
||||
props.onChatScroll?.(event);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chat-thread ${isDirectThread ? "chat-thread--direct" : ""}"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
${ref((element) => {
|
||||
const threadElement = element instanceof HTMLElement ? element : null;
|
||||
scheduleChatHistoryRenderWindowFill(
|
||||
state,
|
||||
threadElement,
|
||||
requestUpdate,
|
||||
props.onScrollToBottom ?? (() => {}),
|
||||
);
|
||||
})}
|
||||
@scroll=${handleChatThreadScroll}
|
||||
tabindex="0"
|
||||
@scroll=${props.onChatScroll}
|
||||
@wheel=${props.onHistoryIntent}
|
||||
@keydown=${props.onHistoryIntent}
|
||||
@mousedown=${beginNativeWindowDragFromTopInset}
|
||||
@click=${(event: Event) => {
|
||||
handleMarkdownCodeBlockCopy(event);
|
||||
@@ -879,17 +675,7 @@ export function renderChatThread(props: ChatThreadProps) {
|
||||
<span>${t("common.loading")}</span>
|
||||
</div>
|
||||
`
|
||||
: props.historyPagination.manualFallback
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--sm chat-history-fallback"
|
||||
type="button"
|
||||
@click=${props.historyPagination.onLoadOlder}
|
||||
>
|
||||
${t("chat.loadOlder")}
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Control UI chat module implements input history behavior.
|
||||
import { CHAT_HISTORY_RENDER_LIMIT } from "../../lib/chat/chat-types.ts";
|
||||
import { extractText } from "../../lib/chat/message-extract.ts";
|
||||
|
||||
const CHAT_INPUT_HISTORY_LIMIT = 100;
|
||||
|
||||
type ChatLocalInputHistoryEntry = {
|
||||
text: string;
|
||||
ts: number;
|
||||
@@ -60,8 +61,8 @@ function collectUserInputHistory(
|
||||
if (messages.length === 0 && localEntries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// Keep input recall aligned with what chat UI renders: only consider the visible history window.
|
||||
const start = Math.max(0, messages.length - CHAT_HISTORY_RENDER_LIMIT);
|
||||
// Bound input recall independently from the transcript's loaded rendering depth.
|
||||
const start = Math.max(0, messages.length - CHAT_INPUT_HISTORY_LIMIT);
|
||||
const candidates: Array<{ text: string; ts: number }> = [...localEntries];
|
||||
for (let i = messages.length - 1; i >= start; i--) {
|
||||
const message = messages[i];
|
||||
@@ -109,7 +110,7 @@ export function recordNonTranscriptInputHistory(state: ChatInputHistoryState, te
|
||||
state.chatLocalInputHistoryBySession[state.sessionKey] = [
|
||||
{ text: trimmed, ts: Date.now() },
|
||||
...sessionEntries,
|
||||
].slice(0, CHAT_HISTORY_RENDER_LIMIT);
|
||||
].slice(0, CHAT_INPUT_HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
export function resetChatInputHistoryNavigation(state: ChatInputHistoryState) {
|
||||
|
||||
@@ -518,18 +518,6 @@ img.chat-avatar {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Message metadata (tokens, cost, model, context %) ── */
|
||||
.msg-meta {
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user