-
-
Notifications
You must be signed in to change notification settings - Fork 415
feat(web): show image thumbnails in composer attachment chips #511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e069e0c
ed86b3a
8c280cc
03abe29
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import { useState } from 'react' | ||
| import { AttachmentPrimitive, useThreadComposerAttachment } from '@assistant-ui/react' | ||
| import { Spinner } from '@/components/Spinner' | ||
| import { ImageLightbox } from '@/components/ImageLightbox' | ||
|
|
||
| function ErrorIcon() { | ||
| return ( | ||
|
|
@@ -31,9 +33,45 @@ function RemoveIcon() { | |
| } | ||
|
|
||
| export function AttachmentItem() { | ||
| const { name, status } = useThreadComposerAttachment() | ||
| const attachment = useThreadComposerAttachment() | ||
| const { name, status } = attachment | ||
| const isUploading = status.type === 'running' | ||
| const isError = status.type === 'incomplete' | ||
| const [lightboxOpen, setLightboxOpen] = useState(false) | ||
|
|
||
| const previewUrl = (attachment as Record<string, unknown>).previewUrl as string | undefined | ||
| const isImage = typeof previewUrl === 'string' && previewUrl.length > 0 | ||
|
|
||
| if (isImage && !isError) { | ||
| return ( | ||
| <> | ||
| <AttachmentPrimitive.Root className="group relative overflow-hidden rounded-lg"> | ||
| <img | ||
| src={previewUrl} | ||
| alt={name} | ||
| className="h-16 max-w-[120px] cursor-pointer rounded-lg object-cover transition-opacity hover:opacity-80" | ||
| onClick={() => setLightboxOpen(true)} | ||
| /> | ||
| {isUploading && ( | ||
| <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/40"> | ||
| <Spinner size="sm" label={null} className="text-white" /> | ||
| </div> | ||
| )} | ||
| <AttachmentPrimitive.Remove | ||
| className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black/80 hover:text-white" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MAJOR] Suggested fix: <AttachmentPrimitive.Remove
className="absolute right-1 top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-100 transition-opacity group-focus-within:opacity-100 md:opacity-0 md:group-hover:opacity-100 hover:bg-black/80 hover:text-white"
aria-label="Remove attachment"
title="Remove attachment"
> |
||
| aria-label="Remove attachment" | ||
| title="Remove attachment" | ||
| > | ||
| <RemoveIcon /> | ||
| </AttachmentPrimitive.Remove> | ||
| <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1"> | ||
| <span className="text-[10px] leading-tight text-white/90 line-clamp-1">{name}</span> | ||
| </div> | ||
| </AttachmentPrimitive.Root> | ||
| <ImageLightbox src={previewUrl} alt={name} open={lightboxOpen} onClose={() => setLightboxOpen(false)} /> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <AttachmentPrimitive.Root className="flex items-center gap-2 rounded-lg bg-[var(--app-subtle-bg)] px-3 py-2 text-base text-[var(--app-fg)]"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import { useEffect, useCallback, useState } from 'react' | ||
| import { createPortal } from 'react-dom' | ||
|
|
||
| interface ImageLightboxProps { | ||
| src: string | ||
| alt?: string | ||
| open: boolean | ||
| onClose: () => void | ||
| } | ||
|
|
||
| export function ImageLightbox({ src, alt, open, onClose }: ImageLightboxProps) { | ||
| const handleKeyDown = useCallback( | ||
| (e: KeyboardEvent) => { | ||
| if (e.key === 'Escape') onClose() | ||
| }, | ||
| [onClose] | ||
| ) | ||
|
|
||
| useEffect(() => { | ||
| if (!open) return | ||
| document.addEventListener('keydown', handleKeyDown) | ||
| document.body.style.overflow = 'hidden' | ||
| return () => { | ||
| document.removeEventListener('keydown', handleKeyDown) | ||
| document.body.style.overflow = '' | ||
| } | ||
| }, [open, handleKeyDown]) | ||
|
|
||
| if (!open) return null | ||
|
|
||
| return createPortal( | ||
| <div | ||
| className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90" | ||
| onClick={onClose} | ||
| > | ||
| {/* Top-right buttons */} | ||
| <div className="fixed right-4 top-4 z-[101] flex items-center gap-2"> | ||
| <button | ||
| className="rounded-lg bg-white/10 p-2 text-white/80 backdrop-blur-sm transition-colors hover:bg-white/20 hover:text-white" | ||
| title="在新标签页打开" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MINOR] These new tooltip strings bypass the existing locale tables, so English sessions now show Chinese-only UI here. Suggested fix: import { useTranslation } from '@/lib/use-translation'
const { t } = useTranslation()
<button title={t('imageLightbox.openInNewTab')} aria-label={t('imageLightbox.openInNewTab')} />
<button title={t('button.close')} aria-label={t('button.close')} /> |
||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| window.open(src, '_blank', 'noopener,noreferrer') | ||
| }} | ||
| > | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | ||
| <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> | ||
| <polyline points="15 3 21 3 21 9" /> | ||
| <line x1="10" y1="14" x2="21" y2="3" /> | ||
| </svg> | ||
| </button> | ||
| <button | ||
| className="rounded-lg bg-white/10 p-2 text-white/80 backdrop-blur-sm transition-colors hover:bg-white/20 hover:text-white" | ||
| title="关闭" | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| onClose() | ||
| }} | ||
| > | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | ||
| <line x1="18" y1="6" x2="6" y2="18" /> | ||
| <line x1="6" y1="6" x2="18" y2="18" /> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
|
|
||
| {/* Image */} | ||
| <img | ||
| src={src} | ||
| alt={alt ?? 'Preview'} | ||
| className="max-h-[90vh] max-w-[90vw] rounded object-contain" | ||
| onClick={(e) => e.stopPropagation()} | ||
| /> | ||
| </div>, | ||
| document.body | ||
| ) | ||
| } | ||
|
|
||
| export function useImageLightbox() { | ||
| const [lightbox, setLightbox] = useState<{ src: string; alt?: string } | null>(null) | ||
|
|
||
| const openLightbox = useCallback((src: string, alt?: string) => { | ||
| setLightbox({ src, alt }) | ||
| }, []) | ||
|
|
||
| const closeLightbox = useCallback(() => { | ||
| setLightbox(null) | ||
| }, []) | ||
|
|
||
| const LightboxPortal = lightbox ? ( | ||
| <ImageLightbox | ||
| src={lightbox.src} | ||
| alt={lightbox.alt} | ||
| open={true} | ||
| onClose={closeLightbox} | ||
| /> | ||
| ) : null | ||
|
|
||
| return { openLightbox, LightboxPortal } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[MAJOR]
isImagenow depends onpreviewUrl, butpreviewUrlis only added after the adapter switches the attachment torequires-action(web/src/lib/attachmentAdapter.ts:93-107). During bothrunningyields there is still no preview URL, so this new thumbnail + spinner state never appears while the upload is in progress.Suggested fix: