Kaynağa Gözat

tweak(ui): inline attachment previews with chips

David Hill 1 ay önce
ebeveyn
işleme
bd332c8f0a

+ 5 - 8
packages/app/src/components/prompt-input.tsx

@@ -52,7 +52,6 @@ import {
 import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
 import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
 import { PromptContextItems } from "./prompt-input/context-items"
-import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
 import { promptPlaceholder } from "./prompt-input/placeholder"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
@@ -1291,6 +1290,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         />
         <PromptContextItems
           items={contextItems()}
+          images={imageAttachments()}
           active={(item) => {
             const active = comments.active()
             return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1300,15 +1300,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             if (item.commentID) comments.remove(item.path, item.commentID)
             prompt.context.remove(item.key)
           }}
-          t={(key) => language.t(key as Parameters<typeof language.t>[0])}
-        />
-        <PromptImageAttachments
-          attachments={imageAttachments()}
-          onOpen={(attachment) =>
+          openImage={(attachment) =>
             dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
           }
-          onRemove={removeAttachment}
-          removeLabel={language.t("prompt.attachment.remove")}
+          removeImage={removeAttachment}
+          imageRemoveLabel={language.t("prompt.attachment.remove")}
+          t={(key) => language.t(key as Parameters<typeof language.t>[0])}
         />
         <div
           class="relative"

+ 51 - 16
packages/app/src/components/prompt-input/context-items.tsx

@@ -1,30 +1,64 @@
-import { Component, For, Show } from "solid-js"
+import { Component, For, Show, createMemo } from "solid-js"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
-import type { ContextItem } from "@/context/prompt"
+import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
+import { PromptImageAttachment } from "./image-attachments"
 
 type PromptContextItem = ContextItem & { key: string }
 
 type ContextItemsProps = {
   items: PromptContextItem[]
+  images: ImageAttachmentPart[]
   active: (item: PromptContextItem) => boolean
   openComment: (item: PromptContextItem) => void
   remove: (item: PromptContextItem) => void
+  openImage: (attachment: ImageAttachmentPart) => void
+  removeImage: (id: string) => void
+  imageRemoveLabel: string
   t: (key: string) => string
 }
 
 export const PromptContextItems: Component<ContextItemsProps> = (props) => {
+  const seen = new Map<string, number>()
+  let seq = 0
+
+  const rows = createMemo(() => {
+    const all = [
+      ...props.items.map((item) => ({ type: "ctx" as const, key: `ctx:${item.key}`, item })),
+      ...props.images.map((attachment) => ({ type: "img" as const, key: `img:${attachment.id}`, attachment })),
+    ]
+
+    for (const row of all) {
+      if (seen.has(row.key)) continue
+      seen.set(row.key, seq)
+      seq += 1
+    }
+
+    return all.slice().sort((a, b) => (seen.get(a.key) ?? 0) - (seen.get(b.key) ?? 0))
+  })
+
   return (
-    <Show when={props.items.length > 0}>
+    <Show when={rows().length > 0}>
       <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
-        <For each={props.items}>
-          {(item) => {
-            const directory = getDirectory(item.path)
-            const filename = getFilename(item.path)
-            const label = getFilenameTruncated(item.path, 14)
-            const selected = props.active(item)
+        <For each={rows()}>
+          {(row) => {
+            if (row.type === "img") {
+              return (
+                <PromptImageAttachment
+                  attachment={row.attachment}
+                  onOpen={props.openImage}
+                  onRemove={props.removeImage}
+                  removeLabel={props.imageRemoveLabel}
+                />
+              )
+            }
+
+            const directory = getDirectory(row.item.path)
+            const filename = getFilename(row.item.path)
+            const label = getFilenameTruncated(row.item.path, 14)
+            const selected = props.active(row.item)
 
             return (
               <Tooltip
@@ -38,21 +72,22 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
                 }
                 placement="top"
                 openDelay={2000}
+                class="shrink-0"
               >
                 <div
                   classList={{
-                    "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
-                    "hover:bg-surface-interactive-weak": !!item.commentID && !selected,
+                    "group flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
+                    "hover:bg-surface-interactive-weak": !!row.item.commentID && !selected,
                     "bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
                     "bg-background-stronger": !selected,
                   }}
-                  onClick={() => props.openComment(item)}
+                  onClick={() => props.openComment(row.item)}
                 >
                   <div class="flex items-center gap-1.5">
-                    <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+                    <FileIcon node={{ path: row.item.path, type: "file" }} class="shrink-0 size-3.5" />
                     <div class="flex items-center text-11-regular min-w-0 font-medium">
                       <span class="text-text-strong whitespace-nowrap">{label}</span>
-                      <Show when={item.selection}>
+                      <Show when={row.item.selection}>
                         {(sel) => (
                           <span class="text-text-weak whitespace-nowrap shrink-0">
                             {sel().startLine === sel().endLine
@@ -69,12 +104,12 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
                       class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
                       onClick={(e) => {
                         e.stopPropagation()
-                        props.remove(item)
+                        props.remove(row.item)
                       }}
                       aria-label={props.t("prompt.context.removeFile")}
                     />
                   </div>
-                  <Show when={item.comment}>
+                  <Show when={row.item.comment}>
                     {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
                   </Show>
                 </div>

+ 50 - 34
packages/app/src/components/prompt-input/image-attachments.tsx

@@ -1,4 +1,4 @@
-import { Component, For, Show } from "solid-js"
+import { Component, Show } from "solid-js"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import type { ImageAttachmentPart } from "@/context/prompt"
@@ -10,6 +10,13 @@ type PromptImageAttachmentsProps = {
   removeLabel: string
 }
 
+type PromptImageAttachmentProps = {
+  attachment: ImageAttachmentPart
+  onOpen: (attachment: ImageAttachmentPart) => void
+  onRemove: (id: string) => void
+  removeLabel: string
+}
+
 const fallbackClass =
   "size-12 rounded-[6px] bg-background-stronger flex items-center justify-center shadow-xs-border cursor-default"
 const imageClass = "size-12 rounded-[6px] object-cover shadow-xs-border"
@@ -19,39 +26,48 @@ const removeClass =
 export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
   return (
     <Show when={props.attachments.length > 0}>
-      <div class="flex flex-wrap gap-2 px-3 pt-3">
-        <For each={props.attachments}>
-          {(attachment) => (
-            <Tooltip value={attachment.filename} placement="top" gutter={6}>
-              <div class="relative group">
-                <Show
-                  when={attachment.mime.startsWith("image/")}
-                  fallback={
-                    <div class={fallbackClass}>
-                      <Icon name="folder" class="size-6 text-text-weak" />
-                    </div>
-                  }
-                >
-                  <img
-                    src={attachment.dataUrl}
-                    alt={attachment.filename}
-                    class={imageClass}
-                    onClick={() => props.onOpen(attachment)}
-                  />
-                </Show>
-                <button
-                  type="button"
-                  onClick={() => props.onRemove(attachment.id)}
-                  class={removeClass}
-                  aria-label={props.removeLabel}
-                >
-                  <Icon name="close" class="size-3 text-text-weak" />
-                </button>
-              </div>
-            </Tooltip>
-          )}
-        </For>
-      </div>
+      <>
+        {props.attachments.map((attachment) => (
+          <PromptImageAttachment
+            attachment={attachment}
+            onOpen={props.onOpen}
+            onRemove={props.onRemove}
+            removeLabel={props.removeLabel}
+          />
+        ))}
+      </>
     </Show>
   )
 }
+
+export const PromptImageAttachment: Component<PromptImageAttachmentProps> = (props) => {
+  return (
+    <Tooltip value={props.attachment.filename} placement="top" gutter={6} class="shrink-0">
+      <div class="relative group">
+        <Show
+          when={props.attachment.mime.startsWith("image/")}
+          fallback={
+            <div class={fallbackClass}>
+              <Icon name="folder" class="size-6 text-text-weak" />
+            </div>
+          }
+        >
+          <img
+            src={props.attachment.dataUrl}
+            alt={props.attachment.filename}
+            class={imageClass}
+            onClick={() => props.onOpen(props.attachment)}
+          />
+        </Show>
+        <button
+          type="button"
+          onClick={() => props.onRemove(props.attachment.id)}
+          class={removeClass}
+          aria-label={props.removeLabel}
+        >
+          <Icon name="close" class="size-3 text-text-weak" />
+        </button>
+      </div>
+    </Tooltip>
+  )
+}