diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index dad035fb3cc..9ebf6e8797f 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 00000000000..3c47f7a2584 --- /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 00000000000..900abc72538 --- /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 ( +
+
+ +
+ +
+
+ {props.alt +
+
+
+
+ ) +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index ce479d8c1e8..4338940cb56 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 e43ffc32238..d2c0ed6ca1b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -14,6 +14,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" @@ -22,6 +23,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" @@ -218,6 +220,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, ) @@ -240,13 +244,26 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp }), ) + const openImagePreview = (url: string, alt?: string) => { + dialog.show(() => ) + } + return (
0}>
{(file) => ( -
+
{ + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + >