diff --git a/.changeset/zoom-chat-images.md b/.changeset/zoom-chat-images.md
new file mode 100644
index 0000000..b801641
--- /dev/null
+++ b/.changeset/zoom-chat-images.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Let chat images open in a full-size modal viewer on click or keyboard activation, with backdrop and Escape to dismiss, a touch-friendly close button, and safe-area handling so the viewer clears device notches.
diff --git a/src/client/src/components/ChatView.image.test.ts b/src/client/src/components/ChatView.image.test.ts
index b810478..6083225 100644
--- a/src/client/src/components/ChatView.image.test.ts
+++ b/src/client/src/components/ChatView.image.test.ts
@@ -15,6 +15,7 @@ describe("ChatView image rendering", () => {
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=");
+
+ expect(zoomedImage(view)).toBeUndefined();
+ onClick(new Event("click"));
+ expect(zoomedImage(view)).toEqual({ src: "data:image/png;base64,QUJD", alt: "attached image" });
+
+ 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", () => {
@@ -40,11 +58,15 @@ describe("ChatView image rendering", () => {
expect(markup).not.toContain('class="msg tool"');
expect(markup).toContain("
')).toEqual(["read output"]);
- expect(templateValuesAfterMarker(rendered, "title=")).toEqual([chatMessageMetadataLabel(message)]);
+ 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 {
+ return Reflect.get(view, "zoomedImage");
+}
+
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;
diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts
index 65518b2..78aea9c 100644
--- a/src/client/src/components/ChatView.ts
+++ b/src/client/src/components/ChatView.ts
@@ -94,7 +94,9 @@ export class ChatView extends LitElement {
@property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement;
+ @query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
@state() private pinnedToBottom = true;
+ @state() private zoomedImage: { src: string; alt: string } | undefined = undefined;
@state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined;
@state() private currentConversationIndex: number | undefined;
@@ -126,6 +128,15 @@ export class ChatView extends LitElement {
private readonly onImageLoad = (): void => {
if (this.pinnedToBottom) this.scrollToBottom();
};
+ private readonly openImageZoom = (src: string, alt: string): void => {
+ this.zoomedImage = { src, alt };
+ };
+ private readonly closeImageZoom = (): void => {
+ if (this.zoomedImage !== undefined) this.zoomedImage = undefined;
+ };
+ private readonly onImageZoomDialogClick = (event: MouseEvent): void => {
+ if (event.target === this.imageZoomDialog) this.closeImageZoom();
+ };
private readonly onPageHide = () => {
this.saveScrollPosition();
};
@@ -200,6 +211,14 @@ export class ChatView extends LitElement {
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
+ if (changed.has("zoomedImage")) this.syncImageZoomDialog();
+ }
+
+ private syncImageZoomDialog(): void {
+ const dialog = this.imageZoomDialog;
+ if (dialog === undefined) return;
+ if (this.zoomedImage !== undefined && !dialog.open) dialog.showModal();
+ else if (this.zoomedImage === undefined && dialog.open) dialog.close();
}
override render() {
@@ -223,6 +242,18 @@ export class ChatView extends LitElement {
${this.renderActivityDock()}
+ ${this.renderImageZoom()}
+ `;
+ }
+
+ private renderImageZoom() {
+ return html`
+
`;
}
@@ -528,7 +559,11 @@ export class ChatView extends LitElement {
read ${part.path}
`;
- if (part.type === "image") return html`
`;
+ if (part.type === "image") {
+ const src = `data:${part.mimeType};base64,${part.data}`;
+ const alt = "attached image";
+ return html`
{ 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`