tweak(ui): inline attachment previews with chips
This commit is contained in:
parent
fbfbc1eac3
commit
bd332c8f0a
3 changed files with 106 additions and 58 deletions
|
|
@ -52,7 +52,6 @@ import {
|
||||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
|
|
@ -1291,6 +1290,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
/>
|
/>
|
||||||
<PromptContextItems
|
<PromptContextItems
|
||||||
items={contextItems()}
|
items={contextItems()}
|
||||||
|
images={imageAttachments()}
|
||||||
active={(item) => {
|
active={(item) => {
|
||||||
const active = comments.active()
|
const active = comments.active()
|
||||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
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)
|
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||||
prompt.context.remove(item.key)
|
prompt.context.remove(item.key)
|
||||||
}}
|
}}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
openImage={(attachment) =>
|
||||||
/>
|
|
||||||
<PromptImageAttachments
|
|
||||||
attachments={imageAttachments()}
|
|
||||||
onOpen={(attachment) =>
|
|
||||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||||
}
|
}
|
||||||
onRemove={removeAttachment}
|
removeImage={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
imageRemoveLabel={language.t("prompt.attachment.remove")}
|
||||||
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
|
|
|
||||||
|
|
@ -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 { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
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 PromptContextItem = ContextItem & { key: string }
|
||||||
|
|
||||||
type ContextItemsProps = {
|
type ContextItemsProps = {
|
||||||
items: PromptContextItem[]
|
items: PromptContextItem[]
|
||||||
|
images: ImageAttachmentPart[]
|
||||||
active: (item: PromptContextItem) => boolean
|
active: (item: PromptContextItem) => boolean
|
||||||
openComment: (item: PromptContextItem) => void
|
openComment: (item: PromptContextItem) => void
|
||||||
remove: (item: PromptContextItem) => void
|
remove: (item: PromptContextItem) => void
|
||||||
|
openImage: (attachment: ImageAttachmentPart) => void
|
||||||
|
removeImage: (id: string) => void
|
||||||
|
imageRemoveLabel: string
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
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 (
|
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">
|
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||||
<For each={props.items}>
|
<For each={rows()}>
|
||||||
{(item) => {
|
{(row) => {
|
||||||
const directory = getDirectory(item.path)
|
if (row.type === "img") {
|
||||||
const filename = getFilename(item.path)
|
return (
|
||||||
const label = getFilenameTruncated(item.path, 14)
|
<PromptImageAttachment
|
||||||
const selected = props.active(item)
|
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 (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|
@ -38,21 +72,22 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||||
}
|
}
|
||||||
placement="top"
|
placement="top"
|
||||||
openDelay={2000}
|
openDelay={2000}
|
||||||
|
class="shrink-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
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,
|
"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": !!item.commentID && !selected,
|
"hover:bg-surface-interactive-weak": !!row.item.commentID && !selected,
|
||||||
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
||||||
"bg-background-stronger": !selected,
|
"bg-background-stronger": !selected,
|
||||||
}}
|
}}
|
||||||
onClick={() => props.openComment(item)}
|
onClick={() => props.openComment(row.item)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<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">
|
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||||
<Show when={item.selection}>
|
<Show when={row.item.selection}>
|
||||||
{(sel) => (
|
{(sel) => (
|
||||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||||
{sel().startLine === sel().endLine
|
{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"
|
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
props.remove(item)
|
props.remove(row.item)
|
||||||
}}
|
}}
|
||||||
aria-label={props.t("prompt.context.removeFile")}
|
aria-label={props.t("prompt.context.removeFile")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>}
|
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
|
@ -10,6 +10,13 @@ type PromptImageAttachmentsProps = {
|
||||||
removeLabel: string
|
removeLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromptImageAttachmentProps = {
|
||||||
|
attachment: ImageAttachmentPart
|
||||||
|
onOpen: (attachment: ImageAttachmentPart) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
removeLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackClass =
|
const fallbackClass =
|
||||||
"size-12 rounded-[6px] bg-background-stronger flex items-center justify-center shadow-xs-border cursor-default"
|
"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"
|
const imageClass = "size-12 rounded-[6px] object-cover shadow-xs-border"
|
||||||
|
|
@ -19,39 +26,48 @@ const removeClass =
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Show when={props.attachments.length > 0}>
|
<Show when={props.attachments.length > 0}>
|
||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<>
|
||||||
<For each={props.attachments}>
|
{props.attachments.map((attachment) => (
|
||||||
{(attachment) => (
|
<PromptImageAttachment
|
||||||
<Tooltip value={attachment.filename} placement="top" gutter={6}>
|
attachment={attachment}
|
||||||
<div class="relative group">
|
onOpen={props.onOpen}
|
||||||
<Show
|
onRemove={props.onRemove}
|
||||||
when={attachment.mime.startsWith("image/")}
|
removeLabel={props.removeLabel}
|
||||||
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>
|
|
||||||
</Show>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue