/ src / components / chatrooms / chatroom-input.tsx
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  }