'use client' // compact-size support added for the ticket-drawer reply composer. import { formatFileSize } from '../../utils/format' /** * Chat-attachment UI primitives. * * Split into two NAMED EXPORTS following 2026 chat-compose conventions * (Claude.ai / ChatGPT / Cursor / Linear AI / etc.): * * 1. `` — the thumbnail strip with per-file * progress / × remove. Renders ABOVE the textarea (only when * chips exist). Visible to the user as a small horizontal row of * file pills that disappears when they Send. * * 2. `` — the standalone `+` glyph. * Renders INLINE in the bottom-controls row alongside the * `ModelDisplay` (BELOW the textarea, 2026 pattern). * `attachmentsEnabled` arrives as a prop — the hub wires the * `useChatIdentity().attachmentsEnabled` boolean; embedders supply * their own gate. Reserves layout space when disabled * (visibility, not display) so the row's width doesn't jump * when chat-identity resolves. * * UI principles applied: * - Borderless icon-only `+` (Claude.ai compose-box style). * - Chips strip floats above input, dismissible per-file via ×. * - All colors via ODS tokens; NO hex anywhere. * - NO `capture="environment"` on the file input — iOS Safari would * force camera-only mode and disable file picking. */ import { useEffect, useRef, useState } from 'react' import { PlusIcon } from '../icons-v2-generated/signs-and-symbols/plus-icon' import { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon' import { Button } from '../ui/button' import { cn } from '../../utils/cn' import { ANTHROPIC_SUPPORTED_IMAGE_MIME } from './utils/chat-attachment-markdown' /** Chip strip / chip density. `compact` shrinks the thumbnail, padding, text * and max-width for narrow surfaces (e.g. the ticket-drawer reply composer); * `default` is the global Ask-AI chat sizing. */ export type ChatAttachmentSize = 'default' | 'compact' // =========================================================================== // CONSTANTS & TYPES — inlined from hub `lib/config/chat-attachment-config.ts` // =========================================================================== /** Hand-curated chat-attachment allow-list. Intentionally excludes * `image/svg+xml` for XSS safety. */ export const CHAT_ATTACHMENT_MIME_TYPES = [ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/webm', 'video/quicktime', ] as const /** Client-side cap on `addFiles` (the upload hook rejects 6+ files in a * single selection). Server-side is per-IP-rate-limit; this is a UX * hint more than a security boundary. */ export const CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER = 5 type Status = 'sniffing' | 'uploading' | 'ready' | 'error' /** Staged-attachment shape consumed by the chip strip. Mirrors the hub * `StagedAttachment` produced by `useChatAttachments`. */ export interface StagedAttachment { /** Stable client-side id; survives across re-renders. */ id: string file: File status: Status /** Set when `status === 'ready'`. Server-issued. */ storagePath?: string /** Set when `status === 'ready'`. Server-issued HMAC view token. */ viewToken?: string /** 0-100 during 'uploading'. */ progress: number /** Set when `status === 'error'`. */ errorMessage?: string } // =========================================================================== // `+` ADD BUTTON — inline in the bottom-controls row (below input) // =========================================================================== export interface ChatAttachmentAddButtonProps { /** Auth-gate flag. Hub passes `useChatIdentity().attachmentsEnabled`; * embedders supply their own boolean. Disabled state collapses the * button to a layout-reserving invisible placeholder. */ attachmentsEnabled: boolean /** Current attachment count — drives the limit-reached disabled state. */ attachmentsCount: number onAddFiles: (files: FileList | File[]) => void /** External disable (e.g. while chat is streaming). */ disabled?: boolean /** Density. `compact` renders a smaller 24×24 button for narrow surfaces * (ticket-drawer composer); `default` is the 28×28 global-chat button. */ size?: ChatAttachmentSize } /** * Settled `+` icon-only button for the bottom-controls row. * * LAYOUT STABILITY — first-paint placeholder pattern: * `attachmentsEnabled` typically resolves ASYNCHRONOUSLY on mount * (hub's `useChatIdentity()` round-trip ~100-500ms). When the flag is * false (loading OR anon final state), the component renders an * INVISIBLE placeholder `` with the same `h-7 w-7 shrink-0` * footprint as the real button. The placeholder reserves layout space * from the very first paint; if `attachmentsEnabled` flips to `true` * the button replaces the placeholder at the IDENTICAL 28×28 * position — ZERO shift. * * Truly-anon users see the invisible 28×28 spacer permanently — * acceptable trade-off vs the universal jump on every panel open. */ export function ChatAttachmentAddButton({ attachmentsEnabled, attachmentsCount, onAddFiles, disabled = false, size = 'default', }: ChatAttachmentAddButtonProps) { const fileInputRef = useRef(null) const compact = size === 'compact' // Keep the placeholder footprint in lockstep with the real button size so // there's zero layout shift when `attachmentsEnabled` resolves. const boxClass = compact ? 'h-6 w-6' : 'h-7 w-7' if (!attachmentsEnabled) { return