Archived
fix: keep late-loading chat images pinned
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { groupChatMessages } from "./chatGroups";
|
import { groupChatMessages } from "./chatGroups";
|
||||||
import { textMessage } from "./chatMessages";
|
import { normalizeMessages, textMessage } from "./chatMessages";
|
||||||
import { applyTranscriptEvent } from "./chatTranscript";
|
import { applyTranscriptEvent } from "./chatTranscript";
|
||||||
import type { ChatLine } from "./components/shared";
|
import type { ChatLine } from "./components/shared";
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ describe("applyTranscriptEvent", () => {
|
|||||||
type: "tool.end",
|
type: "tool.end",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
toolCallId: "read-image-1",
|
toolCallId: "read-image-1",
|
||||||
text: "Read image file [image/png]",
|
text: "Read image file [image/png]\n[image]",
|
||||||
isError: false,
|
isError: false,
|
||||||
content: [{ type: "text", text: "Read image file [image/png]" }, provisionalImage],
|
content: [{ type: "text", text: "Read image file [image/png]" }, provisionalImage],
|
||||||
details: { source: "tool.end" },
|
details: { source: "tool.end" },
|
||||||
@@ -232,7 +232,7 @@ describe("applyTranscriptEvent", () => {
|
|||||||
let messages: ChatLine[] = [];
|
let messages: ChatLine[] = [];
|
||||||
|
|
||||||
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "capture", toolCallId: "capture-1", summary: "screenshot" }) ?? messages;
|
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, {
|
messages = applyTranscriptEvent(messages, {
|
||||||
type: "message.end",
|
type: "message.end",
|
||||||
message: { role: "toolResult", toolCallId: "capture-1", toolName: "capture", content: [image], isError: false },
|
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"]);
|
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", () => {
|
it("does not merge consecutive streamed skill reads", () => {
|
||||||
let messages: ChatLine[] = [];
|
let messages: ChatLine[] = [];
|
||||||
messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages;
|
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", () => {
|
describe("ChatView technical-event groups", () => {
|
||||||
const messages: ChatLine[] = [
|
const messages: ChatLine[] = [
|
||||||
{ role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] },
|
{ role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] },
|
||||||
@@ -103,10 +126,17 @@ interface GroupBodyRenderCall {
|
|||||||
startIndex: number;
|
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 RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
|
||||||
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
||||||
type TemplateEventHandler = (event: Event) => void;
|
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 {
|
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
|
||||||
const method: unknown = Reflect.get(view, "renderMessageGroup");
|
const method: unknown = Reflect.get(view, "renderMessageGroup");
|
||||||
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
|
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
|
||||||
@@ -125,6 +155,10 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
|
|||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRenderPart(value: unknown): value is RenderPart {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
|
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
|
||||||
return typeof value === "function";
|
return typeof value === "function";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export class ChatView extends LitElement {
|
|||||||
if (this.pinnedToBottom) this.scrollToBottom();
|
if (this.pinnedToBottom) this.scrollToBottom();
|
||||||
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
|
else this.lastClientHeight = this.chat?.clientHeight ?? 0;
|
||||||
};
|
};
|
||||||
|
private readonly onImageLoad = (): void => {
|
||||||
|
if (this.pinnedToBottom) this.scrollToBottom();
|
||||||
|
};
|
||||||
private readonly onPageHide = () => {
|
private readonly onPageHide = () => {
|
||||||
this.saveScrollPosition();
|
this.saveScrollPosition();
|
||||||
};
|
};
|
||||||
@@ -500,7 +503,7 @@ export class ChatView extends LitElement {
|
|||||||
<small>read ${part.path}</small>
|
<small>read ${part.path}</small>
|
||||||
</div>
|
</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 === "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 === "toolExecution") return html`<tool-execution-view class="part" .execution=${part}></tool-execution-view>`;
|
||||||
if (part.type === "toolResult") return html`
|
if (part.type === "toolResult") return html`
|
||||||
|
|||||||
Reference in New Issue
Block a user