diff options
| author | Adam <[email protected]> | 2026-01-22 11:24:52 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 12:22:10 -0600 |
| commit | 496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2 (patch) | |
| tree | a56aae966eea61e5945ea7681889c9ab52320b1c | |
| parent | 93044cc7d11d406a661278ed7f05065d85cda60a (diff) | |
| download | opencode-496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2.tar.gz opencode-496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2.zip | |
feat(app): render images in session review
| -rw-r--r-- | packages/app/src/pages/session.tsx | 10 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.css | 26 | ||||
| -rw-r--r-- | 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<any> })[] onViewFile?: (file: string) => void + readFile?: (path: string) => Promise<FileContent | undefined> +} + +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) => { > <Accordion multiple value={open()} onChange={handleChange}> <For each={props.diffs}> - {(diff) => ( - <Accordion.Item value={diff.file} data-slot="session-review-accordion-item"> - <StickyAccordionHeader> - <Accordion.Trigger> - <div data-slot="session-review-trigger-content"> - <div data-slot="session-review-file-info"> - <FileIcon node={{ path: diff.file, type: "file" }} /> - <div data-slot="session-review-file-name-container"> - <Show when={diff.file.includes("/")}> - <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span> - </Show> - <span data-slot="session-review-filename">{getFilename(diff.file)}</span> - <Show when={props.onViewFile}> - <button - data-slot="session-review-view-button" - type="button" - onClick={(e) => { - e.stopPropagation() - props.onViewFile?.(diff.file) - }} - > - <Icon name="eye" size="small" /> - </button> - </Show> + {(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<string | undefined>(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 ( + <Accordion.Item value={diff.file} data-slot="session-review-accordion-item"> + <StickyAccordionHeader> + <Accordion.Trigger> + <div data-slot="session-review-trigger-content"> + <div data-slot="session-review-file-info"> + <FileIcon node={{ path: diff.file, type: "file" }} /> + <div data-slot="session-review-file-name-container"> + <Show when={diff.file.includes("/")}> + <span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span> + </Show> + <span data-slot="session-review-filename">{getFilename(diff.file)}</span> + <Show when={props.onViewFile}> + <button + data-slot="session-review-view-button" + type="button" + onClick={(e) => { + e.stopPropagation() + props.onViewFile?.(diff.file) + }} + > + <Icon name="eye" size="small" /> + </button> + </Show> + </div> + </div> + <div data-slot="session-review-trigger-actions"> + <DiffChanges changes={diff} /> + <Icon name="chevron-grabber-vertical" size="small" /> </div> </div> - <div data-slot="session-review-trigger-actions"> - <DiffChanges changes={diff} /> - <Icon name="chevron-grabber-vertical" size="small" /> - </div> - </div> - </Accordion.Trigger> - </StickyAccordionHeader> - <Accordion.Content data-slot="session-review-accordion-content"> - <Dynamic - component={diffComponent} - preloadedDiff={diff.preloaded} - diffStyle={diffStyle()} - before={{ - name: diff.file!, - contents: typeof diff.before === "string" ? diff.before : "", - }} - after={{ - name: diff.file!, - contents: typeof diff.after === "string" ? diff.after : "", - }} - /> - </Accordion.Content> - </Accordion.Item> - )} + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content data-slot="session-review-accordion-content"> + <Switch> + <Match when={isImage()}> + <div data-slot="session-review-image-container"> + <Show + when={imageSrc()} + fallback={ + <div data-slot="session-review-image-placeholder"> + <Switch> + <Match when={imageStatus() === "loading"}>Loading image...</Match> + <Match when={true}>Image preview unavailable</Match> + </Switch> + </div> + } + > + <img data-slot="session-review-image" src={imageSrc()!} alt={getFilename(diff.file)} /> + </Show> + </div> + </Match> + <Match when={isAdded() || isDeleted()}> + <div data-slot="session-review-file-container"> + <Dynamic component={codeComponent} file={fileForCode()} overflow="scroll" /> + </div> + </Match> + <Match when={true}> + <Dynamic + component={diffComponent} + preloadedDiff={diff.preloaded} + diffStyle={diffStyle()} + before={{ + name: diff.file!, + contents: beforeText(), + }} + after={{ + name: diff.file!, + contents: afterText(), + }} + /> + </Match> + </Switch> + </Accordion.Content> + </Accordion.Item> + ) + }} </For> </Accordion> </div> |
