Keep event group anchors stable when prepending

This commit is contained in:
Federico Jaramillo Martinez
2026-05-11 22:28:49 +02:00
parent fa60c8d963
commit a3a9c9205a
3 changed files with 43 additions and 19 deletions
+14 -6
View File
@@ -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 });
});
});
+2 -2
View File
@@ -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 = [];
};
+26 -10
View File
@@ -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`
<details class="msg event-group" data-index=${startIndex} data-anchor-key=${this.groupAnchorKey(startIndex)} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
<details class="msg event-group" data-index=${startIndex} data-anchor-key=${this.groupAnchorKey(endIndex)} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
<summary>
<b class="label">events</b>
<span>${summarizeChatGroup(messages)}</span>
@@ -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(() => {
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<string> {