Merge pull request #60 from jmfederico/fix/pr-53-live-tool-result-images

fix: show live tool result images outside event groups
This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 23:36:54 +02:00
committed by GitHub
8 changed files with 462 additions and 23 deletions
+37
View File
@@ -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" } } };
+20 -2
View File
@@ -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");
}
+179 -1
View File
@@ -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<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 !== "group"
? group.message.parts.filter((part) => part.type === "image")
: []);
const visibleImageMeta = (groups: ReturnType<typeof groupChatMessages>) => {
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;
+56 -11
View File
@@ -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<ChatLine["parts"][number], { type: "image" }>;
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<Sessio
};
}
function finalizeToolExecution(messages: ChatLine[], toolCallId: string | undefined, toolName: string, fallbackSummary: string, text: string, isError: boolean, content: unknown, details: unknown): ChatLine[] {
function finalizeToolExecution(messages: ChatLine[], result: ToolResultUpdate): ChatLine[] {
const { toolCallId, toolName, text, isError, content, details, presentation } = result;
const updated = updateToolExecution(messages, toolCallId, (part) => {
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),
};
}
@@ -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("<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);
});
// 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("<img");
expect(templateValuesAfterMarker(rendered, '<b class="label">')).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");
}
+23 -7
View File
@@ -123,6 +123,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();
};
@@ -208,10 +211,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()}
@@ -389,6 +394,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))}
<article class="msg tool-image-output" data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
${this.renderMessageHeader(message, String(index), label)}
${message.parts.map((part) => this.renderPart(part, message))}
</article>
`;
}
private isToolExecutionOnlyMessage(message: ChatLine): boolean {
return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution");
}
@@ -428,12 +444,12 @@ export class ChatView extends LitElement {
return html`<span class="scroll-marker" data-marker-id=${markerId} aria-hidden="true"></span>`;
}
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`
<div class="msg-header">
<b class="label">${message.role}</b>
<b class="label">${label}</b>
<div class="msg-header-trailing">
${this.renderMessageActions(message, key)}
<span class=${expanded ? "msg-meta expanded" : "msg-meta"} role="button" tabindex="0" title=${meta} aria-label=${meta} aria-expanded=${String(expanded)} @click=${() => { this.expandedMetaKey = expanded ? undefined : key; }} @keydown=${(event: KeyboardEvent) => { this.onMetaKeydown(event, key, expanded); }}>${meta}</span>
@@ -512,7 +528,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`
+2 -2
View File
@@ -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); }
@@ -324,7 +324,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); }