import { type RefObject, useRef, useState } from 'react' import { IconFile, IconLoader2, IconPaperclip, IconPlayerStopFilled, IconScribble, IconX } from '@tabler/icons-react' import { canSubmitComposerAction, type ComposerAvailability, Composer, ComposerFooter, ComposerSubmitButton, ComposerTextarea } from '@/client/components/shared/Composer' import { Button } from '@/client/components/ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/client/components/ui/hover-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/client/components/ui/tooltip' import { stageComposerFiles } from '@/client/features/chat/attachment-staging' import { cn } from '@/client/lib/cn' import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { type ChatAttachment, attachmentKey, liveStore, useLive } from '@/client/features/chat/chat-store' import { useUiStore } from '@/client/store/ui' import { ModelPicker } from './ModelPicker' export type ComposerAnnotationControls = { active: boolean finish: () => Promise onToggle: () => void onRemove: (localId: string) => void } type ChatComposerDraft = { // The UI-store key the composer subscribes to for live text; `initialValue` // is the builder's server-saved requirements, used until a local draft // exists. id: string initialValue: string onChange: (value: string) => void } type ChatComposerProps = { composerRef: RefObject onSend: (text: string) => void | Promise onStop: () => void processing: boolean sessionId: string | null modelSessionId: string | null availability: ComposerAvailability annotation?: ComposerAnnotationControls onRemoveDrawing?: (localId: string) => void allowFiles?: boolean placeholder?: string draft?: ChatComposerDraft } // Draft text is persisted per workspace (or per view builder, when the // composer is fronting one), while attachments remain ephemeral and follow the // selected chat. Both stores are subscribed here so a keystroke or upload // re-renders only the composer. export function ChatComposer({ composerRef, onSend, onStop, processing, sessionId, modelSessionId, availability, annotation, onRemoveDrawing, allowFiles = true, placeholder, draft }: ChatComposerProps) { const fileRef = useRef(null) const workspaceId = useWorkspaceId() const workspaceDraft = useUiStore(s => s.composerDrafts[workspaceId] ?? '') const builderDraft = useUiStore(s => (draft ? (s.viewBuilderDrafts ?? {})[draft.id] : undefined)) const value = draft ? (builderDraft ?? draft.initialValue) : workspaceDraft const valueRef = useRef(value) valueRef.current = value const attachments = useLive(s => s.attachments[attachmentKey(workspaceId, sessionId)] ?? EMPTY) const [dragOver, setDragOver] = useState(false) const uploading = attachments.some(a => a.status === 'uploading') // A draft annotation counts as sendable content: send() finishes the drawing // first, which uploads it before the message goes out. const hasSendable = attachments.some(a => a.status === 'ready' || a.status === 'draft') const hasContent = value.trim().length > 0 || hasSendable const canSend = canSubmitComposerAction(hasContent, uploading, availability) const onChange = (next: string) => { valueRef.current = next if (draft) draft.onChange(next) else useUiStore.getState().setComposerDraft(workspaceId, next) } const addFiles = (files: File[]) => { if (allowFiles) stageComposerFiles({ workspaceId, sessionId }, files) } // Guards the await window while an annotation finishes: no re-entry from a // second Enter, and the draft is re-read afterwards so text typed during the // upload isn't lost. const sendingRef = useRef(false) const removeAttachment = (attachment: ChatAttachment) => { if (attachment.kind === 'drawing' && onRemoveDrawing) { onRemoveDrawing(attachment.localId) return } liveStore.getState().removeAttachment(workspaceId, sessionId, attachment.localId) } const send = async () => { if (!canSend || sendingRef.current) return sendingRef.current = true try { await annotation?.finish() await onSend(valueRef.current) if (!draft) { valueRef.current = '' useUiStore.getState().setComposerDraft(workspaceId, '') } } finally { sendingRef.current = false } } return ( { e.preventDefault() void send() }} onDragOver={e => { if (!e.dataTransfer.types.includes('Files')) return e.preventDefault() if (allowFiles) setDragOver(true) }} onDragLeave={e => { if (!allowFiles) return // Ignore leaves into child elements — only reset when leaving the form. if (e.currentTarget.contains(e.relatedTarget as Node | null)) return setDragOver(false) }} onDrop={e => { if (!e.dataTransfer.types.includes('Files')) return e.preventDefault() setDragOver(false) addFiles(Array.from(e.dataTransfer.files)) }} className={cn(dragOver && 'ring-2 ring-ring/60')} > {attachments.length > 0 && (
{attachments.map(a => ( removeAttachment(a)} /> ))}
)} onChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() void send() } }} onPaste={e => { const files = Array.from(e.clipboardData?.files ?? []) if (files.length === 0) return e.preventDefault() addFiles(files) }} placeholder={processing ? 'Queue a follow-up' : (placeholder ?? 'Do anything')} rows={1} /> {allowFiles && ( { addFiles(Array.from(e.target.files ?? [])) e.target.value = '' }} /> )}
{allowFiles && ( fileRef.current?.click()} aria-label="Attach files" > {/* Paperclip is too heavy compared to the nearby scribble icon */} } /> Attach files )} {annotation && ( } /> Draw annotation )}
{processing ? ( } /> Stop answering ) : ( )}
) } const EMPTY: ChatAttachment[] = [] type AttachmentChipProps = { attachment: ChatAttachment onRemove: () => void } // A composer attachment preview: an image thumbnail or a labelled file chip, // with an upload spinner / error overlay and a remove button. function AttachmentChip({ attachment, onRemove }: AttachmentChipProps) { if (attachment.kind === 'drawing') { return } const { name, previewUrl, status, error } = attachment const isImage = !!previewUrl return (
{isImage ? ( {name} ) : ( <> {name} )} {status === 'uploading' && (
)} {status === 'error' && (
Failed
)}
) } type DrawingAttachmentChipProps = { attachment: Extract onRemove: () => void } function DrawingAttachmentChip({ attachment, onRemove }: DrawingAttachmentChipProps) { const { previewUrl, purpose } = attachment const label = purpose === 'sketch' ? 'Sketch' : 'Annotation' return ( } > {label} {previewUrl && ( {`${label} )} ) }