chatroom-input.tsx
1 'use client' 2 3 import { useState, useRef, useCallback, useEffect, useMemo, type KeyboardEvent } from 'react' 4 import { AgentAvatar } from '@/components/agents/agent-avatar' 5 import { ComposerShell } from '@/components/input/composer-shell' 6 import type { StructuredSessionLaunchContext } from '@/components/protocols/structured-session-launcher' 7 import { FilePreview } from '@/components/shared/file-preview' 8 import { useChatroomStore } from '@/stores/use-chatroom-store' 9 import { uploadImage } from '@/lib/upload' 10 import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/app/safe-storage' 11 import { 12 BREAKOUT_COMMAND, 13 buildBreakoutLaunchContext, 14 completeBreakoutCommand, 15 parseBreakoutCommand, 16 } from './breakout-command' 17 import type { Agent } from '@/types' 18 19 interface Props { 20 agents: Agent[] 21 onSend: (text: string) => void 22 disabled?: boolean 23 onBreakoutRequest?: (context: StructuredSessionLaunchContext) => void 24 } 25 26 export function ChatroomInput({ agents, onSend, disabled, onBreakoutRequest }: Props) { 27 const [text, setText] = useState('') 28 const [showMentions, setShowMentions] = useState(false) 29 const [mentionFilter, setMentionFilter] = useState('') 30 const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) 31 const [selectedSlashIndex, setSelectedSlashIndex] = useState(0) 32 const chatroomId = useChatroomStore((s) => s.currentChatroomId) 33 const chatrooms = useChatroomStore((s) => s.chatrooms) 34 const inputRef = useRef<HTMLTextAreaElement>(null) 35 const mirrorRef = useRef<HTMLDivElement>(null) 36 const fileInputRef = useRef<HTMLInputElement>(null) 37 const imageInputRef = useRef<HTMLInputElement>(null) 38 39 const pendingFiles = useChatroomStore((s) => s.pendingFiles) 40 const addPendingFile = useChatroomStore((s) => s.addPendingFile) 41 const removePendingFile = useChatroomStore((s) => s.removePendingFile) 42 const replyingTo = useChatroomStore((s) => s.replyingTo) 43 const setReplyingTo = useChatroomStore((s) => s.setReplyingTo) 44 const streaming = useChatroomStore((s) => s.streaming) 45 const queuedMessages = useChatroomStore((s) => s.queuedMessages) 46 const removeQueuedMessage = useChatroomStore((s) => s.removeQueuedMessage) 47 const clearQueuedMessages = useChatroomStore((s) => s.clearQueuedMessages) 48 const currentChatroom = chatroomId ? chatrooms[chatroomId] : null 49 50 const syncMirrorScroll = useCallback(() => { 51 const input = inputRef.current 52 const mirror = mirrorRef.current 53 if (!input || !mirror) return 54 mirror.scrollTop = input.scrollTop 55 mirror.scrollLeft = input.scrollLeft 56 }, []) 57 58 const resizeTextarea = useCallback(() => { 59 const node = inputRef.current 60 if (!node) return 61 node.style.height = 'auto' 62 node.style.height = `${Math.min(node.scrollHeight, 160)}px` 63 syncMirrorScroll() 64 }, [syncMirrorScroll]) 65 66 // Draft persistence: restore on chatroom change 67 const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 68 useEffect(() => { 69 if (!chatroomId) return 70 const draft = safeStorageGet(`sc_draft_cr_${chatroomId}`) 71 setText(draft || '') 72 }, [chatroomId]) 73 74 useEffect(() => { 75 resizeTextarea() 76 }, [resizeTextarea, text, chatroomId]) 77 78 // Debounced save to localStorage 79 useEffect(() => { 80 if (!chatroomId) return 81 if (draftTimerRef.current) clearTimeout(draftTimerRef.current) 82 draftTimerRef.current = setTimeout(() => { 83 if (text) safeStorageSet(`sc_draft_cr_${chatroomId}`, text) 84 else safeStorageRemove(`sc_draft_cr_${chatroomId}`) 85 }, 300) 86 return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) } 87 }, [text, chatroomId]) 88 89 const uploadAndAdd = useCallback(async (file: File) => { 90 try { 91 const result = await uploadImage(file) 92 addPendingFile({ file, path: result.path, url: result.url }) 93 } catch { 94 // ignore upload errors 95 } 96 // eslint-disable-next-line react-hooks/exhaustive-deps 97 }, []) 98 99 const handlePaste = useCallback(async (e: React.ClipboardEvent) => { 100 const items = e.clipboardData?.items 101 if (!items) return 102 for (const item of items) { 103 if (item.type.startsWith('image/')) { 104 e.preventDefault() 105 const file = item.getAsFile() 106 if (file) await uploadAndAdd(file) 107 return 108 } 109 } 110 }, [uploadAndAdd]) 111 112 const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => { 113 const files = e.target.files 114 if (!files?.length) return 115 for (const file of Array.from(files)) { 116 await uploadAndAdd(file) 117 } 118 e.target.value = '' 119 }, [uploadAndAdd]) 120 121 const handleChange = useCallback((value: string) => { 122 setText(value) 123 resizeTextarea() 124 const cursorPos = inputRef.current?.selectionStart || value.length 125 const beforeCursor = value.slice(0, cursorPos) 126 const mentionMatch = beforeCursor.match(/@(\S*)$/) 127 if (mentionMatch) { 128 setShowMentions(true) 129 setMentionFilter(mentionMatch[1].toLowerCase()) 130 setSelectedMentionIndex(0) 131 } else { 132 setShowMentions(false) 133 setMentionFilter('') 134 setSelectedMentionIndex(0) 135 } 136 setSelectedSlashIndex(0) 137 }, [resizeTextarea]) 138 139 const insertMention = useCallback((name: string) => { 140 const cursorPos = inputRef.current?.selectionStart || text.length 141 const beforeCursor = text.slice(0, cursorPos) 142 const afterCursor = text.slice(cursorPos) 143 const mentionMatch = beforeCursor.match(/@(\S*)$/) 144 if (mentionMatch) { 145 const normalizedName = name.replace(/\s+/g, '') 146 const needsSpace = afterCursor.length === 0 || !/^\s/.test(afterCursor) 147 const newBefore = beforeCursor.slice(0, mentionMatch.index) + `@${normalizedName}${needsSpace ? ' ' : ''}` 148 const nextText = newBefore + afterCursor 149 const nextCursorPos = newBefore.length 150 setText(nextText) 151 requestAnimationFrame(() => { 152 const input = inputRef.current 153 if (!input) return 154 input.focus() 155 input.setSelectionRange(nextCursorPos, nextCursorPos) 156 syncMirrorScroll() 157 }) 158 } 159 setShowMentions(false) 160 }, [syncMirrorScroll, text]) 161 162 const filteredAgents = agents.filter((a) => 163 a.name.toLowerCase().replace(/\s+/g, '').includes(mentionFilter) 164 ) 165 const breakoutCommand = useMemo(() => parseBreakoutCommand(text), [text]) 166 167 // Build highlighted segments for the mirror overlay 168 const highlightedSegments = useMemo(() => { 169 if (!text) return null 170 const parts: React.ReactNode[] = [] 171 let lastIndex = 0 172 const regex = /@\S+/g 173 let match: RegExpExecArray | null 174 while ((match = regex.exec(text)) !== null) { 175 if (match.index > lastIndex) { 176 parts.push(text.slice(lastIndex, match.index)) 177 } 178 parts.push( 179 <span key={match.index} className="bg-accent-soft/45 text-accent-bright rounded"> 180 {match[0]} 181 </span> 182 ) 183 lastIndex = regex.lastIndex 184 } 185 if (lastIndex < text.length) { 186 parts.push(text.slice(lastIndex)) 187 } 188 return parts.length > 0 ? parts : null 189 }, [text]) 190 191 const mentionDropdownVisible = showMentions 192 const mentionItems = mentionDropdownVisible 193 ? ['all', ...filteredAgents.map((a) => a.name)] 194 : [] 195 const slashDropdownVisible = !mentionDropdownVisible && !disabled && breakoutCommand.kind !== 'none' 196 const slashItems = slashDropdownVisible 197 ? [{ 198 id: BREAKOUT_COMMAND, 199 label: BREAKOUT_COMMAND, 200 description: 'Start a focused structured session from this room', 201 }] 202 : [] 203 const visibleQueuedMessages = queuedMessages.filter((item) => item.chatroomId === chatroomId) 204 205 const focusInputAtEnd = useCallback((value: string) => { 206 requestAnimationFrame(() => { 207 const input = inputRef.current 208 if (!input) return 209 input.focus() 210 input.setSelectionRange(value.length, value.length) 211 syncMirrorScroll() 212 }) 213 }, [syncMirrorScroll]) 214 215 const handleCompleteBreakoutCommand = useCallback(() => { 216 if (breakoutCommand.kind === 'command') return 217 const nextText = completeBreakoutCommand(text) 218 setText(nextText) 219 focusInputAtEnd(nextText) 220 }, [breakoutCommand.kind, focusInputAtEnd, text]) 221 222 const handleOpenBreakout = useCallback(() => { 223 if (disabled || !onBreakoutRequest || breakoutCommand.kind !== 'command' || !currentChatroom) return false 224 onBreakoutRequest(buildBreakoutLaunchContext(currentChatroom, breakoutCommand.topic)) 225 setText('') 226 resizeTextarea() 227 if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`) 228 setShowMentions(false) 229 setMentionFilter('') 230 setSelectedMentionIndex(0) 231 setSelectedSlashIndex(0) 232 return true 233 }, [ 234 breakoutCommand, 235 chatroomId, 236 currentChatroom, 237 disabled, 238 onBreakoutRequest, 239 resizeTextarea, 240 ]) 241 242 const handleSendCurrent = useCallback(() => { 243 if ((!text.trim() && !pendingFiles.length) || disabled) return 244 onSend(text) 245 setText('') 246 resizeTextarea() 247 if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`) 248 setShowMentions(false) 249 }, [chatroomId, disabled, onSend, pendingFiles.length, resizeTextarea, text]) 250 251 const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { 252 if (mentionDropdownVisible && mentionItems.length > 0) { 253 if (e.key === 'ArrowDown') { 254 e.preventDefault() 255 setSelectedMentionIndex((i) => (i + 1) % mentionItems.length) 256 return 257 } 258 if (e.key === 'ArrowUp') { 259 e.preventDefault() 260 setSelectedMentionIndex((i) => (i - 1 + mentionItems.length) % mentionItems.length) 261 return 262 } 263 if (e.key === 'Enter' || e.key === 'Tab') { 264 e.preventDefault() 265 const selected = mentionItems[selectedMentionIndex] 266 if (selected) insertMention(selected) 267 return 268 } 269 } 270 271 if (slashDropdownVisible && slashItems.length > 0) { 272 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 273 e.preventDefault() 274 setSelectedSlashIndex(0) 275 return 276 } 277 if ((e.key === 'Enter' || e.key === 'Tab') && breakoutCommand.kind === 'candidate') { 278 e.preventDefault() 279 handleCompleteBreakoutCommand() 280 return 281 } 282 } 283 284 if (e.key === 'Enter' && !e.shiftKey) { 285 e.preventDefault() 286 if (handleOpenBreakout()) return 287 handleSendCurrent() 288 } 289 if (e.key === 'Escape') { 290 if (replyingTo) { 291 setReplyingTo(null) 292 } 293 setShowMentions(false) 294 } 295 } 296 297 return ( 298 <div className="relative px-4 py-3 border-t border-white/[0.06]"> 299 {slashDropdownVisible && ( 300 <div className="absolute bottom-full left-4 right-4 mb-1 bg-raised border border-white/[0.1] rounded-[8px] shadow-xl max-h-[200px] overflow-y-auto z-50"> 301 {slashItems.map((item, index) => ( 302 <button 303 key={item.id} 304 type="button" 305 onClick={() => { 306 if (breakoutCommand.kind === 'command') { 307 handleOpenBreakout() 308 return 309 } 310 handleCompleteBreakoutCommand() 311 }} 312 className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all cursor-pointer ${ 313 selectedSlashIndex === index ? 'bg-white/[0.08]' : 'hover:bg-white/[0.06]' 314 }`} 315 > 316 <div className="flex h-6 min-w-6 items-center justify-center rounded-[8px] bg-sky-500/12 text-[10px] font-700 text-sky-200"> 317 / 318 </div> 319 <div className="min-w-0 flex-1"> 320 <div className="text-[13px] font-600 text-text">{item.label}</div> 321 <div className="text-[11px] text-text-3">{item.description}</div> 322 </div> 323 </button> 324 ))} 325 </div> 326 )} 327 328 {/* Mention dropdown */} 329 {mentionDropdownVisible && ( 330 <div className="absolute bottom-full left-4 right-4 mb-1 bg-raised border border-white/[0.1] rounded-[8px] shadow-xl max-h-[200px] overflow-y-auto z-50"> 331 <button 332 onClick={() => insertMention('all')} 333 className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all cursor-pointer ${ 334 selectedMentionIndex === 0 ? 'bg-white/[0.08]' : 'hover:bg-white/[0.06]' 335 }`} 336 > 337 <div className="w-5 h-5 rounded-full bg-accent-soft flex items-center justify-center text-[9px] font-700 text-accent-bright">@</div> 338 <span className="text-[13px] text-text">all</span> 339 <span className="text-[11px] text-text-3 ml-auto">Mention all agents</span> 340 </button> 341 {filteredAgents.length > 0 ? ( 342 filteredAgents.map((agent, i) => ( 343 <button 344 key={agent.id} 345 onClick={() => insertMention(agent.name)} 346 className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all cursor-pointer ${ 347 selectedMentionIndex === i + 1 ? 'bg-white/[0.08]' : 'hover:bg-white/[0.06]' 348 }`} 349 > 350 <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={20} /> 351 <span className="text-[13px] text-text">{agent.name}</span> 352 </button> 353 )) 354 ) : ( 355 <div className="px-3 py-3 text-[12px] text-text-3"> 356 No agents match <span className="text-text">@{mentionFilter}</span>. 357 </div> 358 )} 359 </div> 360 )} 361 362 {visibleQueuedMessages.length > 0 && ( 363 <div className="mb-2 overflow-hidden rounded-[14px] border border-amber-500/18 bg-[linear-gradient(180deg,rgba(245,158,11,0.08)_0%,rgba(245,158,11,0.03)_100%)] shadow-[0_10px_32px_rgba(245,158,11,0.06)]"> 364 <div className="flex items-start justify-between gap-3 border-b border-amber-500/10 px-3.5 py-3"> 365 <div className="min-w-0"> 366 <div className="flex items-center gap-2"> 367 <span className="relative flex h-2.5 w-2.5 shrink-0"> 368 <span className="absolute inline-flex h-2.5 w-2.5 rounded-full bg-amber-400/30 animate-ping" /> 369 <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-amber-300" /> 370 </span> 371 <span className="label-mono text-amber-300/80">Round queue</span> 372 <span className="rounded-pill border border-amber-400/15 bg-amber-400/10 px-2 py-0.5 text-[10px] font-600 text-amber-200"> 373 {visibleQueuedMessages.length} 374 </span> 375 <span className={`rounded-pill border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] ${ 376 streaming 377 ? 'border-amber-300/20 bg-amber-300/10 text-amber-100' 378 : 'border-white/[0.08] bg-white/[0.05] text-text-3' 379 }`}> 380 {streaming ? 'Round running' : 'Queue ready'} 381 </span> 382 </div> 383 <div className="mt-2 text-[12px] text-amber-100/80"> 384 {streaming 385 ? 'Queued prompts will send automatically when the current round finishes.' 386 : 'Queued prompts are ready and will dispatch automatically.'} 387 </div> 388 </div> 389 {chatroomId && visibleQueuedMessages.length > 1 && ( 390 <button 391 type="button" 392 onClick={() => clearQueuedMessages(chatroomId)} 393 className="shrink-0 rounded-pill border border-amber-400/15 bg-transparent px-3 py-1.5 text-[11px] font-600 text-amber-200/80 transition-all hover:border-amber-300/30 hover:bg-amber-300/[0.08] hover:text-amber-100 cursor-pointer" 394 > 395 Clear 396 </button> 397 )} 398 </div> 399 <div className="max-h-[184px] space-y-1.5 overflow-y-auto px-2.5 py-2.5"> 400 {visibleQueuedMessages.map((item, index) => ( 401 <div 402 key={item.id} 403 className={`flex items-start gap-3 rounded-[12px] border px-3 py-2.5 ${ 404 index === 0 405 ? 'border-amber-300/20 bg-amber-300/[0.07]' 406 : 'border-white/[0.05] bg-white/[0.02]' 407 }`} 408 > 409 <div className={`mt-0.5 flex h-6 min-w-6 items-center justify-center rounded-[8px] px-2 text-[10px] font-700 ${ 410 index === 0 411 ? 'bg-amber-300/15 text-amber-100' 412 : 'bg-white/[0.06] text-text-3' 413 }`}> 414 {index + 1} 415 </div> 416 <div className="min-w-0 flex-1"> 417 <div className="flex flex-wrap items-center gap-2"> 418 {index === 0 && ( 419 <span className="rounded-pill border border-amber-300/15 bg-amber-300/10 px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] text-amber-100"> 420 Next 421 </span> 422 )} 423 {item.pendingFiles.length > 0 && ( 424 <span className="rounded-pill border border-amber-400/15 bg-amber-400/10 px-2 py-0.5 text-[10px] text-amber-200"> 425 +{item.pendingFiles.length} file{item.pendingFiles.length === 1 ? '' : 's'} 426 </span> 427 )} 428 {item.replyingTo && ( 429 <span className="rounded-pill border border-white/[0.08] bg-white/[0.04] px-2 py-0.5 text-[10px] text-text-3"> 430 Reply queued 431 </span> 432 )} 433 </div> 434 <p className="mt-1 break-words text-[12px] leading-5 text-text/90 m-0"> 435 {item.text.trim() || `Attachment${item.pendingFiles.length === 1 ? '' : 's'} only`} 436 </p> 437 </div> 438 <button 439 type="button" 440 onClick={() => removeQueuedMessage(item.id)} 441 className="shrink-0 rounded-[8px] border border-transparent bg-transparent p-1.5 text-amber-300/60 transition-all hover:border-amber-300/20 hover:bg-amber-300/[0.08] hover:text-amber-100 cursor-pointer" 442 aria-label={`Remove queued chatroom message ${index + 1}`} 443 title="Remove from queue" 444 > 445 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 446 <line x1="18" y1="6" x2="6" y2="18" /> 447 <line x1="6" y1="6" x2="18" y2="18" /> 448 </svg> 449 </button> 450 </div> 451 ))} 452 </div> 453 </div> 454 )} 455 456 {visibleQueuedMessages.length === 0 && !disabled && ( 457 <div className="mb-2 flex items-center justify-between gap-2 rounded-[10px] border border-white/[0.06] bg-white/[0.03] px-3 py-2"> 458 <span className="text-[11px] text-text-3"> 459 {streaming 460 ? 'Current round is still running. Press send to queue the next message.' 461 : agents.length > 0 462 ? 'Use @AgentName or @all to direct the next reply, or /breakout to spin up a focused session.' 463 : 'Start the next round here.'} 464 </span> 465 <span className="text-[10px] text-text-3/50">Enter sends · Shift+Enter newline</span> 466 </div> 467 )} 468 469 {/* Reply preview banner */} 470 {replyingTo && ( 471 <div className="flex items-center gap-2 mb-2 px-2 py-1.5 rounded-[8px] bg-white/[0.04] border border-white/[0.06]"> 472 <div className="w-0.5 self-stretch rounded-full bg-accent-bright/50 shrink-0" /> 473 <div className="flex-1 min-w-0"> 474 <span className="text-[11px] font-600 text-accent-bright">{replyingTo.senderName}</span> 475 <p className="text-[12px] text-text-3 truncate m-0"> 476 {replyingTo.text.length > 100 ? replyingTo.text.slice(0, 100) + '...' : replyingTo.text} 477 </p> 478 </div> 479 <button 480 onClick={() => setReplyingTo(null)} 481 className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center hover:bg-white/[0.08] cursor-pointer text-text-3 hover:text-text transition-colors" 482 > 483 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 484 <line x1="18" y1="6" x2="6" y2="18" /> 485 <line x1="6" y1="6" x2="18" y2="18" /> 486 </svg> 487 </button> 488 </div> 489 )} 490 491 <ComposerShell 492 top={pendingFiles.length > 0 ? ( 493 <div className="flex items-center gap-2 px-5 pt-4 flex-wrap"> 494 {pendingFiles.map((f, i) => ( 495 <FilePreview key={i} file={f} onRemove={() => removePendingFile(i)} /> 496 ))} 497 </div> 498 ) : undefined} 499 footer={( 500 <div className="flex items-center gap-1 px-4 pb-3.5"> 501 <button 502 type="button" 503 onClick={() => fileInputRef.current?.click()} 504 disabled={disabled} 505 className="flex items-center gap-1.5 px-3 py-2 rounded-[10px] border-none bg-transparent text-text-3 text-[13px] cursor-pointer hover:text-text-2 hover:bg-white/[0.05] transition-all duration-200 disabled:opacity-30" 506 title="Attach file" 507 > 508 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 509 <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /> 510 </svg> 511 <span className="hidden sm:inline">Files</span> 512 </button> 513 <button 514 type="button" 515 onClick={() => imageInputRef.current?.click()} 516 disabled={disabled} 517 className="flex items-center gap-1.5 px-3 py-2 rounded-[10px] border-none bg-transparent text-text-3 text-[13px] cursor-pointer hover:text-text-2 hover:bg-white/[0.05] transition-all duration-200 disabled:opacity-30" 518 title="Attach image" 519 > 520 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 521 <rect x="3" y="3" width="18" height="18" rx="2" ry="2" /> 522 <circle cx="8.5" cy="8.5" r="1.5" /> 523 <polyline points="21 15 16 10 5 21" /> 524 </svg> 525 <span className="hidden sm:inline">Image</span> 526 </button> 527 528 <div className="flex-1" /> 529 530 <span className="text-[11px] text-text-3/60 tabular-nums mr-2 font-mono"> 531 {text.length > 0 && text.length} 532 </span> 533 534 <button 535 onClick={handleSendCurrent} 536 disabled={(!text.trim() && !pendingFiles.length) || disabled} 537 aria-label={streaming ? 'Queue message' : 'Send message'} 538 className={`w-9 h-9 rounded-[11px] border-none flex items-center justify-center shrink-0 cursor-pointer transition-all duration-250 ${ 539 (!text.trim() && !pendingFiles.length) || disabled 540 ? 'bg-white/[0.04] text-text-3 pointer-events-none' 541 : streaming 542 ? 'bg-amber-500/20 text-amber-400 active:scale-90 border border-amber-500/30' 543 : 'bg-accent-bright text-white active:scale-90 shadow-[0_4px_16px_rgba(99,102,241,0.3)]' 544 }`} 545 title={streaming ? 'Queue message' : 'Send message'} 546 > 547 {streaming ? ( 548 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 549 <line x1="12" y1="5" x2="12" y2="19" /> 550 <line x1="5" y1="12" x2="19" y2="12" /> 551 </svg> 552 ) : ( 553 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 554 <line x1="12" y1="19" x2="12" y2="5" /> 555 <polyline points="5 12 12 5 19 12" /> 556 </svg> 557 )} 558 </button> 559 </div> 560 )} 561 hint={disabled ? 'Live rooms are watch-first' : 'Shift+Enter for newline · /breakout starts a focused session'} 562 > 563 <div className="relative"> 564 {/* Highlight mirror — renders the visible text while the textarea handles input and caret. */} 565 <div 566 ref={mirrorRef} 567 aria-hidden 568 className="absolute inset-0 px-5 pt-4 pb-2 text-[15px] leading-[1.55] text-text break-words whitespace-pre-wrap pointer-events-none overflow-hidden" 569 style={{ minHeight: '56px' }} 570 > 571 {highlightedSegments} 572 </div> 573 <textarea 574 ref={inputRef} 575 value={text} 576 onChange={(e) => handleChange(e.target.value)} 577 onKeyDown={handleKeyDown} 578 onPaste={handlePaste} 579 onScroll={syncMirrorScroll} 580 placeholder="Ask the room anything... Use @ to mention agents or /breakout to focus the room" 581 disabled={disabled} 582 rows={1} 583 className="relative w-full resize-none border-none bg-transparent px-5 pt-4 pb-2 text-[15px] text-transparent caret-white placeholder:text-text-3/70 focus:outline-none selection:bg-accent-bright/20 max-h-[160px] leading-[1.55] disabled:opacity-50" 584 style={{ minHeight: '56px', caretColor: 'rgb(244 244 245)' }} 585 /> 586 </div> 587 </ComposerShell> 588 589 {/* Hidden file inputs */} 590 <input ref={fileInputRef} type="file" multiple 591 accept="image/*,.pdf,.txt,.md,.csv,.json,.xml,.html,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.c,.cpp,.h,.yml,.yaml,.toml,.env,.log,.sh,.sql,.css,.scss" 592 onChange={handleFileChange} className="hidden" /> 593 <input ref={imageInputRef} type="file" multiple 594 accept="image/*" 595 onChange={handleFileChange} className="hidden" /> 596 </div> 597 ) 598 }