import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type DragEvent as ReactDragEvent, } from "react"; import { cn } from "@/lib/utils"; import { AlertTriangle, ArrowUp, Check, ChevronDown, Loader2, Plus } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta"; import type { InlineEntityOption } from "@/components/InlineEntitySelector"; import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared"; /** Structurally identical to IssueChatThread's module-private CommentReassignment. */ interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; } interface TaskChatComposerProps { onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise | void; workMode: IssueWorkMode; onWorkModeChange?: (mode: IssueWorkMode) => Promise | void; disabled?: boolean; disabledReason?: string | null; placeholder?: string; /** Preferred upload path: attaches the file to the task (mirrors legacy). */ onAttachImage?: (file: File) => Promise; /** Fallback upload path: returns a URL for inline image markdown. */ onImageUpload?: (file: File) => Promise; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; issueStatus?: string; } /** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */ const MODE_HUE: Partial> = { standard: "var(--tc-mode-agent)", planning: "var(--tc-mode-plan)", ask: "var(--tc-mode-ask)", }; function modeHue(mode: IssueWorkMode): string { return MODE_HUE[mode] ?? "var(--tc-mode-agent)"; } const MODE_DESCRIPTION: Partial> = { standard: "Make changes and run work", planning: "Draft a plan before acting", ask: "Answer questions only, no changes", }; /** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */ function modePlaceholder(mode: IssueWorkMode, agentName: string): string { switch (mode) { case "planning": return `Plan with ${agentName} — shapes the plan doc, no code changes…`; case "ask": return `Ask ${agentName} a question — read-only, nothing runs…`; default: return `Message ${agentName} — describe what you want done…`; } } type ComposerAttachment = { id: string; name: string; status: "uploading" | "attached" | "error"; error?: string; /** Object URL for an immediate thumbnail while (and after) uploading. */ previewUrl?: string; isImage?: boolean; }; /** Local duplicate of IssueChatThread's module-private helper (same rule). */ function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked"; return resumesToTodo && assigneeValue.startsWith("agent:"); } function parseAssigneeValue(value: string): CommentReassignment | undefined { if (value.startsWith("agent:")) { const id = value.slice("agent:".length); return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined; } if (value.startsWith("user:")) { const id = value.slice("user:".length); return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined; } return undefined; } function hasFilePayload(evt: ReactDragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } /** * Composer for the redesigned thread (v7 spec): textarea over a 32px comp-bar * of [attach] [mode chip] … [assignee] [send]. The mode chip is a status-chip * rectangle carrying the pending mode's hue; the composer chrome itself stays * neutral. Shift+Tab cycles modes; the picked mode is applied on submit. * Attachments upload via `onAttachImage` (or the inline `onImageUpload` * fallback) from the + button, paste, or drag-drop. */ export function TaskChatComposer({ onAdd, workMode, onWorkModeChange, disabled = false, disabledReason, placeholder, onAttachImage, onImageUpload, enableReassign = false, reassignOptions, currentAssigneeValue = "", issueStatus, }: TaskChatComposerProps) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState(false); const [pendingMode, setPendingMode] = useState(workMode); const [pendingAssignee, setPendingAssignee] = useState(null); const [attachments, setAttachments] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const dragDepthRef = useRef(0); const fileInputRef = useRef(null); const previewUrlsRef = useRef>(new Set()); // Object URLs created for image previews must be revoked or they leak the // underlying blobs for the page's lifetime. useEffect(() => { const urls = previewUrlsRef.current; return () => { for (const url of urls) URL.revokeObjectURL(url); urls.clear(); }; }, []); function releasePreview(url: string | undefined) { if (!url) return; URL.revokeObjectURL(url); previewUrlsRef.current.delete(url); } const modeMeta = workModeMetaFor(pendingMode); const canAcceptFiles = Boolean(onAttachImage || onImageUpload); const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0); const assigneeValue = pendingAssignee ?? currentAssigneeValue; const assigneeLabel = reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned"; const assigneeName = assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel; const effectivePlaceholder = placeholder ?? modePlaceholder(pendingMode, assigneeName); function insertReference(name: string, url: string, asImage: boolean) { const safeName = name.replace(/[[\]]/g, "\\$&"); const markdown = asImage ? `![${safeName}](${url})` : `[${safeName}](${url})`; setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown)); } async function attachFile(file: File) { const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`; const isImage = file.type.startsWith("image/"); // Create the preview up front so the thumbnail appears while uploading. const previewUrl = isImage && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : undefined; if (previewUrl) previewUrlsRef.current.add(previewUrl); setAttachments((prev) => [ ...prev, { id, name: file.name, status: "uploading", previewUrl, isImage: isImage || undefined }, ]); try { if (onAttachImage) { const attachment = await onAttachImage(file); const name = attachment?.originalFilename ?? file.name; if (attachment?.contentPath) { insertReference(name, attachment.contentPath, file.type.startsWith("image/")); } setAttachments((prev) => prev.map((item) => (item.id === id ? { ...item, name, status: "attached" } : item)), ); } else if (onImageUpload && file.type.startsWith("image/")) { const url = await onImageUpload(file); insertReference(file.name, url, true); setAttachments((prev) => prev.map((item) => (item.id === id ? { ...item, status: "attached" } : item)), ); } else { setAttachments((prev) => prev.map((item) => item.id === id ? { ...item, status: "error", error: "This file type cannot be attached here" } : item, ), ); } } catch (err) { setAttachments((prev) => prev.map((item) => item.id === id ? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed" } : item, ), ); } } async function attachFiles(files: Iterable) { for (const file of files) { await attachFile(file); } } function handleFileInputChange(evt: ChangeEvent) { const files = evt.target.files; if (files && files.length > 0) void attachFiles(Array.from(files)); evt.target.value = ""; } function handlePaste(evt: ReactClipboardEvent) { if (!canAcceptFiles) return; const files = Array.from(evt.clipboardData?.files ?? []); if (files.length === 0) return; evt.preventDefault(); void attachFiles(files); } function handleFileDragEnter(evt: ReactDragEvent) { if (!canAcceptFiles || !hasFilePayload(evt)) return; evt.preventDefault(); dragDepthRef.current += 1; setIsDragOver(true); } function handleFileDragOver(evt: ReactDragEvent) { if (!canAcceptFiles || !hasFilePayload(evt)) return; evt.preventDefault(); evt.dataTransfer.dropEffect = "copy"; } function handleFileDragLeave(evt: ReactDragEvent) { if (!canAcceptFiles || !hasFilePayload(evt)) return; evt.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setIsDragOver(false); } function handleFileDrop(evt: ReactDragEvent) { if (!canAcceptFiles || !hasFilePayload(evt)) return; evt.preventDefault(); dragDepthRef.current = 0; setIsDragOver(false); const files = evt.dataTransfer?.files; if (files && files.length > 0) void attachFiles(Array.from(files)); } async function submit() { const trimmed = body.trim(); if (!trimmed || submitting || disabled) return; const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue; const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined; const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined; setSubmitting(true); setBody(""); try { if (pendingMode !== workMode && onWorkModeChange) { await onWorkModeChange(pendingMode); } await onAdd(trimmed, reopen, reassignment); setAttachments((prev) => { for (const item of prev) releasePreview(item.previewUrl); return []; }); setPendingAssignee(null); } catch { setBody(trimmed); // restore on failure } finally { setSubmitting(false); } } return (