Skip to content

Commit b7ce46f

Browse files
authored
Desktop: Image Preview and Dedupe File Upload (anomalyco#6372)
1 parent 82b8d8f commit b7ce46f

6 files changed

Lines changed: 111 additions & 2 deletions

File tree

packages/app/src/context/sync.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
5656
const result = Binary.search(messages, input.messageID, (m) => m.id)
5757
messages.splice(result.index, 0, message)
5858
}
59-
draft.part[input.messageID] = input.parts.slice()
59+
draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
6060
}),
6161
)
6262
},
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
[data-component="image-preview"] {
2+
position: fixed;
3+
inset: 0;
4+
z-index: 50;
5+
display: flex;
6+
align-items: center;
7+
justify-content: center;
8+
9+
[data-slot="image-preview-container"] {
10+
position: relative;
11+
z-index: 50;
12+
width: min(calc(100vw - 32px), 90vw);
13+
max-width: 1200px;
14+
height: min(calc(100vh - 32px), 90vh);
15+
display: flex;
16+
flex-direction: column;
17+
align-items: center;
18+
justify-content: center;
19+
20+
[data-slot="image-preview-content"] {
21+
display: flex;
22+
flex-direction: column;
23+
align-items: center;
24+
width: 100%;
25+
max-height: 100%;
26+
border-radius: var(--radius-lg);
27+
background: var(--surface-raised-stronger-non-alpha);
28+
box-shadow:
29+
0 15px 45px 0 rgba(19, 16, 16, 0.35),
30+
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
31+
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
32+
overflow: hidden;
33+
34+
&:focus-visible {
35+
outline: none;
36+
}
37+
38+
[data-slot="image-preview-header"] {
39+
display: flex;
40+
padding: 8px 8px 0;
41+
justify-content: flex-end;
42+
align-items: center;
43+
align-self: stretch;
44+
}
45+
46+
[data-slot="image-preview-body"] {
47+
width: 100%;
48+
display: flex;
49+
align-items: center;
50+
justify-content: center;
51+
padding: 16px;
52+
overflow: auto;
53+
}
54+
55+
[data-slot="image-preview-image"] {
56+
max-width: 100%;
57+
max-height: calc(90vh - 100px);
58+
object-fit: contain;
59+
border-radius: var(--radius-md);
60+
}
61+
}
62+
}
63+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Dialog as Kobalte } from "@kobalte/core/dialog"
2+
import { IconButton } from "./icon-button"
3+
4+
export interface ImagePreviewProps {
5+
src: string
6+
alt?: string
7+
}
8+
9+
export function ImagePreview(props: ImagePreviewProps) {
10+
return (
11+
<div data-component="image-preview">
12+
<div data-slot="image-preview-container">
13+
<Kobalte.Content data-slot="image-preview-content">
14+
<div data-slot="image-preview-header">
15+
<Kobalte.CloseButton data-slot="image-preview-close" as={IconButton} icon="close" variant="ghost" />
16+
</div>
17+
<div data-slot="image-preview-body">
18+
<img src={props.src} alt={props.alt ?? "Image preview"} data-slot="image-preview-image" />
19+
</div>
20+
</Kobalte.Content>
21+
</div>
22+
</div>
23+
)
24+
}

packages/ui/src/components/message-part.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
border-color: var(--border-strong-base);
4141
}
4242

43+
&[data-clickable="true"] {
44+
cursor: pointer;
45+
}
46+
4347
&[data-type="image"] {
4448
width: 48px;
4549
height: 48px;

packages/ui/src/components/message-part.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { useData } from "../context"
2626
import { useDiffComponent } from "../context/diff"
2727
import { useCodeComponent } from "../context/code"
28+
import { useDialog } from "../context/dialog"
2829
import { BasicTool } from "./basic-tool"
2930
import { GenericTool } from "./basic-tool"
3031
import { Button } from "./button"
@@ -33,6 +34,7 @@ import { Icon } from "./icon"
3334
import { Checkbox } from "./checkbox"
3435
import { DiffChanges } from "./diff-changes"
3536
import { Markdown } from "./markdown"
37+
import { ImagePreview } from "./image-preview"
3638
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
3739
import { checksum } from "@opencode-ai/util/encode"
3840
import { createAutoScroll } from "../hooks"
@@ -264,6 +266,8 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
264266
}
265267

266268
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
269+
const dialog = useDialog()
270+
267271
const textPart = createMemo(
268272
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
269273
)
@@ -286,13 +290,26 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
286290
}),
287291
)
288292

293+
const openImagePreview = (url: string, alt?: string) => {
294+
dialog.show(() => <ImagePreview src={url} alt={alt} />)
295+
}
296+
289297
return (
290298
<div data-component="user-message">
291299
<Show when={attachments().length > 0}>
292300
<div data-slot="user-message-attachments">
293301
<For each={attachments()}>
294302
{(file) => (
295-
<div data-slot="user-message-attachment" data-type={file.mime.startsWith("image/") ? "image" : "file"}>
303+
<div
304+
data-slot="user-message-attachment"
305+
data-type={file.mime.startsWith("image/") ? "image" : "file"}
306+
data-clickable={file.mime.startsWith("image/") && !!file.url}
307+
onClick={() => {
308+
if (file.mime.startsWith("image/") && file.url) {
309+
openImagePreview(file.url, file.filename)
310+
}
311+
}}
312+
>
296313
<Show
297314
when={file.mime.startsWith("image/") && file.url}
298315
fallback={

packages/ui/src/styles/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
@import "../components/provider-icon.css" layer(components);
2323
@import "../components/icon.css" layer(components);
2424
@import "../components/icon-button.css" layer(components);
25+
@import "../components/image-preview.css" layer(components);
2526
@import "../components/text-field.css" layer(components);
2627
@import "../components/list.css" layer(components);
2728
@import "../components/logo.css" layer(components);

0 commit comments

Comments
 (0)