diff --git a/.changeset/show-tool-images-outside-events.md b/.changeset/show-tool-images-outside-events.md new file mode 100644 index 00000000..387debea --- /dev/null +++ b/.changeset/show-tool-images-outside-events.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep tool-result images visible in clearly labeled standard chat cards outside collapsed event groups while retaining technical execution details and final message metadata. diff --git a/src/client/src/chatGroups.test.ts b/src/client/src/chatGroups.test.ts index eef8bb60..c3d4d1d7 100644 --- a/src/client/src/chatGroups.test.ts +++ b/src/client/src/chatGroups.test.ts @@ -41,6 +41,43 @@ describe("groupChatMessages", () => { ]); }); + it("keeps image content visible outside collapsed event groups", () => { + const image = { type: "image" as const, mimeType: "image/png", data: "QUJD" }; + const messages: ChatLine[] = [ + { role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "Read image file [image/png]", isError: false }, image] }, + ]; + + expect(groupChatMessages(messages)).toEqual([ + { + kind: "group", + startIndex: 0, + endIndex: 0, + messages: [{ role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "Read image file [image/png]", isError: false }] }], + }, + { kind: "tool-image", index: 0, message: { role: "tool", parts: [image] }, toolName: "read" }, + ]); + }); + + it("preserves image metadata when splitting technical and readable parts", () => { + const meta = { timestamp: "2026-07-13T22:00:00.000Z" }; + const image = { type: "image" as const, mimeType: "image/webp", data: "QUJD" }; + const message: ChatLine = { role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "ok", isError: false }, image], meta }; + + expect(groupChatMessages([message])).toEqual([ + { kind: "group", startIndex: 0, endIndex: 0, messages: [{ role: "tool", parts: [message.parts[0]], meta }] }, + { kind: "tool-image", index: 0, message: { role: "tool", parts: [image], meta }, toolName: "read" }, + ]); + }); + + it("keeps user images as ordinary messages", () => { + const image = { type: "image" as const, mimeType: "image/png", data: "QUJD" }; + const message: ChatLine = { role: "user", parts: [image] }; + + expect(groupChatMessages([message])).toEqual([ + { kind: "message", index: 0, message }, + ]); + }); + it("preserves message metadata when grouping", () => { 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" } } }; diff --git a/src/client/src/chatGroups.ts b/src/client/src/chatGroups.ts index 917a2f71..c536035c 100644 --- a/src/client/src/chatGroups.ts +++ b/src/client/src/chatGroups.ts @@ -2,6 +2,7 @@ import type { ChatLine, ChatPart } from "./components/shared"; export type ChatGroup = | { kind: "message"; message: ChatLine; index: number } + | { kind: "tool-image"; message: ChatLine; index: number; toolName?: string } | { kind: "group"; messages: ChatLine[]; startIndex: number; endIndex: number }; export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGroup[] { @@ -29,7 +30,13 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr if (readableParts.length) { flushEvents(); const role = readableParts.every((part) => part.type === "skillRead") ? "skill" : message.role; - groups.push({ kind: "message", message: { role, parts: readableParts, ...metadata }, index: absoluteIndex }); + const readableMessage = { role, parts: readableParts, ...metadata }; + if (isToolImageMessage(readableMessage)) { + const toolName = toolNameFromParts(technicalParts); + groups.push({ kind: "tool-image", message: readableMessage, index: absoluteIndex, ...(toolName === undefined ? {} : { toolName }) }); + } else { + groups.push({ kind: "message", message: readableMessage, index: absoluteIndex }); + } } }); flushEvents(); @@ -47,8 +54,19 @@ export function summarizeChatGroup(messages: ChatLine[]): string { return `${String(messages.length)} ${messages.length === 1 ? "event" : "events"}${details !== "" ? ` · ${details}` : ""}`; } +function isToolImageMessage(message: ChatLine): boolean { + return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "image"); +} + +function toolNameFromParts(parts: ChatPart[]): string | undefined { + for (const part of parts) { + if ((part.type === "toolCall" || part.type === "toolExecution" || part.type === "toolResult") && part.toolName !== "") return part.toolName; + } + return undefined; +} + function isReadablePart(message: ChatLine, part: ChatPart): boolean { if (message.source === "compaction" || message.source === "branch_summary") return false; - if (part.type === "skillInvocation" || part.type === "skillRead") return true; + if (part.type === "skillInvocation" || part.type === "skillRead" || part.type === "image") return true; return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash"); } diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index 09c349b4..af4585d9 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { textMessage } from "./chatMessages"; +import { groupChatMessages } from "./chatGroups"; +import { normalizeMessages, textMessage } from "./chatMessages"; import { applyTranscriptEvent } from "./chatTranscript"; import type { ChatLine } from "./components/shared"; @@ -169,6 +170,183 @@ describe("applyTranscriptEvent", () => { ]); }); + it("projects live tool-result images and reconciles final content and metadata", () => { + const provisionalImage = { type: "image" as const, mimeType: "image/png", data: "UFJFVklFVw==" }; + const finalImage = { type: "image" as const, mimeType: "image/png", data: "RklOQUw=" }; + const finalContent = [{ type: "text", text: "Read image file [image/png]" }, finalImage]; + const timestamp = "2026-07-13T22:00:00.000Z"; + let messages: ChatLine[] = []; + + messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "read-image-1", summary: "image.png", args: { path: "image.png" } }) ?? messages; + messages = applyTranscriptEvent(messages, { + type: "tool.end", + toolName: "read", + toolCallId: "read-image-1", + text: "Read image file [image/png]\n[image]", + isError: false, + content: [{ type: "text", text: "Read image file [image/png]" }, provisionalImage], + details: { source: "tool.end" }, + }) ?? messages; + + expect(messages[0]?.parts.filter((part) => part.type === "image")).toEqual([provisionalImage]); + + messages = applyTranscriptEvent(messages, { + type: "message.end", + message: { + role: "toolResult", + toolCallId: "read-image-1", + toolName: "read", + content: finalContent, + details: { source: "message.end" }, + isError: false, + timestamp, + }, + }) ?? messages; + messages = applyTranscriptEvent(messages, { type: "assistant.delta", text: "done" }) ?? messages; + + const finalizedToolLine: ChatLine = { + role: "tool", + parts: [{ + type: "toolExecution", + toolCallId: "read-image-1", + toolName: "read", + summary: "image.png", + args: { path: "image.png" }, + status: "success", + resultText: "Read image file [image/png]", + content: finalContent, + details: { source: "message.end" }, + }, finalImage], + meta: { timestamp }, + }; + expect(messages).toEqual([finalizedToolLine, textMessage("assistant", "done")]); + expect(groupChatMessages(messages)).toEqual([ + { kind: "group", startIndex: 0, endIndex: 0, messages: [{ ...finalizedToolLine, parts: [finalizedToolLine.parts[0]] }] }, + { kind: "tool-image", index: 0, message: { ...finalizedToolLine, parts: [finalImage] }, toolName: "read" }, + { kind: "message", index: 1, message: textMessage("assistant", "done") }, + ]); + }); + + it("keeps image-only live tool results visible without inventing text", () => { + const image = { type: "image" as const, mimeType: "image/webp", data: "QUJD" }; + 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: "[image]", isError: false, content: [image] }) ?? messages; + messages = applyTranscriptEvent(messages, { + type: "message.end", + message: { role: "toolResult", toolCallId: "capture-1", toolName: "capture", content: [image], isError: false }, + }) ?? messages; + + expect(messages).toEqual([{ + role: "tool", + parts: [{ + type: "toolExecution", + toolCallId: "capture-1", + toolName: "capture", + summary: "screenshot", + status: "success", + resultText: "", + content: [image], + }, image], + }]); + expect(groupChatMessages(messages).map((group) => group.kind)).toEqual(["group", "tool-image"]); + }); + + 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 !== "group" + ? group.message.parts.filter((part) => part.type === "image") + : []); + const visibleImageMeta = (groups: ReturnType) => { + for (const group of groups) { + if (group.kind !== "group" && group.message.parts.some((part) => part.type === "image")) return group.message.meta; + } + return undefined; + }; + + expect(historyGroups.map((group) => group.kind)).toEqual(["group", "tool-image"]); + expect(liveGroups.map((group) => group.kind)).toEqual(["group", "tool-image"]); + 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/chatTranscript.ts b/src/client/src/chatTranscript.ts index bad38d5e..179b84db 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -3,13 +3,40 @@ import type { ChatLine, ToolExecutionPart } from "./components/shared"; import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages"; import type { SessionUiEvent } from "./sessionSocket"; +type ToolResultImage = Extract; + +interface ToolResultPresentation { + images: ToolResultImage[]; + meta?: ChatLine["meta"]; +} + +interface ToolResultUpdate { + toolCallId?: string; + toolName: string; + text: string; + isError: boolean; + content: unknown; + details: unknown; + presentation: ToolResultPresentation; +} + export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent): ChatLine[] | undefined { if (event.type === "message.append") return appendNewMessage(messages, event.message); if (event.type === "assistant.delta") return appendText(messages, "assistant", event.text); if (event.type === "assistant.thinking.delta") return appendThinking(messages, event.text); if (event.type === "tool.start") return appendToolExecutionStart(messages, event); if (event.type === "tool.update") return updateToolExecution(messages, event.toolCallId, (part) => mergeToolExecutionUpdate(part, event)); - if (event.type === "tool.end") return finalizeToolExecution(messages, event.toolCallId, event.toolName, summarizeArgs(event.content), event.text, event.isError, event.content, event.details); + if (event.type === "tool.end") { + return finalizeToolExecution(messages, { + toolCallId: event.toolCallId, + toolName: event.toolName, + text: event.text, + isError: event.isError, + content: event.content, + details: event.details, + presentation: toolResultPresentation({ role: "toolResult", content: event.content }), + }); + } if (event.type === "shell.start") return [...messages, shellStartMessage(event.command, event.excludeFromContext)]; if (event.type === "shell.chunk") return appendShellChunk(messages, event.chunk); if (event.type === "shell.end") return finalizeShellMessage(messages, event); @@ -21,9 +48,7 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined { const rawToolResult = toolResultFromRawMessage(rawMessage); - if (rawToolResult !== undefined) { - return finalizeToolExecution(messages, rawToolResult.toolCallId, rawToolResult.toolName, summarizeArgs(rawToolResult.content), rawToolResult.text, rawToolResult.isError, rawToolResult.content, rawToolResult.details); - } + if (rawToolResult !== undefined) return finalizeToolExecution(messages, rawToolResult); const ended = normalizeMessage(rawMessage); if (ended.length === 0) return undefined; @@ -85,7 +110,8 @@ function mergeToolExecutionUpdate(part: ToolExecutionPart, event: Extract { const preview = previewFromDetails(details) ?? part.preview; return { @@ -96,7 +122,7 @@ function finalizeToolExecution(messages: ChatLine[], toolCallId: string | undefi ...(details === undefined ? {} : { details }), ...(preview === undefined ? {} : { preview }), }; - }); + }, (line) => reconcileToolResultPresentation(line, presentation)); if (updated !== messages) return updated; const preview = previewFromDetails(details); @@ -104,17 +130,22 @@ function finalizeToolExecution(messages: ChatLine[], toolCallId: string | undefi type: "toolExecution", ...(toolCallId === undefined || toolCallId === "" ? {} : { toolCallId }), toolName, - summary: fallbackSummary, + summary: summarizeArgs(content), status: isError ? "error" : "success", resultText: text, ...(content === undefined ? {} : { content }), ...(details === undefined ? {} : { details }), ...(preview === undefined ? {} : { preview }), }; - return [...messages, { role: "tool", parts: [part] }]; + return [...messages, reconcileToolResultPresentation({ role: "tool", parts: [part] }, presentation)]; } -function updateToolExecution(messages: ChatLine[], toolCallId: string | undefined, update: (part: ToolExecutionPart) => ToolExecutionPart): ChatLine[] { +function updateToolExecution( + messages: ChatLine[], + toolCallId: string | undefined, + update: (part: ToolExecutionPart) => ToolExecutionPart, + reconcileLine: (line: ChatLine) => ChatLine = (line) => line, +): ChatLine[] { if (toolCallId === undefined || toolCallId === "") return messages; for (let lineIndex = messages.length - 1; lineIndex >= 0; lineIndex--) { const line = messages[lineIndex]; @@ -123,13 +154,26 @@ function updateToolExecution(messages: ChatLine[], toolCallId: string | undefine if (partIndex < 0) continue; const part = line.parts[partIndex]; if (part?.type !== "toolExecution") continue; - const nextLine = { ...line, parts: [...line.parts.slice(0, partIndex), update(part), ...line.parts.slice(partIndex + 1)] }; + const updatedLine = { ...line, parts: [...line.parts.slice(0, partIndex), update(part), ...line.parts.slice(partIndex + 1)] }; + const nextLine = reconcileLine(updatedLine); return [...messages.slice(0, lineIndex), nextLine, ...messages.slice(lineIndex + 1)]; } return messages; } -function toolResultFromRawMessage(message: unknown): { toolCallId?: string; toolName: string; text: string; isError: boolean; content: unknown; details: unknown } | undefined { +function reconcileToolResultPresentation(line: ChatLine, presentation: ToolResultPresentation): ChatLine { + const next = { ...line, parts: [...line.parts.filter((part) => part.type !== "image"), ...presentation.images] }; + return presentation.meta === undefined ? next : { ...next, meta: presentation.meta }; +} + +function toolResultPresentation(message: unknown): ToolResultPresentation { + const normalized = normalizeMessage(message); + const images = normalized.flatMap((line) => line.parts.filter((part): part is ToolResultImage => part.type === "image")); + const meta = normalized.find((line) => line.meta !== undefined)?.meta; + return { images, ...(meta === undefined ? {} : { meta }) }; +} + +function toolResultFromRawMessage(message: unknown): ToolResultUpdate | undefined { if (getString(message, "role") !== "toolResult") return undefined; const toolCallId = getString(message, "toolCallId"); const content = getProperty(message, "content"); @@ -140,6 +184,7 @@ function toolResultFromRawMessage(message: unknown): { toolCallId?: string; tool isError: getBoolean(message, "isError") === true, content, details: getProperty(message, "details"), + presentation: toolResultPresentation(message), }; } diff --git a/src/client/src/components/ChatView.image.test.ts b/src/client/src/components/ChatView.image.test.ts new file mode 100644 index 00000000..b810478e --- /dev/null +++ b/src/client/src/components/ChatView.image.test.ts @@ -0,0 +1,140 @@ +import type { TemplateResult } from "lit"; +import { describe, expect, it } from "vitest"; +import type { ChatLine } from "./shared"; +import { ChatView, chatMessageMetadataLabel } from "./ChatView"; + +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 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=")).toEqual([chatMessageMetadataLabel(message)]); + expect(templateValuesAfterMarker(rendered, "data-scroll-anchor-id=")).toEqual(["m:7"]); + }); +}); + +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"); + if (!isRenderPart(method)) throw new Error("ChatView.renderPart is not callable"); + 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 8cc549ef..067bba78 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(); }; @@ -202,10 +205,12 @@ export class ChatView extends LitElement { ${this.renderHistoryBoundary()} ${repeat( groups, - (group) => group.kind === "message" ? this.messageAnchorKey(group.index) : this.groupRenderKey(group.startIndex), - (group, index) => group.kind === "message" - ? this.renderMessage(group.message, group.index) - : this.renderMessageGroup(group.messages, group.startIndex, group.endIndex, this.isLiveTailGroup(groups, index)), + (group) => group.kind === "group" ? this.groupRenderKey(group.startIndex) : this.messageAnchorKey(group.index), + (group, index) => { + if (group.kind === "group") return this.renderMessageGroup(group.messages, group.startIndex, group.endIndex, this.isLiveTailGroup(groups, index)); + if (group.kind === "tool-image") return this.renderToolImageOutput(group.message, group.index, group.toolName); + return this.renderMessage(group.message, group.index); + }, )} ${this.renderQueuedMessages()} ${this.renderSessionActivity()} @@ -377,6 +382,17 @@ export class ChatView extends LitElement { `; } + private renderToolImageOutput(message: ChatLine, index: number, toolName?: string) { + const label = toolName === undefined || toolName === "" ? "tool output" : `${toolName} output`; + return html` + ${this.renderScrollMarker(this.messageScrollMarkerId(index))} +
+ ${this.renderMessageHeader(message, String(index), label)} + ${message.parts.map((part) => this.renderPart(part, message))} +
+ `; + } + private isToolExecutionOnlyMessage(message: ChatLine): boolean { return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution"); } @@ -416,12 +432,12 @@ export class ChatView extends LitElement { return html``; } - private renderMessageHeader(message: ChatLine, key: string) { + private renderMessageHeader(message: ChatLine, key: string, label: string = message.role) { const meta = this.messageMetaLabel(message); const expanded = this.expandedMetaKey === key; return html`
- ${message.role} + ${label}
${this.renderMessageActions(message, key)} { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta} @@ -500,7 +516,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` diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index c8fd3d2f..cbdf86c6 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -281,7 +281,7 @@ export const chatStyles = css` .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; } .activity-dock.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; } .msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: visible; } - .msg.assistant { background: var(--pi-surface); } + .msg.assistant, .msg.tool-image-output { background: var(--pi-surface); } .msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); } .msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); } .msg.tool-execution-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); } @@ -321,7 +321,7 @@ export const chatStyles = css` .msg-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 22px; margin-bottom: 8px; } .msg > .msg-header { position: sticky; top: -26px; z-index: 4; margin: -12px -12px 8px; padding: 7px 10px 6px; border-radius: 9px 9px 0 0; border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent); background: var(--pi-surface); box-shadow: 0 8px 18px var(--pi-shadow-soft); } .msg.user > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-accent-border) 35%, transparent); background: var(--pi-selection-bg); } - .msg.assistant > .msg-header .label { color: var(--pi-text-secondary); } + .msg.assistant > .msg-header .label, .msg.tool-image-output > .msg-header .label { color: var(--pi-text-secondary); } .msg.user > .msg-header .label { color: var(--pi-accent); } .msg.tool > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-warning-border) 35%, transparent); background: var(--pi-warning-surface); } .msg.bash > .msg-header { border-bottom-color: color-mix(in srgb, var(--pi-success) 35%, transparent); background: var(--pi-success-bg); }