cc-haha/desktop/src/components/chat/AttachmentGallery.tsx
程序员阿江(Relakkes) 52b11697e1 feat: make selected context reusable in desktop chat
Workspace and transcript selections now flow into the composer as lightweight references so users can carry exact snippets into the next prompt without file-only workarounds. The prompt formatter keeps workspace and chat excerpts separate, and the composer avoids sending chat snippets as fake file attachments.

Constraint: Selection references must prepare model context without polluting backend file attachment payloads.
Rejected: Reuse file attachments for chat selections | would leak chat:// pseudo paths into file-oriented payloads.
Confidence: high
Scope-risk: moderate
Directive: Keep chat-selection references prompt-only unless the backend gains a first-class chat context attachment type.
Tested: bun run check:desktop
Tested: bun run verify
Tested: agent-browser UI smoke for workspace, user-message, and assistant-message selections
Not-tested: Strict Chrome extension channel; connection timed out, local browser automation covered the flow.
2026-05-13 14:08:12 +08:00

143 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react'
import { ImageGalleryModal } from './ImageGalleryModal'
export type AttachmentPreview = {
id?: string
type: 'image' | 'file'
name: string
path?: string
data?: string
previewUrl?: string
isDirectory?: boolean
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
type Props = {
attachments: AttachmentPreview[]
variant?: 'composer' | 'message'
onRemove?: (id: string) => void
}
export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) {
const [activeImageIndex, setActiveImageIndex] = useState<number | null>(null)
const images = useMemo(
() =>
attachments
.filter((attachment) => attachment.type === 'image' && (attachment.previewUrl || attachment.data))
.map((attachment) => ({
src: attachment.previewUrl || attachment.data || '',
name: attachment.name,
})),
[attachments],
)
if (attachments.length === 0) return null
const isComposer = variant === 'composer'
return (
<>
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
{attachments.map((attachment, index) => {
if (attachment.type === 'image' && (attachment.previewUrl || attachment.data)) {
const src = attachment.previewUrl || attachment.data || ''
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
className={isComposer ? 'group relative' : ''}
>
<button
type="button"
onClick={() => setActiveImageIndex(images.findIndex((image) => image.src === src))}
className={
isComposer
? 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)]'
: 'overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-sm transition-transform hover:scale-[1.01]'
}
>
<img
src={src}
alt={attachment.name}
className={
isComposer
? 'h-16 w-16 object-cover'
: 'max-h-[340px] w-full max-w-[360px] object-cover'
}
/>
</button>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${attachment.name}`}
>
×
</button>
)}
</div>
)
}
const lineLabel = attachment.lineStart
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
: ''
const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ')
const hasQuotePreview = !!quotePreview
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
className={[
'group/file inline-flex max-w-full min-w-0 border border-[var(--color-border)]',
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
hasQuotePreview
? 'items-start gap-2 rounded-[8px] px-2.5 py-2'
: 'h-9 items-center gap-2 rounded-full px-3',
].join(' ')}
>
<span className={`material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)] ${hasQuotePreview ? 'mt-0.5' : ''}`}>
{hasQuotePreview ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'}
</span>
<span className="min-w-0">
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">
{attachment.name}{lineLabel}
</span>
{hasQuotePreview && (
<span className="mt-0.5 block max-w-[320px] truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{quotePreview}
</span>
)}
</span>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className={`${hasQuotePreview ? 'mt-0.5' : 'ml-0.5'} flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]`}
aria-label={`Remove ${attachment.name}`}
>
<span className="material-symbols-outlined text-[17px]">close</span>
</button>
)}
</div>
)
})}
</div>
{activeImageIndex !== null && activeImageIndex >= 0 && (
<ImageGalleryModal
open={activeImageIndex !== null}
images={images}
activeIndex={activeImageIndex}
onClose={() => setActiveImageIndex(null)}
onSelect={setActiveImageIndex}
/>
)}
</>
)
}