For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Composer Attachments 01

A message composer with compact attachment previews.

Usage

Pass an array of attachments and update it with onAttachmentsChange. Omit progress for completed files, or provide a number from 0 to 100 to display upload progress. The standalone strip can be used outside the composer. Uploads and the lifetime of any supplied image URLs are managed by the caller.

Source

"use client";

import {
  RiAddLine,
  RiArrowUpLine,
  RiCloseLine,
  RiFileCodeLine,
  RiFileExcel2Line,
  RiFileLine,
  RiFileTextLine,
  RiFilmLine,
  RiFolderLine,
  RiGitBranchLine,
  RiImageLine,
  RiMicLine,
  RiPresentationLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Composer } from "@/components/ui/composer";
import { Textarea } from "@/components/ui/textarea";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

export type AttachmentKind =
  | "image"
  | "document"
  | "spreadsheet"
  | "presentation"
  | "code"
  | "video";
export interface ComposerAttachment {
  id: string;
  name: string;
  kind: AttachmentKind;
  src?: string;
  file?: File;
  /** Omit when complete; otherwise a value from 0 to 100. */
  progress?: number;
}

/** File-kind icons, coloured from the badge tones (each has a dark pair). */
const icons: Record<AttachmentKind, React.ReactNode> = {
  image: (
    <RiImageLine
      aria-hidden
      className="size-5 text-(--badge-cyan-foreground,var(--muted-foreground))"
    />
  ),
  document: (
    <RiFileTextLine
      aria-hidden
      className="size-5 text-(--file-upload-icon,var(--muted-foreground))"
    />
  ),
  spreadsheet: (
    <RiFileExcel2Line
      aria-hidden
      className="size-5 text-(--badge-blue-foreground,var(--muted-foreground))"
    />
  ),
  presentation: (
    <RiPresentationLine
      aria-hidden
      className="size-5 text-(--badge-purple-foreground,var(--muted-foreground))"
    />
  ),
  code: (
    <RiFileCodeLine
      aria-hidden
      className="size-5 text-(--badge-cyan-foreground,var(--muted-foreground))"
    />
  ),
  video: (
    <RiFilmLine
      aria-hidden
      className="size-5 text-(--badge-blue-foreground,var(--muted-foreground))"
    />
  ),
};

/** The count-up from orders-chart-card-01: 320ms, soft landing. */
function useCountUp(target: number, reduced: boolean) {
  const [display, setDisplay] = React.useState(target);
  const fromRef = React.useRef(target);
  React.useEffect(() => {
    const from = fromRef.current;
    if (reduced) {
      fromRef.current = target;
      setDisplay(target);
      return;
    }
    if (from === target) {
      return;
    }
    const start = performance.now();
    let frame = 0;
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / 320);
      const value = Math.round(from + (target - from) * (1 - (1 - t) ** 3));
      fromRef.current = value;
      setDisplay(value);
      if (t < 1) {
        frame = requestAnimationFrame(tick);
      } else {
        fromRef.current = target;
      }
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [target, reduced]);
  return reduced ? target : display;
}

/**
 * Starts at zero so the arc draws in when a tile mounts, then follows each
 * progress update with the primitive's transition.
 */
function useRingProgress(target: number, reduced: boolean) {
  const [value, setValue] = React.useState(0);
  React.useEffect(() => {
    if (reduced) {
      setValue(target);
      return;
    }
    const frame = requestAnimationFrame(() => setValue(target));
    return () => cancelAnimationFrame(frame);
  }, [target, reduced]);
  return value;
}

export function ComposerAttachmentTile({
  attachment,
  index = 0,
  onRemove,
}: {
  attachment: ComposerAttachment;
  index?: number;
  onRemove?: (id: string) => void;
}) {
  const icon = icons[attachment.kind] ?? (
    <RiFileLine
      aria-hidden
      className="size-5 text-(--file-upload-icon,var(--muted-foreground))"
    />
  );
  const progress =
    attachment.progress === undefined
      ? undefined
      : Math.max(0, Math.min(100, attachment.progress));
  const uploading = progress !== undefined && progress < 100;
  const reduced = useReducedMotion();
  const display = useCountUp(progress ?? 0, reduced);
  const ring = useRingProgress(progress ?? 0, reduced);

  return (
    <div
      className="composer-attachments-01-in shrink-0 snap-start"
      style={{ "--delay": `${index * 40}ms` } as React.CSSProperties}
    >
      <Attachment
        variant="outline"
        orientation="vertical"
        size="xs"
        state={uploading ? "uploading" : "done"}
        // oxlint-disable-next-line shadcn/no-restyle -- reference: the attachment tile is 56px
        className="size-14! flex-nowrap gap-0 overflow-hidden"
      >
        {attachment.kind === "image" && attachment.src ? (
          <AttachmentMedia variant="image" className="size-full!">
            {/* User-provided previews can be local object URLs. */}
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              alt={attachment.name}
              className="size-full object-cover"
              src={attachment.src}
            />
          </AttachmentMedia>
        ) : (
          <>
            <AttachmentMedia
              variant="bare"
              className="m-1 size-5! shrink-0 self-start"
            >
              {icon}
            </AttachmentMedia>
            <AttachmentContent
              // oxlint-disable-next-line shadcn/no-restyle -- reference: the 56px tile sets the filename about 7px above its bottom edge (the tile's own 4px padding plus the line box)
              className="mt-auto flex min-w-0 flex-col justify-end px-1 pb-0.5 leading-none"
            >
              {/* oxlint-disable-next-line shadcn/no-restyle, shadcn/no-arbitrary-values -- reference: 9px tile filename */}
              <AttachmentTitle className="text-[9px] leading-3 font-normal text-muted-foreground">
                {attachment.name}
              </AttachmentTitle>
            </AttachmentContent>
          </>
        )}
        {uploading ? (
          <div
            aria-label={`${attachment.name}: ${Math.round(progress)}% uploaded`}
            className={cn(
              "absolute inset-0 rounded-[8px]",
              attachment.kind === "image" &&
                attachment.src &&
                "bg-background/50"
            )}
            role="progressbar"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={Math.round(progress)}
          >
            <svg
              aria-hidden
              className="absolute inset-0 size-full -rotate-90 text-primary"
              viewBox="0 0 56 56"
            >
              <rect
                x="1.5"
                y="1.5"
                width="53"
                height="53"
                rx="8"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                pathLength="100"
                strokeDasharray={`${ring} 100`}
                className="composer-attachments-01-ring"
              />
            </svg>
            {/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: 9px upload percentage */}
            <span className="absolute top-1.5 right-1.5 text-[9px] font-medium text-primary tabular-nums">
              {display}%
            </span>
          </div>
        ) : onRemove ? (
          <AttachmentActions className="top-1! right-1! z-20">
            <Tooltip>
              <TooltipTrigger
                render={
                  <AttachmentAction
                    aria-label={`Remove ${attachment.name}`}
                    size="icon-xs"
                    variant="filled"
                    shape="pill"
                    className="size-4"
                    onClick={() => onRemove(attachment.id)}
                  />
                }
              >
                <RiCloseLine className="size-3" />
              </TooltipTrigger>
              <TooltipContent>Remove {attachment.name}</TooltipContent>
            </Tooltip>
          </AttachmentActions>
        ) : null}
      </Attachment>
    </div>
  );
}

export function ComposerAttachmentStrip({
  attachments,
  onRemove,
  className,
}: {
  attachments: ComposerAttachment[];
  onRemove?: (id: string) => void;
  className?: string;
}) {
  return (
    <AttachmentGroup
      className={cn("flex-wrap gap-1.5 overflow-visible", className)}
    >
      {attachments.map((attachment, index) => (
        <ComposerAttachmentTile
          key={attachment.id}
          attachment={attachment}
          index={index}
          onRemove={onRemove}
        />
      ))}
    </AttachmentGroup>
  );
}

export function ComposerAttachments({
  attachments,
  onAttachmentsChange,
  onSend,
  className,
}: {
  attachments: ComposerAttachment[];
  onAttachmentsChange: (attachments: ComposerAttachment[]) => void;
  onSend?: (message: string, attachments: ComposerAttachment[]) => void;
  className?: string;
}) {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const [message, setMessage] = React.useState("");
  const urls = React.useRef(new Set<string>());
  const remove = (id: string) => {
    const src = attachments.find((item) => item.id === id)?.src;
    if (src && urls.current.delete(src)) {
      URL.revokeObjectURL(src);
    }
    onAttachmentsChange(attachments.filter((item) => item.id !== id));
  };
  const addFiles = (files: FileList | null) => {
    if (!files) {
      return;
    }
    const items = Array.from(
      files,
      (file): ComposerAttachment => ({
        id: crypto.randomUUID(),
        name: file.name,
        file,
        kind: file.type.startsWith("image/")
          ? "image"
          : file.type.startsWith("video/")
            ? "video"
            : /\.(csv|xlsx?)$/i.test(file.name)
              ? "spreadsheet"
              : /\.(pptx?|key)$/i.test(file.name)
                ? "presentation"
                : /\.(js|ts|tsx|py|json)$/i.test(file.name)
                  ? "code"
                  : "document",
        src: file.type.startsWith("image/")
          ? URL.createObjectURL(file)
          : undefined,
      })
    );
    for (const item of items) {
      if (item.src) {
        urls.current.add(item.src);
      }
    }
    onAttachmentsChange([...attachments, ...items]);
  };
  React.useEffect(() => {
    const ownedUrls = urls.current;
    return () => {
      for (const url of ownedUrls) {
        URL.revokeObjectURL(url);
      }
    };
  }, []);
  return (
    <Composer
      className={cn("@container gap-0", className)}
      value={message}
      onValueChange={setMessage}
      onSend={(text) => onSend?.(text, attachments)}
    >
      <TooltipProvider>
        <div className="mx-7 flex h-8.5 min-w-0 items-center justify-between gap-2 rounded-[12px] rounded-b-none bg-surface-strong px-3 text-xs text-muted-foreground">
          <span className="flex min-w-0 items-center gap-2 overflow-hidden">
            <span className="flex items-center gap-1 whitespace-nowrap">
              <RiGitBranchLine className="size-3.5" /> trunk
            </span>
            <span className="flex items-center gap-1 whitespace-nowrap @max-[280px]:hidden">
              <RiFolderLine className="size-3.5" /> studio
            </span>
          </span>
          <span className="shrink-0 whitespace-nowrap">ā—Œ 42%</span>
        </div>
        <Card radius={20} size="sm">
          <CardContent>
            {attachments.length > 0 && (
              <ComposerAttachmentStrip
                attachments={attachments}
                onRemove={remove}
              />
            )}
            <Textarea
              variant="ghost"
              aria-label="Message"
              value={message}
              onChange={(event) => setMessage(event.target.value)}
              placeholder="Ask about your work..."
              rows={1}
              resize="vertical"
              className="min-h-8"
            />
            <div className="flex items-center justify-between gap-2">
              <div className="flex min-w-0 items-center gap-2">
                <input
                  ref={inputRef}
                  className="sr-only"
                  type="file"
                  multiple
                  onChange={(event) => {
                    addFiles(event.target.files);
                    event.target.value = "";
                  }}
                />
                <Button
                  tooltip="Add attachments"
                  type="button"
                  variant="filled"
                  size="icon"
                  shape="pill"
                  onClick={() => inputRef.current?.click()}
                >
                  <RiAddLine />
                </Button>
                <span className="whitespace-nowrap text-xs text-muted-foreground @max-[320px]:hidden">
                  ā—‰ Auto
                </span>
              </div>
              <div className="flex min-w-0 items-center gap-2">
                <span className="whitespace-nowrap text-xs text-muted-foreground @max-[380px]:hidden">
                  Standard
                </span>
                <Button
                  tooltip="Voice input"
                  type="button"
                  variant="outline"
                  size="icon"
                  shape="pill"
                  disabled
                >
                  <RiMicLine />
                </Button>
                <Button
                  tooltip="Send message"
                  type="submit"
                  size="icon"
                  shape="pill"
                >
                  <RiArrowUpLine />
                </Button>
              </div>
            </div>
          </CardContent>
        </Card>
      </TooltipProvider>
    </Composer>
  );
}