From 496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:24:52 -0600 Subject: [PATCH] feat(app): render images in session review --- packages/app/src/pages/session.tsx | 10 + packages/ui/src/components/session-review.css | 26 +++ packages/ui/src/components/session-review.tsx | 216 ++++++++++++++---- 3 files changed, 202 insertions(+), 50 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3a14cf401..719e13f00 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -93,6 +93,15 @@ function SessionReviewTab(props: SessionReviewTabProps) { let frame: number | undefined let pending: { x: number; y: number } | undefined + const sdk = useSDK() + + const readFile = (path: string) => { + return sdk.client.file + .read({ path }) + .then((x) => x.data) + .catch(() => undefined) + } + const restoreScroll = (retries = 0) => { const el = scroll if (!el) return @@ -161,6 +170,7 @@ function SessionReviewTab(props: SessionReviewTabProps) { diffStyle={props.diffStyle} onDiffStyleChange={props.onDiffStyleChange} onViewFile={props.onViewFile} + readFile={readFile} /> ) } diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index eb6ddb441..e682c19b4 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -137,4 +137,30 @@ align-items: center; justify-content: flex-end; } + + [data-slot="session-review-file-container"] { + padding: 0; + } + + [data-slot="session-review-image-container"] { + padding: 12px; + display: flex; + justify-content: center; + background: var(--background-stronger); + } + + [data-slot="session-review-image"] { + max-width: 100%; + max-height: 60vh; + object-fit: contain; + border-radius: 8px; + border: 1px solid var(--border-weak-base); + background: var(--background-base); + } + + [data-slot="session-review-image-placeholder"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + color: var(--text-weak); + } } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 27c033f77..4d2123ff0 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -5,12 +5,14 @@ import { DiffChanges } from "./diff-changes" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { StickyAccordionHeader } from "./sticky-accordion-header" +import { useCodeComponent } from "../context/code" import { useDiffComponent } from "../context/diff" import { useI18n } from "../context/i18n" +import { checksum } from "@opencode-ai/util/encode" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { For, Match, Show, Switch, type JSX } from "solid-js" +import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { type FileDiff } from "@opencode-ai/sdk/v2" +import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { Dynamic } from "solid-js/web" @@ -30,11 +32,52 @@ export interface SessionReviewProps { actions?: JSX.Element diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult })[] onViewFile?: (file: string) => void + readFile?: (path: string) => Promise +} + +const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"]) + +function getExtension(file: string): string { + const idx = file.lastIndexOf(".") + if (idx === -1) return "" + return file.slice(idx + 1).toLowerCase() +} + +function isImageFile(file: string): boolean { + return imageExtensions.has(getExtension(file)) +} + +function dataUrl(content: FileContent | undefined): string | undefined { + if (!content) return + if (content.encoding !== "base64") return + const mime = content.mimeType ?? "" + if (!mime.startsWith("image/")) return + return `data:${mime};base64,${content.content}` +} + +function dataUrlFromValue(value: unknown): string | undefined { + if (typeof value === "string") { + if (value.startsWith("data:image/")) return value + return + } + if (!value || typeof value !== "object") return + + const content = (value as { content?: unknown }).content + const encoding = (value as { encoding?: unknown }).encoding + const mimeType = (value as { mimeType?: unknown }).mimeType + + if (typeof content !== "string") return + if (encoding !== "base64") return + if (typeof mimeType !== "string") return + if (!mimeType.startsWith("image/")) return + + return `data:${mimeType};base64,${content}` } export const SessionReview = (props: SessionReviewProps) => { const i18n = useI18n() const diffComponent = useDiffComponent() + const codeComponent = useCodeComponent() const [store, setStore] = createStore({ open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file), }) @@ -100,56 +143,129 @@ export const SessionReview = (props: SessionReviewProps) => { > - {(diff) => ( - - - -
-
- -
- - {`\u202A${getDirectory(diff.file)}\u202C`} - - {getFilename(diff.file)} - - - + {(diff) => { + const beforeText = () => (typeof diff.before === "string" ? diff.before : "") + const afterText = () => (typeof diff.after === "string" ? diff.after : "") + + const isAdded = () => beforeText().length === 0 && afterText().length > 0 + const isDeleted = () => afterText().length === 0 && beforeText().length > 0 + const isImage = () => isImageFile(diff.file) + + const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before) + const [imageSrc, setImageSrc] = createSignal(diffImageSrc) + const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle") + + createEffect(() => { + if (!open().includes(diff.file)) return + if (!isImage()) return + if (imageSrc()) return + if (imageStatus() !== "idle") return + + const reader = props.readFile + if (!reader) return + + setImageStatus("loading") + reader(diff.file) + .then((result) => { + const src = dataUrl(result) + if (!src) { + setImageStatus("error") + return + } + setImageSrc(src) + setImageStatus("idle") + }) + .catch(() => { + setImageStatus("error") + }) + }) + + const fileForCode = () => { + const contents = afterText() || beforeText() + return { + name: diff.file, + contents, + cacheKey: checksum(contents), + } + } + + return ( + + + +
+
+ +
+ + {`\u202A${getDirectory(diff.file)}\u202C`} + + {getFilename(diff.file)} + + + +
+
+
+ +
-
- - -
-
- - - - - - - )} + + + + + +
+ + + Loading image... + Image preview unavailable + +
+ } + > + {getFilename(diff.file)} + +
+ + +
+ +
+
+ + + + + + + ) + }}