Przeglądaj źródła

Desktop: Image Preview and Dedupe File Upload (#6372)

Daniel Polito 1 miesiąc temu
rodzic
commit
b7ce46f7a1

+ 1 - 1
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))
             }),
           )
         },

+ 63 - 0
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);
+      }
+    }
+  }
+}

+ 24 - 0
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>
+  )
+}

+ 4 - 0
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;

+ 18 - 1
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={

+ 1 - 0
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);