tweak(ui): inline attachment previews with chips
parent
fbfbc1eac3
commit
bd332c8f0a
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue