fix: keep late-loading chat images pinned

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 08:58:00 +02:00
parent 5ce76a12e2
commit c22a2feff6
3 changed files with 135 additions and 4 deletions
+97 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { groupChatMessages } from "./chatGroups";
import { textMessage } from "./chatMessages";
import { normalizeMessages, textMessage } from "./chatMessages";
import { applyTranscriptEvent } from "./chatTranscript";
import type { ChatLine } from "./components/shared";
@@ -182,7 +182,7 @@ describe("applyTranscriptEvent", () => {
type: "tool.end",
toolName: "read",
toolCallId: "read-image-1",
text: "Read image file [image/png]",
text: "Read image file [image/png]\n[image]",
isError: false,
content: [{ type: "text", text: "Read image file [image/png]" }, provisionalImage],
details: { source: "tool.end" },
@@ -232,7 +232,7 @@ describe("applyTranscriptEvent", () => {
let messages: ChatLine[] = [];
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "capture", toolCallId: "capture-1", summary: "screenshot" }) ?? messages;
messages = applyTranscriptEvent(messages, { type: "tool.end", toolName: "capture", toolCallId: "capture-1", text: "", isError: false, content: [image] }) ?? messages;
messages = applyTranscriptEvent(messages, { type: "tool.end", toolName: "capture", toolCallId: "capture-1", text: "[image]", isError: false, content: [image] }) ?? messages;
messages = applyTranscriptEvent(messages, {
type: "message.end",
message: { role: "toolResult", toolCallId: "capture-1", toolName: "capture", content: [image], isError: false },
@@ -253,6 +253,100 @@ describe("applyTranscriptEvent", () => {
expect(groupChatMessages(messages).map((group) => group.kind)).toEqual(["group", "message"]);
});
it("keeps repeated final tool-result events idempotent", () => {
const image = { type: "image" as const, mimeType: "image/png", data: "RklOQUw=" };
const finalEvent = {
type: "message.end" as const,
message: {
role: "toolResult",
toolCallId: "read-image-repeat",
toolName: "read",
content: [{ type: "text", text: "Read image file [image/png]" }, image],
isError: false,
timestamp: "2026-07-13T22:00:00.000Z",
},
};
let messages: ChatLine[] = [];
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "read-image-repeat", summary: "image.png" }) ?? messages;
messages = applyTranscriptEvent(messages, {
type: "tool.end",
toolName: "read",
toolCallId: "read-image-repeat",
text: "Read image file [image/png]\n[image]",
isError: false,
content: finalEvent.message.content,
}) ?? messages;
messages = applyTranscriptEvent(messages, finalEvent) ?? messages;
messages = applyTranscriptEvent(messages, finalEvent) ?? messages;
expect(messages).toHaveLength(1);
expect(messages[0]?.parts.filter((part) => part.type === "toolExecution")).toHaveLength(1);
expect(messages[0]?.parts.filter((part) => part.type === "image")).toEqual([image]);
expect(messages[0]?.meta).toEqual({ timestamp: "2026-07-13T22:00:00.000Z" });
});
it("matches hydrated history for technical execution and visible image content", () => {
const image = { type: "image" as const, mimeType: "image/png", data: "QUJD" };
const timestamp = "2026-07-13T22:00:00.000Z";
const finalResult = {
role: "toolResult",
toolCallId: "read-history-parity",
toolName: "read",
content: [{ type: "text", text: "Read image file [image/png]" }, image],
details: { path: "image.png" },
isError: false,
timestamp,
};
const historyGroups = groupChatMessages(normalizeMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "read-history-parity", name: "read", arguments: { path: "image.png" } }],
},
finalResult,
]));
let liveMessages: ChatLine[] = [];
liveMessages = applyTranscriptEvent(liveMessages, {
type: "tool.start",
toolName: "read",
toolCallId: "read-history-parity",
summary: "image.png",
args: { path: "image.png" },
}) ?? liveMessages;
liveMessages = applyTranscriptEvent(liveMessages, {
type: "tool.end",
toolName: "read",
toolCallId: "read-history-parity",
text: "Read image file [image/png]\n[image]",
isError: false,
content: finalResult.content,
details: finalResult.details,
}) ?? liveMessages;
liveMessages = applyTranscriptEvent(liveMessages, { type: "message.end", message: finalResult }) ?? liveMessages;
const liveGroups = groupChatMessages(liveMessages);
const technicalParts = (groups: ReturnType<typeof groupChatMessages>) => groups.flatMap((group) => group.kind === "group"
? group.messages.flatMap((message) => message.parts.filter((part) => part.type === "toolExecution"))
: []);
const visibleImages = (groups: ReturnType<typeof groupChatMessages>) => groups.flatMap((group) => group.kind === "message"
? group.message.parts.filter((part) => part.type === "image")
: []);
const visibleImageMeta = (groups: ReturnType<typeof groupChatMessages>) => {
for (const group of groups) {
if (group.kind === "message" && group.message.parts.some((part) => part.type === "image")) return group.message.meta;
}
return undefined;
};
expect(historyGroups.map((group) => group.kind)).toEqual(["group", "message"]);
expect(liveGroups.map((group) => group.kind)).toEqual(["group", "message"]);
expect(technicalParts(liveGroups)).toEqual(technicalParts(historyGroups));
expect(visibleImages(liveGroups)).toEqual(visibleImages(historyGroups));
expect(visibleImageMeta(historyGroups)).toEqual({ timestamp });
expect(visibleImageMeta(liveGroups)).toEqual({ timestamp });
});
it("does not merge consecutive streamed skill reads", () => {
let messages: ChatLine[] = [];
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages;
@@ -38,6 +38,29 @@ describe("chatMessageMetadataLabel", () => {
});
});
describe("ChatView image rendering", () => {
// Direct handler extraction keeps this node-environment test focused on the
// late image-load scroll wiring without introducing a component-wide DOM shim.
it("renders native image data and re-pins late loads only while already pinned", () => {
const view = new ChatView();
let scrollCalls = 0;
if (!Reflect.set(view, "scrollToBottom", () => { scrollCalls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" });
const onLoad = templateEventHandler(rendered, "@load=");
expect(templateStaticMarkup(rendered)).toContain("<img");
expect(templateStaticMarkup(rendered)).toContain('loading="lazy"');
expect(templateValuesAfterMarker(rendered, "src=")).toEqual(["data:image/png;base64,QUJD"]);
if (!Reflect.set(view, "pinnedToBottom", true)) throw new Error("Could not set ChatView.pinnedToBottom");
onLoad(new Event("load"));
if (!Reflect.set(view, "pinnedToBottom", false)) throw new Error("Could not set ChatView.pinnedToBottom");
onLoad(new Event("load"));
expect(scrollCalls).toBe(1);
});
});
describe("ChatView technical-event groups", () => {
const messages: ChatLine[] = [
{ role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] },
@@ -103,10 +126,17 @@ interface GroupBodyRenderCall {
startIndex: number;
}
type RenderPart = (this: ChatView, part: ChatLine["parts"][number], message?: ChatLine) => TemplateResult;
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
type TemplateEventHandler = (event: Event) => void;
function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: ChatLine): TemplateResult {
const method: unknown = Reflect.get(view, "renderPart");
if (!isRenderPart(method)) throw new Error("ChatView.renderPart is not callable");
return method.call(view, part, message);
}
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
const method: unknown = Reflect.get(view, "renderMessageGroup");
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
@@ -125,6 +155,10 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
return calls;
}
function isRenderPart(value: unknown): value is RenderPart {
return typeof value === "function";
}
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
return typeof value === "function";
}
+4 -1
View File
@@ -120,6 +120,9 @@ export class ChatView extends LitElement {
if (this.pinnedToBottom) this.scrollToBottom();
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
};
private readonly onImageLoad = (): void => {
if (this.pinnedToBottom) this.scrollToBottom();
};
private readonly onPageHide = () => {
this.saveScrollPosition();
};
@@ -500,7 +503,7 @@ export class ChatView extends LitElement {
<small>read ${part.path}</small>
</div>
`;
if (part.type === "image") return html`<img class="part chat-image" src=${`data:${part.mimeType};base64,${part.data}`} alt="attached image" loading="lazy" />`;
if (part.type === "image") return html`<img class="part chat-image" src=${`data:${part.mimeType};base64,${part.data}`} alt="attached image" loading="lazy" @load=${this.onImageLoad} />`;
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
if (part.type === "toolExecution") return html`<tool-execution-view class="part" .execution=${part}></tool-execution-view>`;
if (part.type === "toolResult") return html`