Archived
Keep event group anchors stable when prepending
This commit is contained in:
@@ -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,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 = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user