import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react' import { IconArrowRight, IconBuilding, IconCheck, IconClose, IconLock, IconPaperclip, IconSend, IconSparkles, IconStop, IconUserRound, } from './SvgIcons' import type { OutgoingAttachmentPayload } from '../types/chat' import type { TaskPreferredAgent } from '../types/kanban' import type { SavedOrgSummary } from '../types/visual' import { getContextUsageMetrics } from '../lib/contextUsage' const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_TOTAL_SIZE = 20 * 1024 * 1024 const ACCEPTED_TYPES = 'image/*,video/mp4,video/mpeg,video/quicktime,video/webm,.mp4,.mpeg,.mpg,.mov,.webm,.txt,.md,.pdf,.csv,.json,.yaml,.yml,.py,.js,.ts,.tsx,.jsx,.html,.css,.java,.c,.cpp,.go,.rs,.rb,.sh,.xml,.toml,.docx,.xlsx,.pptx' type AttachmentTransferState = 'reading' | 'ready' | 'error' type ComposerExecMode = 'task' | 'company' | 'org' | 'custom' type ComposerCompanyProfile = 'corporate' | 'custom' type ComposerModeOption = 'task' | 'company' type CompanyArchitectureOption = '' | 'corporate' | `org:${string}` const TASK_AGENT_LABELS: Record = { native: 'OpenOPC Native', codex: 'Codex', claude_code: 'Claude Code', cursor: 'Cursor', opencode: 'OpenCode', } interface PendingAttachment { id: string file: File filename: string mime_type: string size_bytes: number preview_url: string base64_data?: string progress_percent: number transfer_state: AttachmentTransferState error?: string } function readFileAsBase64(file: File, onProgress?: (progress: number) => void): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onprogress = (event) => { if (!event.lengthComputable) return onProgress?.(Math.round((event.loaded / event.total) * 100)) } reader.onload = () => { const result = reader.result as string onProgress?.(100) resolve(result.split(',')[1] || '') } reader.onerror = reject reader.readAsDataURL(file) }) } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB` return `${(bytes / (1024 * 1024)).toFixed(1)}MB` } function attachmentBadgeLabel(mime: string, filename: string): string { const extension = filename.includes('.') ? filename.split('.').pop()?.toUpperCase() ?? '' : '' if (mime.startsWith('image/')) return 'IMG' if (mime.startsWith('video/')) return 'VID' if (mime === 'application/pdf') return 'PDF' if (mime.includes('wordprocessingml')) return 'DOC' if (mime.includes('spreadsheetml') || extension === 'CSV') return 'XLS' if (mime.includes('presentationml')) return 'PPT' if (mime.includes('json')) return 'JSON' if (mime.includes('yaml') || extension === 'YML' || extension === 'YAML') return 'YAML' if (mime.startsWith('text/')) return 'TXT' if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(extension)) return extension return extension || 'FILE' } function attachmentToneClass(mime: string, filename: string): string { const label = attachmentBadgeLabel(mime, filename) if (label === 'IMG') return 'image' if (label === 'VID') return 'video' if (label === 'PDF') return 'pdf' if (label === 'DOC' || label === 'XLS' || label === 'PPT') return 'office' if (label === 'JSON' || label === 'YAML') return 'data' if (label === 'TXT') return 'text' if (['PY', 'TS', 'TSX', 'JS', 'JSX', 'GO', 'RS', 'RB', 'JAVA', 'C', 'CPP', 'HTML', 'CSS', 'SH'].includes(label)) return 'code' return 'generic' } function AttachmentProgressRing({ progress, state, error, }: { progress: number state: AttachmentTransferState error?: string }) { const radius = 11 const circumference = 2 * Math.PI * radius const normalized = error ? 100 : state === 'ready' ? 100 : Math.max(0, Math.min(progress, 100)) const dashOffset = circumference * (1 - normalized / 100) return ( {error ? '!' : state === 'ready' ? : normalized} ) } function ContextRing({ usedPct, usedTokens, windowTokens, }: { usedPct: number usedTokens?: number windowTokens?: number }) { const radius = 11 const circumference = 2 * Math.PI * radius const clamped = Math.max(0, Math.min(usedPct, 100)) const dashOffset = circumference * (1 - clamped / 100) const isLow = clamped >= 80 const isCritical = clamped >= 90 const usageLabel = typeof usedTokens === 'number' && typeof windowTokens === 'number' ? `${clamped}% used (${usedTokens.toLocaleString()}/${windowTokens.toLocaleString()})` : `${clamped}% used` return ( {clamped} ) } interface MessageComposerProps { disabled?: boolean placeholder?: string channelId?: string execMode?: string companyProfile?: string taskPreferredAgent?: TaskPreferredAgent agentStatus?: string currentTool?: string displayTool?: string activeAgentCount?: number runtimeControlState?: 'running' | 'suspending' | 'suspended' | 'resuming' | 'idle' canStop?: boolean autoFocus?: boolean contextTokens?: number contextWindow?: number contextRemainingPct?: number savedOrgs?: SavedOrgSummary[] | null activeSavedOrg?: string | null selectedOrgId?: string | null /** * When true the mode/agent pickers freeze into a read-only chip: the chat * has committed to its current execution identity (i.e. messages have been * sent) and the identity is no longer changeable from this composer. The * chip exposes a hover hint pointing users at "start a new chat" instead. */ lockedMode?: boolean onSend: (content: string, attachments?: OutgoingAttachmentPayload[]) => void onModeChange?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void onTaskAgentChange?: (preferredAgent: TaskPreferredAgent) => void onSavedOrgsRefresh?: () => void onSavedOrgLoad?: (name: string) => void onStop?: () => void /** * Spawn a brand-new chat in the requested mode, preserving the user inside * the same project. Wired from the locked-mode chip popover so users can * "continue in a different mode" without having to find the global new-chat * button. When omitted, the popover degrades gracefully to text-only. */ onContinueInNewChat?: (mode: ComposerExecMode, profile?: ComposerCompanyProfile, orgId?: string) => void } interface ModeAlternative { key: string mode: ComposerExecMode profile?: ComposerCompanyProfile orgId?: string label: string description: string icon: ReactElement } function savedOrgLabel(org: SavedOrgSummary): string { return org.organization_name?.trim() || org.name } export function MessageComposer({ disabled, placeholder, channelId, execMode, companyProfile, taskPreferredAgent = 'native', agentStatus, currentTool, displayTool, activeAgentCount, runtimeControlState, canStop, autoFocus = true, contextTokens, contextWindow, contextRemainingPct, savedOrgs, activeSavedOrg, selectedOrgId, lockedMode = false, onSend, onModeChange, onTaskAgentChange, onSavedOrgsRefresh, onSavedOrgLoad, onStop, onContinueInNewChat, }: MessageComposerProps) { const [text, setText] = useState('') const [focused, setFocused] = useState(false) const [pending, setPending] = useState([]) const [lightbox, setLightbox] = useState(null) const textareaRef = useRef(null) const fileInputRef = useRef(null) const isStopping = runtimeControlState === 'suspending' const isRuntimeActive = runtimeControlState === 'running' || runtimeControlState === 'suspending' || runtimeControlState === 'resuming' const isSuspended = runtimeControlState === 'suspended' const hasRuntimeControlState = runtimeControlState != null const isWorking = isRuntimeActive || (!hasRuntimeControlState && agentStatus != null && agentStatus !== 'idle') const stopEnabled = (canStop ?? true) && !isStopping && !isSuspended const contextUsage = useMemo( () => getContextUsageMetrics({ contextTokens, contextWindow, contextRemainingPct }), [contextRemainingPct, contextTokens, contextWindow], ) useEffect(() => { const ta = textareaRef.current if (!ta) return ta.style.height = 'auto' ta.style.height = `${Math.min(ta.scrollHeight, 200)}px` }, [text]) useEffect(() => { setText('') setPending(prev => { prev.forEach(attachment => { if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url) }) return [] }) if (!disabled && autoFocus) setTimeout(() => textareaRef.current?.focus(), 50) }, [channelId, disabled, autoFocus]) const updateAttachment = useCallback((id: string, updater: (attachment: PendingAttachment) => PendingAttachment) => { setPending(prev => prev.map(attachment => attachment.id === id ? updater(attachment) : attachment)) }, []) const prepareAttachment = useCallback(async (attachmentId: string, file: File) => { try { const base64 = await readFileAsBase64(file, (progress) => { updateAttachment(attachmentId, (attachment) => ({ ...attachment, progress_percent: progress, transfer_state: 'reading', })) }) updateAttachment(attachmentId, (attachment) => ({ ...attachment, base64_data: base64, progress_percent: 100, transfer_state: 'ready', })) } catch { updateAttachment(attachmentId, (attachment) => ({ ...attachment, error: 'Failed to prepare file', progress_percent: 0, transfer_state: 'error', })) } }, [updateAttachment]) const addFiles = useCallback((files: FileList | File[]) => { const arr = Array.from(files) let runningTotal = pending.reduce((sum, attachment) => sum + attachment.size_bytes, 0) const newPending: PendingAttachment[] = arr.map(file => { let error: string | undefined if (file.size > MAX_FILE_SIZE) error = `Too large (${formatSize(file.size)})` else if (runningTotal + file.size > MAX_TOTAL_SIZE) error = 'Total size exceeds 20MB' else runningTotal += file.size return { id: crypto.randomUUID(), file, filename: file.name || 'upload', mime_type: file.type || 'application/octet-stream', size_bytes: file.size, preview_url: file.type.startsWith('image/') ? URL.createObjectURL(file) : '', progress_percent: 0, transfer_state: error ? 'error' : 'reading', error, } }) setPending(prev => [...prev, ...newPending]) newPending .filter(attachment => !attachment.error) .forEach(attachment => { void prepareAttachment(attachment.id, attachment.file) }) }, [pending, prepareAttachment]) const removeAttachment = useCallback((id: string) => { setPending(prev => { const item = prev.find(attachment => attachment.id === id) if (item?.preview_url) URL.revokeObjectURL(item.preview_url) return prev.filter(attachment => attachment.id !== id) }) }, []) const handleSend = useCallback(() => { if (disabled || isWorking) return const content = text.trim() const preparing = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading') const ready = pending.filter(attachment => !attachment.error && !!attachment.base64_data) if (preparing.length > 0) return if (!content && ready.length === 0) return const attachments = ready.map(attachment => ({ filename: attachment.filename, data: attachment.base64_data!, mime_type: attachment.mime_type, })) onSend(content || 'Sent with attachments', attachments.length ? attachments : undefined) setText('') pending.forEach(attachment => { if (attachment.preview_url) URL.revokeObjectURL(attachment.preview_url) }) setPending([]) }, [disabled, isWorking, text, pending, onSend]) const handlePaste = useCallback((event: React.ClipboardEvent) => { const files = event.clipboardData?.files if (files && files.length > 0) { event.preventDefault() addFiles(files) } }, [addFiles]) const normalizedCompanyProfile = String(companyProfile ?? '').trim().toLowerCase() const normalizedExecMode: ComposerExecMode = execMode === 'company' ? 'company' : execMode === 'org' || execMode === 'custom' || normalizedCompanyProfile === 'custom' ? 'org' : 'task' const savedOrgOptions = useMemo( () => (savedOrgs ?? []).filter(org => !!org.name && org.name !== 'corporate'), [savedOrgs], ) const activeSavedOrgOption = activeSavedOrg ? savedOrgOptions.find(org => org.name === activeSavedOrg) : undefined const selectedOrgOption = selectedOrgId ? savedOrgOptions.find(org => org.name === selectedOrgId) : undefined const activeSavedOrgLabel = activeSavedOrgOption ? savedOrgLabel(activeSavedOrgOption) : activeSavedOrg || '' const selectedOrgLabel = selectedOrgOption ? savedOrgLabel(selectedOrgOption) : selectedOrgId || activeSavedOrgLabel const selectedOrgValue = selectedOrgOption?.name || selectedOrgId || activeSavedOrgOption?.name || '' const selectedModeOption: ComposerModeOption = normalizedExecMode === 'task' ? 'task' : 'company' const selectedCompanyArchitecture: CompanyArchitectureOption = normalizedExecMode === 'org' ? (selectedOrgValue ? `org:${selectedOrgValue}` : '') : 'corporate' const companyArchitectureLabel = normalizedExecMode === 'org' ? selectedOrgLabel ? `Company / ${selectedOrgLabel}` : 'Company / Saved org' : 'Company / Corporate' const modeLabel = selectedModeOption === 'task' ? 'Task' : companyArchitectureLabel const showModePicker = !!execMode && !!onModeChange const showTaskAgentPicker = normalizedExecMode === 'task' && !!onTaskAgentChange // Build the list of "Continue in a new chat" alternatives, excluding the // mode the current chat is already locked to. We surface up to three options // so the popover stays compact; the order is stable so users build muscle // memory for it. const continueAlternatives: ModeAlternative[] = useMemo(() => { const currentKey = normalizedExecMode === 'task' ? 'task' : normalizedExecMode === 'org' ? `org:${selectedOrgValue || 'selected'}` : 'company:corporate' const continueOrgName = selectedOrgValue || activeSavedOrgOption?.name || '' const continueOrgLabel = selectedOrgLabel || activeSavedOrgLabel || continueOrgName const all: ModeAlternative[] = [ { key: 'task', mode: 'task', label: 'Task', description: 'A single agent handles the request', icon: , }, { key: 'company:corporate', mode: 'company', profile: 'corporate', label: 'Company / Corporate', description: 'A team of roles collaborates', icon: , }, ] if (continueOrgName) { all.push({ key: `org:${continueOrgName}`, mode: 'org', profile: 'custom', orgId: continueOrgName, label: `Company / ${continueOrgLabel}`, description: 'A saved company architecture collaborates', icon: , }) } return all.filter(option => option.key !== currentKey) }, [activeSavedOrgLabel, activeSavedOrgOption?.name, normalizedExecMode, selectedOrgLabel, selectedOrgValue]) const preparingAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'reading').length const readyAttachmentCount = pending.filter(attachment => !attachment.error && attachment.transfer_state === 'ready').length const visibleTool = displayTool || currentTool const statusText = (() => { if (!isWorking) return null if (isStopping) return 'Stopping...' if (activeAgentCount && activeAgentCount > 1) return `${activeAgentCount} agents working` if (visibleTool) return `Running ${visibleTool}` if (agentStatus === 'reflecting') return 'Thinking...' return 'Working...' })() return ( <>
{isWorking && statusText && (
{statusText}
)} {pending.length > 0 && (
{pending.map(attachment => (
{attachment.preview_url ? ( {attachment.filename} setLightbox(attachment.preview_url)} /> ) : ( {attachmentBadgeLabel(attachment.mime_type, attachment.filename)} )} {attachment.filename} {attachment.error ? attachment.error : attachment.transfer_state === 'reading' ? `Preparing ${attachment.progress_percent}%` : `${formatSize(attachment.size_bytes)} - Ready`}
))}
)}