diff --git a/src/client/src/chatGroups.test.ts b/src/client/src/chatGroups.test.ts index faa3596..eef8bb6 100644 --- a/src/client/src/chatGroups.test.ts +++ b/src/client/src/chatGroups.test.ts @@ -13,9 +13,9 @@ describe("groupChatMessages", () => { ]; expect(groupChatMessages(messages, 10)).toEqual([ - { kind: "group", startIndex: 10, messages: [messages[0]] }, + { kind: "group", startIndex: 10, endIndex: 10, messages: [messages[0]] }, { kind: "message", index: 11, message: text("assistant", "visible answer") }, - { kind: "group", startIndex: 12, messages: [messages[2]] }, + { kind: "group", startIndex: 12, endIndex: 12, messages: [messages[2]] }, ]); }); @@ -25,7 +25,7 @@ describe("groupChatMessages", () => { ]; expect(groupChatMessages(messages)).toEqual([ - { kind: "group", startIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "hidden" }] }] }, + { kind: "group", startIndex: 0, endIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "hidden" }] }] }, { kind: "message", index: 0, message: { role: "assistant", parts: [{ type: "text", text: "shown" }] } }, ]); }); @@ -36,7 +36,7 @@ describe("groupChatMessages", () => { ]; expect(groupChatMessages(messages)).toEqual([ - { kind: "group", startIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "plan" }] }] }, + { kind: "group", startIndex: 0, endIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "plan" }] }] }, { kind: "message", index: 0, message: { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] } }, ]); }); @@ -45,7 +45,7 @@ describe("groupChatMessages", () => { const message: ChatLine = { role: "assistant", parts: [{ type: "thinking", text: "hidden" }, { type: "text", text: "shown" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "test", id: "model" } } }; expect(groupChatMessages([message])).toEqual([ - { kind: "group", startIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "hidden" }], meta: message.meta }] }, + { kind: "group", startIndex: 0, endIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "hidden" }], meta: message.meta }] }, { kind: "message", index: 0, message: { role: "assistant", parts: [{ type: "text", text: "shown" }], meta: message.meta } }, ]); }); @@ -59,7 +59,15 @@ describe("groupChatMessages", () => { const groups = groupChatMessages(messages); expect(groups).toHaveLength(1); - expect(groups[0]).toMatchObject({ kind: "group", startIndex: 0 }); + expect(groups[0]).toMatchObject({ kind: "group", startIndex: 0, endIndex: 1 }); + }); + + it("keeps a stable group end index when older events are prepended into a group", () => { + expect(groupChatMessages([ + { role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "older", isError: false }] }, + { role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "newer" }] }, + text("assistant", "answer"), + ], 8)[0]).toMatchObject({ kind: "group", startIndex: 8, endIndex: 9 }); }); }); diff --git a/src/client/src/chatGroups.ts b/src/client/src/chatGroups.ts index 8bd4b01..917a2f7 100644 --- a/src/client/src/chatGroups.ts +++ b/src/client/src/chatGroups.ts @@ -2,7 +2,7 @@ import type { ChatLine, ChatPart } from "./components/shared"; export type ChatGroup = | { kind: "message"; message: ChatLine; index: number } - | { kind: "group"; messages: ChatLine[]; startIndex: number }; + | { kind: "group"; messages: ChatLine[]; startIndex: number; endIndex: number }; export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGroup[] { const groups: ChatGroup[] = []; @@ -15,7 +15,7 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr }; const flushEvents = () => { if (!eventMessages.length) return; - groups.push({ kind: "group", messages: eventMessages, startIndex: eventStartIndex }); + groups.push({ kind: "group", messages: eventMessages, startIndex: eventStartIndex, endIndex: eventStartIndex + eventMessages.length - 1 }); eventMessages = []; }; diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index e1a2c06..8ba8be1 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -11,6 +11,8 @@ import "./FormattedText"; interface PrependScrollAnchor { scrollTop: number; scrollHeight: number; + key?: string; + offset?: number; } function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } { @@ -88,10 +90,10 @@ export class ChatView extends LitElement { ${this.renderHistoryBoundary()} ${repeat( groupChatMessages(this.messages, this.messageStart), - (group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupAnchorKey(group.startIndex), + (group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupAnchorKey(group.endIndex), (group) => group.kind === "message" ? this.renderMessage(group.message, group.index) - : this.renderMessageGroup(group.messages, group.startIndex), + : this.renderMessageGroup(group.messages, group.startIndex, group.endIndex), )} ${this.renderQueuedMessages()} ${this.renderSessionActivity()} @@ -211,10 +213,10 @@ export class ChatView extends LitElement { `; } - private renderMessageGroup(messages: ChatLine[], startIndex: number) { - const key = this.groupKey(startIndex); + private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number) { + const key = this.groupKey(endIndex); return html` -
{ this.onGroupToggle(key, event); }}> +
{ this.onGroupToggle(key, event); }}> events ${summarizeChatGroup(messages)} @@ -489,14 +491,28 @@ export class ChatView extends LitElement { capturePrependScrollAnchor(): PrependScrollAnchor | undefined { const chat = this.chat; if (!chat) return undefined; - return { scrollTop: chat.scrollTop, scrollHeight: chat.scrollHeight }; + const firstVisible = this.firstVisibleArticle(); + if (!firstVisible) return { scrollTop: chat.scrollTop, scrollHeight: chat.scrollHeight }; + const chatTop = chat.getBoundingClientRect().top; + const key = firstVisible.dataset["anchorKey"]; + const anchor = { scrollTop: chat.scrollTop, scrollHeight: chat.scrollHeight }; + return key === undefined + ? anchor + : { ...anchor, key, offset: firstVisible.getBoundingClientRect().top - chatTop }; } restorePrependScrollAnchor(anchor: PrependScrollAnchor | undefined): void { const chat = this.chat; if (!chat || !anchor) return; this.withSuppressedScrollSave(() => { - chat.scrollTop = anchor.scrollTop + (chat.scrollHeight - anchor.scrollHeight); + const article = anchor.key === undefined ? undefined : this.articleAt({ key: anchor.key }); + if (article !== undefined && anchor.offset !== undefined) { + const chatTop = chat.getBoundingClientRect().top; + const currentOffset = article.getBoundingClientRect().top - chatTop; + chat.scrollTop += currentOffset - anchor.offset; + } else { + chat.scrollTop = anchor.scrollTop + (chat.scrollHeight - anchor.scrollHeight); + } this.lastScrollTop = chat.scrollTop; }); this.requestLoadMoreIfNeeded(); @@ -584,16 +600,16 @@ export class ChatView extends LitElement { return `pi-web:chat-groups:${sessionId}`; } - private groupKey(startIndex: number): string { - return `${this.sessionId}:${String(startIndex)}`; + private groupKey(endIndex: number): string { + return `${this.sessionId}:${String(endIndex)}`; } private messageAnchorKey(index: number): string { return `m:${String(index)}`; } - private groupAnchorKey(startIndex: number): string { - return `g:${String(startIndex)}`; + private groupAnchorKey(endIndex: number): string { + return `g:${String(endIndex)}`; } private readOpenGroupKeys(): Set {