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` + + ${this.zoomedImage === undefined ? null : html` + + ${this.zoomedImage.alt} + `} + `; } @@ -528,7 +559,11 @@ export class ChatView extends LitElement { read ${part.path} `; - if (part.type === "image") return html`attached image`; + if (part.type === "image") { + const src = `data:${part.mimeType};base64,${part.data}`; + const alt = "attached image"; + 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}
`; 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 6b02628..c2c2559 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -294,7 +294,15 @@ export const chatStyles = css` .msg.event-group.live > summary { border-bottom-color: var(--pi-success-border); background: var(--pi-success-bg); color: var(--pi-success); } .msg.event-group > summary .label { margin: 0; } .group-body { padding: 0 12px 12px; } - .chat-image { display: block; max-width: 100%; max-height: 320px; margin: 8px 0 0; border: 1px solid var(--pi-border-muted); border-radius: 8px; object-fit: contain; } + .chat-image { display: block; max-width: 100%; max-height: 320px; margin: 8px 0 0; border: 1px solid var(--pi-border-muted); border-radius: 8px; object-fit: contain; cursor: zoom-in; } + .chat-image:focus-visible { outline: 2px solid var(--pi-accent, var(--pi-success-border)); outline-offset: 2px; } + dialog.image-zoom { position: fixed; inset: 0; margin: auto; max-width: calc(96vw - env(safe-area-inset-left) - env(safe-area-inset-right)); max-height: calc(96vh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); width: fit-content; height: fit-content; padding: 0; border: none; background: transparent; overflow: visible; } + dialog.image-zoom[open] { display: flex; } + dialog.image-zoom::backdrop { background: rgba(0, 0, 0, 0.8); } + .image-zoom-full { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: 8px; object-fit: contain; cursor: zoom-out; } + .image-zoom-close { position: absolute; top: max(8px, env(safe-area-inset-top)); right: max(8px, env(safe-area-inset-right)); width: 44px; height: 44px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 22px; line-height: 1; color: var(--pi-text); background: color-mix(in srgb, var(--pi-surface) 85%, transparent); border: 1px solid var(--pi-border); border-radius: 50%; cursor: pointer; } + .image-zoom-close:focus-visible { outline: 2px solid var(--pi-accent, var(--pi-success-border)); outline-offset: 2px; } + @media (min-width: 640px) { .image-zoom-close { top: -14px; right: -14px; background: var(--pi-surface); } } .group-msg { max-width: 100%; min-width: 0; box-sizing: border-box; padding: 10px 0; border-top: 1px solid var(--pi-border-muted); color: var(--pi-text); overflow: visible; } .group-msg.tool { color: var(--pi-warning); } .group-msg.tool-execution-shell { color: var(--pi-text); }