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([ 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: "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([ 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" }] } }, { kind: "message", index: 0, message: { role: "assistant", parts: [{ type: "text", text: "shown" }] } },
]); ]);
}); });
@@ -36,7 +36,7 @@ describe("groupChatMessages", () => {
]; ];
expect(groupChatMessages(messages)).toEqual([ 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" }] } }, { 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" } } }; 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([ 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 } }, { 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); const groups = groupChatMessages(messages);
expect(groups).toHaveLength(1); 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 = export type ChatGroup =
| { kind: "message"; message: ChatLine; index: number } | { 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[] { export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGroup[] {
const groups: ChatGroup[] = []; const groups: ChatGroup[] = [];
@@ -15,7 +15,7 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr
}; };
const flushEvents = () => { const flushEvents = () => {
if (!eventMessages.length) return; 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 = []; eventMessages = [];
}; };
+27 -11
View File
@@ -11,6 +11,8 @@ import "./FormattedText";
interface PrependScrollAnchor { interface PrependScrollAnchor {
scrollTop: number; scrollTop: number;
scrollHeight: number; scrollHeight: number;
key?: string;
offset?: number;
} }
function isScrollPosition(value: unknown): value is { index?: 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()} ${this.renderHistoryBoundary()}
${repeat( ${repeat(
groupChatMessages(this.messages, this.messageStart), 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" (group) => group.kind === "message"
? this.renderMessage(group.message, group.index) ? this.renderMessage(group.message, group.index)
: this.renderMessageGroup(group.messages, group.startIndex), : this.renderMessageGroup(group.messages, group.startIndex, group.endIndex),
)} )}
${this.renderQueuedMessages()} ${this.renderQueuedMessages()}
${this.renderSessionActivity()} ${this.renderSessionActivity()}
@@ -211,10 +213,10 @@ export class ChatView extends LitElement {
`; `;
} }
private renderMessageGroup(messages: ChatLine[], startIndex: number) { private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number) {
const key = this.groupKey(startIndex); const key = this.groupKey(endIndex);
return html` 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> <summary>
<b class="label">events</b> <b class="label">events</b>
<span>${summarizeChatGroup(messages)}</span> <span>${summarizeChatGroup(messages)}</span>
@@ -489,14 +491,28 @@ export class ChatView extends LitElement {
capturePrependScrollAnchor(): PrependScrollAnchor | undefined { capturePrependScrollAnchor(): PrependScrollAnchor | undefined {
const chat = this.chat; const chat = this.chat;
if (!chat) return undefined; 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 { restorePrependScrollAnchor(anchor: PrependScrollAnchor | undefined): void {
const chat = this.chat; const chat = this.chat;
if (!chat || !anchor) return; if (!chat || !anchor) return;
this.withSuppressedScrollSave(() => { 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.lastScrollTop = chat.scrollTop;
}); });
this.requestLoadMoreIfNeeded(); this.requestLoadMoreIfNeeded();
@@ -584,16 +600,16 @@ export class ChatView extends LitElement {
return `pi-web:chat-groups:${sessionId}`; return `pi-web:chat-groups:${sessionId}`;
} }
private groupKey(startIndex: number): string { private groupKey(endIndex: number): string {
return `${this.sessionId}:${String(startIndex)}`; return `${this.sessionId}:${String(endIndex)}`;
} }
private messageAnchorKey(index: number): string { private messageAnchorKey(index: number): string {
return `m:${String(index)}`; return `m:${String(index)}`;
} }
private groupAnchorKey(startIndex: number): string { private groupAnchorKey(endIndex: number): string {
return `g:${String(startIndex)}`; return `g:${String(endIndex)}`;
} }
private readOpenGroupKeys(): Set<string> { private readOpenGroupKeys(): Set<string> {