summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDaniel Polito <[email protected]>2025-12-29 14:22:48 -0300
committerGitHub <[email protected]>2025-12-29 11:22:48 -0600
commitb7ce46f7a12e68283d6588c33aaf972426ddd65e (patch)
treeb1599ff88021779f969c646a1e154b8871d46129
parent82b8d8fa5dd9206607b60de6130a6115cee68830 (diff)
downloadopencode-b7ce46f7a12e68283d6588c33aaf972426ddd65e.tar.gz
opencode-b7ce46f7a12e68283d6588c33aaf972426ddd65e.zip
Desktop: Image Preview and Dedupe File Upload (#6372)
-rw-r--r--packages/app/src/context/sync.tsx2
-rw-r--r--packages/ui/src/components/image-preview.css63
-rw-r--r--packages/ui/src/components/image-preview.tsx24
-rw-r--r--packages/ui/src/components/message-part.css4
-rw-r--r--packages/ui/src/components/message-part.tsx19
-rw-r--r--packages/ui/src/styles/index.css1
6 files changed, 111 insertions, 2 deletions
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 05156613a..b45936a91 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -56,7 +56,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
- draft.part[input.messageID] = input.parts.slice()
+ draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
diff --git a/packages/ui/src/components/image-preview.css b/packages/ui/src/components/image-preview.css
new file mode 100644
index 000000000..3c47f7a25
--- /dev/null
+++ b/packages/ui/src/components/image-preview.css
@@ -0,0 +1,63 @@
+[data-component="image-preview"] {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ [data-slot="image-preview-container"] {
+ position: relative;
+ z-index: 50;
+ width: min(calc(100vw - 32px), 90vw);
+ max-width: 1200px;
+ height: min(calc(100vh - 32px), 90vh);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+
+ [data-slot="image-preview-content"] {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-height: 100%;
+ border-radius: var(--radius-lg);
+ background: var(--surface-raised-stronger-non-alpha);
+ box-shadow:
+ 0 15px 45px 0 rgba(19, 16, 16, 0.35),
+ 0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
+ 0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
+ overflow: hidden;
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ [data-slot="image-preview-header"] {
+ display: flex;
+ padding: 8px 8px 0;
+ justify-content: flex-end;
+ align-items: center;
+ align-self: stretch;
+ }
+
+ [data-slot="image-preview-body"] {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ overflow: auto;
+ }
+
+ [data-slot="image-preview-image"] {
+ max-width: 100%;
+ max-height: calc(90vh - 100px);
+ object-fit: contain;
+ border-radius: var(--radius-md);
+ }
+ }
+ }
+}
diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx
new file mode 100644
index 000000000..900abc725
--- /dev/null
+++ b/packages/ui/src/components/image-preview.tsx
@@ -0,0 +1,24 @@
+import { Dialog as Kobalte } from "@kobalte/core/dialog"
+import { IconButton } from "./icon-button"
+
+export interface ImagePreviewProps {
+ src: string
+ alt?: string
+}
+
+export function ImagePreview(props: ImagePreviewProps) {
+ return (
+ <div data-component="image-preview">
+ <div data-slot="image-preview-container">
+ <Kobalte.Content data-slot="image-preview-content">
+ <div data-slot="image-preview-header">
+ <Kobalte.CloseButton data-slot="image-preview-close" as={IconButton} icon="close" variant="ghost" />
+ </div>
+ <div data-slot="image-preview-body">
+ <img src={props.src} alt={props.alt ?? "Image preview"} data-slot="image-preview-image" />
+ </div>
+ </Kobalte.Content>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index ce479d8c1..4338940cb 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -40,6 +40,10 @@
border-color: var(--border-strong-base);
}
+ &[data-clickable="true"] {
+ cursor: pointer;
+ }
+
&[data-type="image"] {
width: 48px;
height: 48px;
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 31103b35c..8590b31cb 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -25,6 +25,7 @@ import {
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { useCodeComponent } from "../context/code"
+import { useDialog } from "../context/dialog"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { Button } from "./button"
@@ -33,6 +34,7 @@ import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
+import { ImagePreview } from "./image-preview"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { createAutoScroll } from "../hooks"
@@ -264,6 +266,8 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
+ const dialog = useDialog()
+
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
)
@@ -286,13 +290,26 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}),
)
+ const openImagePreview = (url: string, alt?: string) => {
+ dialog.show(() => <ImagePreview src={url} alt={alt} />)
+ }
+
return (
<div data-component="user-message">
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
{(file) => (
- <div data-slot="user-message-attachment" data-type={file.mime.startsWith("image/") ? "image" : "file"}>
+ <div
+ data-slot="user-message-attachment"
+ data-type={file.mime.startsWith("image/") ? "image" : "file"}
+ data-clickable={file.mime.startsWith("image/") && !!file.url}
+ onClick={() => {
+ if (file.mime.startsWith("image/") && file.url) {
+ openImagePreview(file.url, file.filename)
+ }
+ }}
+ >
<Show
when={file.mime.startsWith("image/") && file.url}
fallback={
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 9bf3e4f69..554bdeb89 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -22,6 +22,7 @@
@import "../components/provider-icon.css" layer(components);
@import "../components/icon.css" layer(components);
@import "../components/icon-button.css" layer(components);
+@import "../components/image-preview.css" layer(components);
@import "../components/text-field.css" layer(components);
@import "../components/list.css" layer(components);
@import "../components/logo.css" layer(components);