Skip to content
Source
Sign inSign up
Open History to see the last commit

ComposerAttachmentStrip.tsx

748 lines27.1 KB

1import type {
2 ChatAttachment,
3 ChatFileAttachment,
4 EnvironmentId,
5 ScopedThreadRef,
6 SnapShotSource,
7} from "@t3tools/contracts";
8import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
9import { CircleAlertIcon, ImageIcon, PaperclipIcon, PlayIcon, XIcon } from "lucide-react";
10import { type ComponentProps, useLayoutEffect, useRef } from "react";
11​
12import {
13 type ComposerFileAttachment,
14 type ComposerImageAttachment,
15 type DraftId,
16 composerFileNeedsReattach,
17} from "../../composerDraftStore";
18import { retryAttachmentUpload, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue";
19import type { AttachmentUploadState } from "../../lib/attachmentUploadState";
20import { useAttachmentUploadProgress } from "../../hooks/useAttachmentUploadProgress";
21import { isHeicImageFile } from "../../lib/imageCompression";
22import {
23 type PendingSnapShotAnimation,
24 scheduleSnapShotAnimationDestination,
25 setSnapShotAnimationDestination,
26 shouldAnimateSnapShotArrival,
27} from "../../lib/snapShotAnimation";
28import { cn } from "~/lib/utils";
29import { getRestingComposerImagePreviewCounts } from "../composerFooterLayout";
30import { RefreshIcon } from "~/components/ui/refresh-icon";
31import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
32import { AttachmentImage } from "../media/AttachmentImage";
33import { Button } from "../ui/button";
34import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
35import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
36import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
37import { composerFloatingLayerProps } from "./composerEventScope";
38import type { PreparingComposerImage } from "./preparingComposerImages";
39import {
40 attachVideoThumbnail,
41 buildExpandedImagePreview,
42 type ExpandedImagePreview,
43} from "./ExpandedImagePreview";
44import { PierreEntryIcon } from "./PierreEntryIcon";
45import {
46 SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
47 SnapShotAttachmentDetails,
48} from "./SnapShotAttachmentDetails";
49import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
50​
51function ComposerAttachmentUploadProgress({
52 id,
53 upload,
54 fallback,
55}: {
56 id: string;
57 upload: AttachmentUploadState | undefined;
58 fallback?: string;
59}) {
60 return useAttachmentUploadProgress(id, upload) ?? fallback ?? null;
62​
63function ComposerImageFallback() {
64 return (
65 <span
66 role="img"
67 aria-label="Image attachment"
68 className="flex size-full items-center justify-center p-3 text-secondary-label"
69 >
70 <ImageIcon aria-hidden="true" className="size-4" />
71 </span>
72 );
74​
75function ComposerVideoThumbnail({ file }: { file: File }) {
76 const setVideo = (video: HTMLVideoElement | null) => {
77 if (!video) return;
78 return attachVideoThumbnail(video, file);
79 };
80​
81 return (
82 <video
83 ref={setVideo}
84 muted
85 playsInline
86 preload="metadata"
87 aria-hidden="true"
88 onLoadedMetadata={(event) => prepareVideoFirstFrame(event.currentTarget)}
89 className="pointer-events-none absolute inset-0 size-full object-cover"
90 />
91 );
93​
94function SnapShotAttachmentFrame({
95 animationId,
96 animationSource,
97 arrival,
98 animateArrival,
99 className,
100 ...props
101}: ComponentProps<"div"> & {
102 readonly animationId?: string | undefined;
103 readonly animationSource?: SnapShotSource | undefined;
104 readonly arrival?: boolean | undefined;
105 readonly animateArrival?: boolean | undefined;
106}) {
107 const frameRef = useRef<HTMLDivElement>(null);
108​
109 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the mounted tile into view and registers it as the SnapShot animation destination
110 useLayoutEffect(() => {
111 const frame = frameRef.current;
112 if (!frame || (!animationId && !arrival)) return;
113 frame.scrollIntoView({ block: "nearest", inline: "nearest" });
114 if (!animationId) return;
115​
116 return scheduleSnapShotAnimationDestination(animationId, () =>
117 setSnapShotAnimationDestination(animationId, frame, animationSource),
118 );
119 }, [animationId, animationSource, arrival]);
120​
121 return (
122 <div
123 ref={frameRef}
124 className={cn(
125 animateArrival &&
126 !animationId &&
127 "origin-center transition-[opacity,scale] duration-300 ease-drawer starting:scale-95 starting:opacity-0 motion-reduce:transition-none motion-reduce:starting:scale-100 motion-reduce:starting:opacity-100",
128 className,
129 )}
130 {...props}
131 />
132 );
134​
135/** Image, SnapShot and video tiles above the composer editor. */
136export function ComposerAttachmentMediaRow({
137 images,
138 expandedImages,
139 preparingImages,
140 videos,
141 pendingSnapShotAnimations,
142 pendingSnapShotIds,
143 uncommittedSnapShotIds,
144 nonPersistedImageIds,
145 snapShotAnimations,
146 supportsAttachmentUploads,
147 maxFileAttachmentBytes,
148 environmentId,
149 attachmentDraftTarget,
150 canOperateThread,
151 onExpandImage,
152 onFileOpen,
153 onRemoveImage,
154 onRemoveFile,
155}: {
156 readonly images: ReadonlyArray<ComposerImageAttachment>;
157 readonly expandedImages: ReadonlyArray<ComposerImageAttachment>;
158 readonly preparingImages: ReadonlyArray<PreparingComposerImage>;
159 readonly videos: ReadonlyArray<ComposerFileAttachment>;
160 readonly pendingSnapShotAnimations: ReadonlyArray<PendingSnapShotAnimation>;
161 readonly pendingSnapShotIds: ReadonlyArray<string>;
162 readonly uncommittedSnapShotIds: ReadonlyArray<string>;
163 readonly nonPersistedImageIds: ReadonlyArray<string>;
164 readonly snapShotAnimations: boolean;
165 readonly supportsAttachmentUploads: boolean;
166 readonly maxFileAttachmentBytes: number | null;
167 readonly environmentId: EnvironmentId;
168 readonly attachmentDraftTarget: ScopedThreadRef | DraftId;
169 readonly canOperateThread: boolean;
170 readonly onExpandImage: (preview: ExpandedImagePreview) => void;
171 readonly onFileOpen: (attachment: ChatFileAttachment) => void;
172 readonly onRemoveImage: (imageId: string) => void;
173 readonly onRemoveFile: (fileId: string) => void;
174}) {
175 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
176 const pendingSnapShotIdSet = new Set(pendingSnapShotIds);
177 const nonPersistedImageIdSet = new Set(nonPersistedImageIds);
178 return (
179 <div
180 className={cn(
181 "mb-3 flex max-w-full gap-2",
182 pendingSnapShotIds.length > 0 ||
183 expandedImages.some((image) => image.source?.kind === "snap-shot")
184 ? "snap-x snap-proximity overflow-x-auto overscroll-x-contain pb-1 scrollbar-thumb-foreground/18 scrollbar-track-transparent [scrollbar-width:thin] [&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:border-3 [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-foreground/18 [&::-webkit-scrollbar-thumb]:bg-clip-content [&::-webkit-scrollbar-thumb:hover]:bg-foreground/28 [&::-webkit-scrollbar-track]:mx-1 [&::-webkit-scrollbar-track]:bg-transparent"
185 : "flex-wrap",
186 )}
187 >
188 {/* A preparing image renders through the same tile, keyed by the id its compressed image
189 takes, so the thumbnail element survives the swap. */}
190 {[
191 ...expandedImages.map((image) => ({ image, ready: image })),
192 ...preparingImages.map((image) => ({ image, ready: null })),
193 ]
194 .map(({ image, ready }) => {
195 const upload =
196 ready && supportsAttachmentUploads ? uploadsByImageId[image.id] : undefined;
197 const source = ready?.source;
198 const snapShotAnimationPending =
199 source?.kind === "snap-shot" && pendingSnapShotIdSet.has(image.id);
200 const snapShotArrival =
201 source?.kind === "snap-shot" && shouldAnimateSnapShotArrival(source.capturedAt);
202 return (
203 <SnapShotAttachmentFrame
204 key={image.id}
205 data-chat-composer-expanded-image="true"
206 aria-hidden={snapShotAnimationPending || undefined}
207 inert={snapShotAnimationPending || undefined}
208 arrival={snapShotArrival}
209 animateArrival={snapShotAnimations && !snapShotAnimationPending && snapShotArrival}
210 animationId={snapShotAnimationPending ? image.id : undefined}
211 animationSource={snapShotAnimationPending ? source : undefined}
212 className={cn(
213 "group/attachment shrink-0 snap-start bg-background",
214 source?.kind === "snap-shot"
215 ? SNAP_SHOT_ATTACHMENT_FRAME_CLASS
216 : "relative h-16 w-16 overflow-hidden rounded-lg border border-border/80",
217 snapShotAnimationPending && "invisible",
218 )}
219 >
220 {!ready || ready.previewUrl ? (
221 <button
222 type="button"
223 className={cn("h-full w-full", ready && "cursor-zoom-in")}
224 aria-label={`Preview ${image.name}`}
225 disabled={!ready}
226 onClick={() => {
227 const preview = buildExpandedImagePreview(images, image.id);
228 if (!preview) return;
229 onExpandImage(preview);
230 }}
231 >
232 <ComposerImageThumbnail
233 file={image.file}
234 alt={image.name}
235 className="h-full w-full object-cover"
236 fallback={
237 ready && isHeicImageFile({ name: ready.name, type: ready.mimeType }) ? (
238 <AttachmentImage
239 name={ready.name}
240 mimeType={ready.mimeType}
241 src={ready.previewUrl}
242 alt=""
243 className="h-full w-full object-cover"
244 />
245 ) : (
246 <ComposerImageFallback />
247 )
248 }
249 />
250 </button>
251 ) : (
252 <ComposerImageFallback />
253 )}
254 {source?.kind === "snap-shot" ? (
255 <SnapShotAttachmentDetails
256 source={source}
257 className={cn(
258 upload?.status === "uploading" && "bottom-4",
259 upload?.status === "failed" && "bottom-8",
260 )}
261 />
262 ) : null}
263 {nonPersistedImageIdSet.has(image.id) && (
264 <Tooltip>
265 <TooltipTrigger
266 render={
267 <span
268 role="img"
269 aria-label="Draft attachment may not persist"
270 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-warning-foreground"
271 >
272 <CircleAlertIcon className="size-3" />
273 </span>
274 }
275 />
276 <TooltipPopup side="top">
277 Draft attachment could not be saved locally and may be lost on navigation.
278 </TooltipPopup>
279 </Tooltip>
280 )}
281 {(!ready || upload?.status === "uploading") && (
282 <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-3xs text-foreground">
283 {upload?.status === "uploading" ? (
284 <ComposerAttachmentUploadProgress id={image.id} upload={upload} />
285 ) : (
286 "Preparing"
287 )}
288 </span>
289 )}
290 {ready && upload?.status === "failed" && (
291 <Tooltip>
292 <TooltipTrigger
293 render={
294 <Button
295 variant="overlay"
296 size="icon-xs"
297 className="absolute bottom-1 left-1"
298 onClick={() =>
299 retryAttachmentUpload({
300 environmentId,
301 image: ready,
302 draftTarget: attachmentDraftTarget,
303 })
304 }
305 disabled={!canOperateThread}
306 aria-label={`Retry upload for ${image.name}`}
307 />
308 }
309 >
310 <RefreshIcon />
311 </TooltipTrigger>
312 <TooltipPopup side="top">{upload.reason}</TooltipPopup>
313 </Tooltip>
314 )}
315 <span
316 className={cn(
317 "absolute right-1 top-1 flex",
318 source?.kind === "snap-shot" &&
319 "opacity-0 transition-opacity pointer-coarse:opacity-100 group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100",
320 )}
321 >
322 <Button
323 variant="media-close"
324 size="icon-xs"
325 onClick={() => onRemoveImage(image.id)}
326 aria-label={`Remove ${image.name}`}
327 >
328 <XIcon />
329 </Button>
330 </span>
331 </SnapShotAttachmentFrame>
332 );
333 })
334 .concat(
335 uncommittedSnapShotIds.map((captureId) => (
336 <SnapShotAttachmentFrame
337 key={captureId}
338 aria-hidden="true"
339 animationId={captureId}
340 animationSource={
341 pendingSnapShotAnimations.find((capture) => capture.id === captureId)?.source
342 }
343 className={cn(
344 SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
345 "invisible shrink-0 snap-start bg-background",
346 )}
347 />
348 )),
349 )}
350 {videos.map((file) => {
351 const fileCanUpload =
352 supportsAttachmentUploads &&
353 maxFileAttachmentBytes !== null &&
354 file.sizeBytes <= maxFileAttachmentBytes;
355 const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
356 return (
357 <div
358 key={file.id}
359 className="relative h-16 w-16 shrink-0 snap-start overflow-hidden rounded-lg border border-border/80 bg-black"
360 >
361 <button
362 type="button"
363 className="flex h-full w-full cursor-zoom-in flex-col items-center justify-center gap-1 px-1 text-white"
364 aria-label={`Play ${file.name}`}
365 onClick={() => {
366 if (file.file !== null) {
367 const preview = buildExpandedImagePreview([file], file.id);
368 if (preview) onExpandImage(preview);
369 return;
370 }
371 if (!file.uploadedAttachmentId) return;
372 onFileOpen({ ...file, id: file.uploadedAttachmentId });
373 }}
374 >
375 {file.file && (
376 <>
377 <ComposerVideoThumbnail file={file.file} />
378 <span className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/10" />
379 </>
380 )}
381 <PlayIcon className="relative z-10 size-4 fill-current drop-shadow-md" />
382 </button>
383 {upload?.status === "uploading" && (
384 <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-background/85 px-1 text-center text-3xs text-foreground">
385 <ComposerAttachmentUploadProgress id={file.id} upload={upload} />
386 </span>
387 )}
388 {upload?.status === "failed" && (
389 <Tooltip>
390 <TooltipTrigger
391 render={
392 <Button
393 variant="overlay"
394 size="icon-xs"
395 className="absolute bottom-1 left-1"
396 onClick={() =>
397 retryAttachmentUpload({
398 environmentId,
399 image: file,
400 draftTarget: attachmentDraftTarget,
401 })
402 }
403 disabled={!canOperateThread}
404 aria-label={`Retry upload for ${file.name}`}
405 />
406 }
407 >
408 <RefreshIcon />
409 </TooltipTrigger>
410 <TooltipPopup side="top">{upload.reason}</TooltipPopup>
411 </Tooltip>
412 )}
413 <Button
414 variant="media-close"
415 size="icon-xs"
416 className="absolute right-1 top-1"
417 onClick={() => onRemoveFile(file.id)}
418 aria-label={`Remove ${file.name}`}
419 >
420 <XIcon />
421 </Button>
422 </div>
423 );
424 })}
425 </div>
426 );
428​
429/** Non-media file attachments, listed under the media row. */
430export function ComposerAttachmentFileList({
431 files,
432 resolvedTheme,
433 supportsAttachmentUploads,
434 maxFileAttachmentBytes,
435 fileStagingLimit,
436 environmentId,
437 attachmentDraftTarget,
438 canOperateThread,
439 onPreviewFile,
440 onRemoveFile,
441}: {
442 readonly files: ReadonlyArray<ComposerFileAttachment>;
443 readonly resolvedTheme: "light" | "dark";
444 readonly supportsAttachmentUploads: boolean;
445 readonly maxFileAttachmentBytes: number | null;
446 readonly fileStagingLimit: number | null;
447 readonly environmentId: EnvironmentId;
448 readonly attachmentDraftTarget: ScopedThreadRef | DraftId;
449 readonly canOperateThread: boolean;
450 readonly onPreviewFile: (fileId: string) => void;
451 readonly onRemoveFile: (fileId: string) => void;
452}) {
453 const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
454 return (
455 <div className="mb-3 flex flex-col gap-1">
456 {files.map((file) => {
457 const fileCanUpload =
458 supportsAttachmentUploads &&
459 maxFileAttachmentBytes !== null &&
460 file.sizeBytes <= maxFileAttachmentBytes;
461 const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined;
462 const needsReattach = composerFileNeedsReattach(file);
463 const canReattachFile = fileStagingLimit !== null && file.sizeBytes <= fileStagingLimit;
464 return (
465 <div
466 key={file.id}
467 className="flex min-w-0 items-center gap-2 py-1 text-sm text-foreground"
468 >
469 <PierreEntryIcon pathValue={file.name} kind="file" theme={resolvedTheme} />
470 <button
471 type="button"
472 disabled={needsReattach}
473 className="min-w-0 flex-1 truncate text-left hover:underline focus-visible:outline-2"
474 onClick={() => onPreviewFile(file.id)}
475 >
476 {file.name}
477 </button>
478 <span className="shrink-0 text-xs text-secondary-label">
479 {needsReattach ? (
480 canReattachFile ? (
481 "Attach again"
482 ) : (
483 "Remove to send"
484 )
485 ) : upload?.status === "uploading" ? (
486 <ComposerAttachmentUploadProgress
487 id={file.id}
488 upload={upload}
489 fallback={formatAttachmentSize(file.sizeBytes)}
490 />
491 ) : (
492 formatAttachmentSize(file.sizeBytes)
493 )}
494 </span>
495 {!needsReattach && upload?.status === "failed" ? (
496 <Tooltip>
497 <TooltipTrigger
498 render={
499 <Button
500 variant="ghost"
501 size="icon-xs"
502 onClick={() =>
503 retryAttachmentUpload({
504 environmentId,
505 image: file,
506 draftTarget: attachmentDraftTarget,
507 })
508 }
509 disabled={!canOperateThread}
510 aria-label={`Retry upload for ${file.name}`}
511 />
512 }
513 >
514 <RefreshIcon />
515 </TooltipTrigger>
516 <TooltipPopup side="top">{upload.reason}</TooltipPopup>
517 </Tooltip>
518 ) : null}
519 <Button
520 variant="ghost"
521 size="icon-xs"
522 onClick={() => onRemoveFile(file.id)}
523 aria-label={`Remove ${file.name}`}
524 >
525 <XIcon />
526 </Button>
527 </div>
528 );
529 })}
530 </div>
531 );
533​
534export function ComposerFilePreviewDialog({
535 file,
536 onRemove,
537 onPreviewFileIdChange,
538}: {
539 readonly file: ComposerFileAttachment;
540 readonly onRemove: (fileId: string) => void;
541 readonly onPreviewFileIdChange: (fileId: string | null) => void;
542}) {
543 const onClose = () => onPreviewFileIdChange(null);
544 return (
545 <Dialog
546 open
547 onOpenChange={(open) => {
548 if (!open) onClose();
549 }}
550 >
551 <DialogPopup
552 {...composerFloatingLayerProps}
553 className="h-[min(85vh,52rem)] max-w-4xl overflow-hidden"
554 showCloseButton={false}
555 >
556 <DialogTitle className="sr-only">{file.name}</DialogTitle>
557 <AttachmentFilePreview
558 key={file.id}
559 name={file.name}
560 mimeType={file.mimeType}
561 sizeBytes={file.sizeBytes}
562 file={file.file}
563 origin="Draft"
564 {...(file.uploadedAttachmentId && file.uploadEnvironmentId
565 ? {
566 asset: {
567 environmentId: file.uploadEnvironmentId,
568 attachmentId: file.uploadedAttachmentId,
569 },
570 }
571 : {})}
572 onRemove={() => {
573 onRemove(file.id);
574 onClose();
575 }}
576 onClose={onClose}
577 />
578 </DialogPopup>
579 </Dialog>
580 );
582​
583/** Thumbnails beside the single-line prompt while the composer rests. */
584export function ComposerRestingImagePreviews({
585 images,
586 allImages,
587 resolvedTheme,
588 onExpandImage,
589 onShowMore,
590}: {
591 readonly images: ReadonlyArray<ComposerImageAttachment>;
592 readonly allImages: ReadonlyArray<ComposerImageAttachment>;
593 readonly resolvedTheme: "light" | "dark";
594 readonly onExpandImage: (preview: ExpandedImagePreview) => void;
595 readonly onShowMore: () => void;
596}) {
597 const counts = getRestingComposerImagePreviewCounts(images.length);
598 const visibleImages = images.slice(0, counts.visibleCount);
599 return (
600 <div data-chat-composer-resting-images="true" className="flex shrink-0 items-center gap-1 ps-1">
601 {visibleImages.map((image) => (
602 <button
603 key={image.id}
604 type="button"
605 className="relative size-7 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-border/70 bg-muted/60"
606 aria-label={`Preview ${image.name}`}
607 onPointerDown={(event) => event.preventDefault()}
608 onClick={() => {
609 const preview = buildExpandedImagePreview(allImages, image.id);
610 if (preview) onExpandImage(preview);
611 }}
612 >
613 {image.previewUrl ? (
614 <ComposerImageThumbnail
615 file={image.file}
616 alt=""
617 className="size-full object-cover"
618 fallback={
619 isHeicImageFile({ name: image.name, type: image.mimeType }) ? (
620 <AttachmentImage
621 name={image.name}
622 mimeType={image.mimeType}
623 src={image.previewUrl}
624 alt=""
625 className="size-full object-cover"
626 />
627 ) : (
628 <PierreEntryIcon
629 pathValue={image.name}
630 kind="file"
631 theme={resolvedTheme}
632 className="m-auto size-3.5"
633 />
634 )
635 }
636 />
637 ) : (
638 <PierreEntryIcon
639 pathValue={image.name}
640 kind="file"
641 theme={resolvedTheme}
642 className="m-auto size-3.5"
643 />
644 )}
645 </button>
646 ))}
647 {counts.overflowCount > 0 ? (
648 <button
649 type="button"
650 className="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-border/70 bg-muted/60 font-medium text-secondary-label text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
651 aria-label={`Show ${String(counts.overflowCount)} more image attachments`}
652 onPointerDown={(event) => event.preventDefault()}
653 onClick={onShowMore}
654 >
655 +{counts.overflowCount}
656 </button>
657 ) : null}
658 </div>
659 );
661​
662/** Attachments of the queued message being edited; they are already uploaded. */
663export function ComposerQueuedAttachmentRow({
664 attachments,
665 onRemove,
666}: {
667 readonly attachments: ReadonlyArray<{
668 readonly attachment: ChatAttachment;
669 readonly url: string | null;
670 }>;
671 readonly onRemove: (attachmentId: string) => void;
672}) {
673 return (
674 <div className="mb-3 flex flex-wrap gap-2">
675 {attachments.map(({ attachment, url }) => (
676 <div
677 key={attachment.id}
678 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
679 >
680 {attachment.type === "image" && url ? (
681 <img src={url} alt={attachment.name} className="h-full w-full object-cover" />
682 ) : (
683 <div className="flex h-full w-full items-center justify-center px-1 text-center text-3xs text-secondary-label">
684 {attachment.name}
685 </div>
686 )}
687 <Button
688 variant="media-close"
689 size="icon-xs"
690 className="absolute right-1 top-1"
691 onClick={() => onRemove(attachment.id)}
692 aria-label={`Remove ${attachment.name}`}
693 >
694 <XIcon />
695 </Button>
696 </div>
697 ))}
698 </div>
699 );
701​
702export function ComposerAttachButton({
703 addComposerAttachments,
704 focusComposer,
705}: {
706 readonly addComposerAttachments: (files: File[]) => Promise<boolean>;
707 readonly focusComposer: () => void;
708}) {
709 const inputRef = useRef<HTMLInputElement>(null);
710 return (
711 <>
712 <input
713 ref={inputRef}
714 type="file"
715 multiple
716 className="hidden"
717 onChange={(event) => {
718 const files = Array.from(event.currentTarget.files ?? []);
719 event.currentTarget.value = "";
720 // Inserting a chip refocuses the editor after the draft renders;
721 // focusing synchronously here would report the editor's stale text
722 // over the prompt that was just written.
723 void addComposerAttachments(files).then((inserted) => {
724 if (!inserted) focusComposer();
725 });
726 }}
727 />
728 <Tooltip>
729 <TooltipTrigger
730 render={
731 <Button
732 type="button"
733 variant="ghost"
734 size="icon-sm"
735 onPointerDown={(event) => event.preventDefault()}
736 onClick={() => inputRef.current?.click()}
737 aria-label="Attach files"
738 />
739 }
740 >
741 {/* The clip's weight sits in its upper loop; 1px down reads as centered on the row. */}
742 <PaperclipIcon className="translate-y-px" />
743 </TooltipTrigger>
744 <TooltipPopup>Attach files</TooltipPopup>
745 </Tooltip>
746 </>
747 );