diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index 34c2249..d44bf70 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -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) => groups.flatMap((group) => group.kind === "group" + ? group.messages.flatMap((message) => message.parts.filter((part) => part.type === "toolExecution")) + : []); + const visibleImages = (groups: ReturnType) => groups.flatMap((group) => group.kind === "message" + ? group.message.parts.filter((part) => part.type === "image") + : []); + const visibleImageMeta = (groups: ReturnType) => { + 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; diff --git a/src/client/src/components/ChatView.test.ts b/src/client/src/components/ChatView.test.ts index df3b167..d3206f5 100644 --- a/src/client/src/components/ChatView.test.ts +++ b/src/client/src/components/ChatView.test.ts @@ -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(" { 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"; } diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 8cc549e..2afa0cc 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -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 { read ${part.path} `; - if (part.type === "image") return html`attached image`; + if (part.type === "image") return html`attached image`; if (part.type === "toolCall") return html`
▶ ${part.toolName}${part.summary}
`; if (part.type === "toolExecution") return html``; if (part.type === "toolResult") return html`