diff --git a/src/client/src/components/ChatView.image.test.ts b/src/client/src/components/ChatView.image.test.ts index 6083225..73dde63 100644 --- a/src/client/src/components/ChatView.image.test.ts +++ b/src/client/src/components/ChatView.image.test.ts @@ -1,22 +1,49 @@ import type { TemplateResult } from "lit"; import { describe, expect, it } from "vitest"; import type { ChatLine } from "./shared"; -import { ChatView, chatMessageMetadataLabel } from "./ChatView"; +import { + ChatView, + chatImagePartSource, + chatMessageAnchorKey, + chatToolOutputLabel, +} from "./ChatView"; +import { templateEventHandlerAfterMarker } from "../templateInspection.testSupport"; -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", () => { +describe("ChatView image content derivation", () => { + // Content/attribute derivation (image src/alt, the tool-output header label, + // and the scroll-anchor key) lives in pure exported seams rather than being + // scraped from rendered `TemplateResult` markup, per the testing-guide rule + // that TemplateResult inspection is not for general content assertions. + it("derives the image data URL and alt text from an image part", () => { + expect(chatImagePartSource({ type: "image", mimeType: "image/png", data: "QUJD" })).toEqual({ + src: "data:image/png;base64,QUJD", + alt: "attached image", + }); + }); + + it("labels tool image output by tool name and falls back to a generic label", () => { + expect(chatToolOutputLabel("read")).toBe("read output"); + expect(chatToolOutputLabel(undefined)).toBe("tool output"); + expect(chatToolOutputLabel("")).toBe("tool output"); + }); + + it("keys a tool image message to its stable scroll anchor", () => { + expect(chatMessageAnchorKey(7)).toBe("m:7"); + }); +}); + +describe("ChatView image event wiring", () => { + // Escape hatch: these two cases verify Lit event wiring (`@load` re-pin and + // `@click` zoom) whose only observable effect is a private state/scroll side + // effect. Vitest runs with no DOM environment here, so a shadow-DOM click + // harness would add disproportionate setup; direct handler extraction anchored + // to the stable `@load=`/`@click=` attribute markup is proportionate. + it("re-pins late image loads only while already pinned to the bottom", () => { 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(" { expect(scrollCalls).toBe(1); }); - // Clicking an image records the zoom target so the modal dialog can present - // it at full size, and dismissing clears the target. it("opens and closes the image zoom target on click and close", () => { const view = new ChatView(); - const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" }); - const onClick = templateEventHandler(rendered, "@click="); + const part = { type: "image", mimeType: "image/png", data: "QUJD" } as const; + const rendered = renderPart(view, part); + const onClick = templateEventHandlerAfterMarker(rendered, "@click="); expect(zoomedImage(view)).toBeUndefined(); onClick(new Event("click")); - expect(zoomedImage(view)).toEqual({ src: "data:image/png;base64,QUJD", alt: "attached image" }); + expect(zoomedImage(view)).toEqual(chatImagePartSource(part)); const close: unknown = Reflect.get(view, "closeImageZoom"); if (typeof close !== "function") throw new Error("ChatView.closeImageZoom is not callable"); close.call(view); expect(zoomedImage(view)).toBeUndefined(); }); - - // Direct rendering keeps this node-environment test focused on the dedicated - // tool-image presentation without introducing a component-wide DOM shim. - it("renders tool images as labeled standard messages with final metadata", () => { - const message: ChatLine = { - role: "tool", - parts: [{ type: "image", mimeType: "image/png", data: "QUJD" }], - meta: { timestamp: "2026-07-13T22:00:00.000Z" }, - }; - const rendered = renderToolImageOutput(new ChatView(), message, 7, "read"); - const markup = templateStaticMarkup(rendered); - - expect(markup).toContain('class="msg tool-image-output"'); - expect(markup).not.toContain('class="msg tool"'); - expect(markup).toContain("')).toEqual(["read output"]); - expect(templateValuesAfterMarker(rendered, "title=").filter((value) => typeof value === "string")).toEqual([chatMessageMetadataLabel(message)]); - expect(templateValuesAfterMarker(rendered, "data-scroll-anchor-id=")).toEqual(["m:7"]); - }); }); function zoomedImage(view: ChatView): unknown { @@ -68,8 +75,6 @@ function zoomedImage(view: ChatView): unknown { } type RenderPart = (this: ChatView, part: ChatLine["parts"][number], message?: ChatLine) => TemplateResult; -type RenderToolImageOutput = (this: ChatView, message: ChatLine, index: number, toolName?: string) => TemplateResult; -type TemplateEventHandler = (event: Event) => void; function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: ChatLine): TemplateResult { const method: unknown = Reflect.get(view, "renderPart"); @@ -77,86 +82,6 @@ function renderPart(view: ChatView, part: ChatLine["parts"][number], message?: C return method.call(view, part, message); } -function renderToolImageOutput(view: ChatView, message: ChatLine, index: number, toolName?: string): TemplateResult { - const method: unknown = Reflect.get(view, "renderToolImageOutput"); - if (!isRenderToolImageOutput(method)) throw new Error("ChatView.renderToolImageOutput is not callable"); - return method.call(view, message, index, toolName); -} - -function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler { - const strings = templateStrings(template); - const values = templateValues(template); - for (let index = 0; index < values.length; index += 1) { - const value = values[index]; - if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value; - } - throw new Error(`Expected template event handler after ${marker}`); -} - function isRenderPart(value: unknown): value is RenderPart { return typeof value === "function"; } - -function isRenderToolImageOutput(value: unknown): value is RenderToolImageOutput { - return typeof value === "function"; -} - -function isTemplateEventHandler(value: unknown): value is TemplateEventHandler { - return typeof value === "function"; -} - -function templateStaticMarkup(template: TemplateResult): string { - const chunks: string[] = []; - visit(template); - return chunks.join(""); - - function visit(value: unknown): void { - if (Array.isArray(value)) { - for (const item of value) visit(item); - return; - } - if (!isTemplateResult(value)) return; - chunks.push(...templateStrings(value)); - for (const child of templateValues(value)) visit(child); - } -} - -function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] { - const matches: unknown[] = []; - visit(template); - return matches; - - function visit(value: unknown): void { - if (Array.isArray(value)) { - for (const item of value) visit(item); - return; - } - if (!isTemplateResult(value)) return; - const strings = templateStrings(value); - const values = templateValues(value); - for (let index = 0; index < values.length; index += 1) { - if (strings[index]?.includes(marker) === true) matches.push(values[index]); - visit(values[index]); - } - } -} - -function templateStrings(template: TemplateResult): readonly string[] { - const strings = Reflect.get(template, "strings"); - if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable"); - return strings; -} - -function templateValues(template: TemplateResult): readonly unknown[] { - const values = Reflect.get(template, "values"); - if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable"); - return values.map((value: unknown) => value); -} - -function isTemplateResult(value: unknown): value is TemplateResult { - return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values")); -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item: unknown) => typeof item === "string"); -} diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 1e38232..ec1b1fe 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -45,6 +45,23 @@ export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], ].filter((section): section is QueuedMessageSection => section !== undefined); } +export type ChatImagePart = Extract; + +/** Derive the `` source URL and alt text for a rendered image part. */ +export function chatImagePartSource(part: ChatImagePart): { src: string; alt: string } { + return { src: `data:${part.mimeType};base64,${part.data}`, alt: "attached image" }; +} + +/** The message-header label used when a tool message renders as an image output. */ +export function chatToolOutputLabel(toolName?: string): string { + return toolName === undefined || toolName === "" ? "tool output" : `${toolName} output`; +} + +/** The stable scroll-anchor/render key for a top-level message at `index`. */ +export function chatMessageAnchorKey(index: number): string { + return `m:${String(index)}`; +} + export function chatMessageMetadataLabel(message: ChatLine): string { const timestamp = message.meta?.timestamp; const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp); @@ -436,7 +453,7 @@ export class ChatView extends LitElement { } private renderToolImageOutput(message: ChatLine, index: number, toolName?: string) { - const label = toolName === undefined || toolName === "" ? "tool output" : `${toolName} output`; + const label = chatToolOutputLabel(toolName); return html` ${this.renderScrollMarker(this.messageScrollMarkerId(index))}
@@ -570,8 +587,7 @@ export class ChatView extends LitElement { `; if (part.type === "image") { - const src = `data:${part.mimeType};base64,${part.data}`; - const alt = "attached image"; + const { src, alt } = chatImagePartSource(part); return html`${alt} { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`; } if (part.type === "toolCall") return html`
▶ ${part.toolName}${part.summary}
`; @@ -874,7 +890,7 @@ export class ChatView extends LitElement { } private messageAnchorKey(index: number): string { - return `m:${String(index)}`; + return chatMessageAnchorKey(index); } private groupRenderKey(startIndex: number): string { @@ -890,7 +906,7 @@ export class ChatView extends LitElement { } private messageScrollMarkerId(index: number): string { - return `m:${String(index)}`; + return chatMessageAnchorKey(index); } private groupScrollMarkerId(endIndex: number): string {