@traky-app/ui 1.6.5 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@traky-app/ui",
3
- "version": "1.6.5",
3
+ "version": "1.6.6",
4
4
  "description": "Componentes Traky UI para importar desde @traky-app/ui, más el registro copy-paste.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "files": [
24
24
  {
25
25
  "path": "ui/internal-chat-panel.tsx",
26
- "content": "\"use client\";\n\nimport {\n IconArrowUp,\n IconCheck,\n IconChecks,\n IconDotsVertical,\n IconFile,\n IconHeadset,\n IconLoader2,\n IconMicrophone,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPhoto,\n IconPlayerPause,\n IconPlayerPlay,\n IconSquare,\n IconPlus,\n IconScreenShare,\n IconSparkles,\n IconTrash,\n IconTruckLoading,\n IconX,\n type Icon,\n} from \"@tabler/icons-react\";\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type FormEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription,\n DrawerTitle,\n} from \"@/components/ui/drawer\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Message,\n MessageAvatar,\n MessageContent,\n MessageFooter,\n MessageHeader,\n} from \"@/components/ui/message\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport {\n buildMentionList,\n DEFAULT_MENTION_KIND_LABEL,\n DEFAULT_MENTION_KIND_ORDER,\n formatMessageTime,\n parseMentionSegment,\n parseSlashMention,\n roomInitialLetter,\n type BuildMentionListOptions,\n type InternalChatMentionCatalogState,\n type InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\n type InternalChatMentionListEntry,\n} from \"@/components/ui/internal-chat-mention\";\n\nexport type InternalChatRoom = {\n id: string;\n name: string;\n isOrganization?: boolean;\n /** Canal reservado con el administrador / soporte Traky. */\n isSupport?: boolean;\n};\n\n/** Acuse de recibo estilo mensajería (1 palomita = enviado, 2 grises = llegó, 2 azules = leído). */\nexport type InternalChatMessageReceiptStatus =\n | \"pending\"\n | \"sent\"\n | \"delivered\"\n | \"read\";\n\nexport type InternalChatMessageAttachment = {\n kind: \"image\" | \"file\" | \"video\";\n name: string;\n url?: string;\n sizeLabel?: string;\n};\n\nconst SCREEN_RECORD_MAX_MS = 60_000;\n\nexport type InternalChatMessageAudio = {\n src: string;\n durationMs?: number;\n};\n\nexport type InternalChatMessageLine = {\n id: string;\n role: \"me\" | \"team\";\n author: string;\n initials: string;\n text: string;\n at: string;\n kind?: \"text\" | \"audio\";\n audio?: InternalChatMessageAudio;\n attachments?: InternalChatMessageAttachment[];\n /** Solo mensajes propios (`role: \"me\"`). */\n receiptStatus?: InternalChatMessageReceiptStatus;\n};\n\nexport type InternalChatMessagesStatus =\n | \"idle\"\n | \"loading\"\n | \"error\"\n | \"empty\"\n | \"ready\";\n\nexport type InternalChatPanelLabels = {\n openChat: string;\n newRoom: string;\n roomMenu: string;\n deleteRoom: string;\n closeChat: string;\n newRoomTitle: string;\n newRoomDescription: string;\n newRoomPlaceholder: string;\n cancel: string;\n create: string;\n deleteRoomTitle: string;\n deleteRoomDescription: (roomName: string) => string;\n deleteConfirm: string;\n noRoomTitle: string;\n noRoomDescription: string;\n loadingMessages: string;\n loadMessagesErrorTitle: string;\n loadMessagesErrorFallback: string;\n emptyThreadTitle: string;\n emptyThreadDescription: (roomName: string) => string;\n loadingOlderMessages: string;\n mentionListLabel: string;\n messagePlaceholder: string;\n messagePlaceholderNoRoom: string;\n messageAriaLabel: string;\n attachAriaLabel: string;\n attachPhotos: string;\n attachFiles: string;\n attachAudio: string;\n attachAudioFile: string;\n attachmentPending: string;\n voiceRecordTitle: string;\n voiceRecordHint: string;\n voiceRecordStart: string;\n voiceRecordStop: string;\n voiceRecordSend: string;\n voiceRecordCancel: string;\n voiceRecordPermissionError: string;\n screenCaptureAriaLabel: string;\n screenRecordTitle: string;\n screenRecordHint: string;\n screenRecordStart: string;\n screenRecordStop: string;\n screenRecordSend: string;\n screenRecordCancel: string;\n screenRecordPermissionError: string;\n screenRecordMaxReached: string;\n screenRecordBackgroundHint: string;\n screenRecordOpenChatToFinish: string;\n sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: string;\n supportChat: string;\n supportChatTooltip: string;\n supportRoomDescription: string;\n};\n\nconst DEFAULT_LABELS: InternalChatPanelLabels = {\n openChat: \"Abrir chat de la empresa\",\n newRoom: \"Nueva sala\",\n roomMenu: \"Más acciones de salas\",\n deleteRoom: \"Eliminar sala actual\",\n closeChat: \"Cerrar chat\",\n newRoomTitle: \"Nueva sala\",\n newRoomDescription:\n \"Crea un espacio para conversar con tu equipo en tiempo real.\",\n newRoomPlaceholder: \"Nombre de la sala\",\n cancel: \"Cancelar\",\n create: \"Crear\",\n deleteRoomTitle: \"¿Eliminar esta sala?\",\n deleteRoomDescription: (roomName) =>\n `Se borrarán los mensajes de «${roomName}» en el servidor. Esta acción no se puede deshacer.`,\n deleteConfirm: \"Eliminar\",\n noRoomTitle: \"Sin sala\",\n noRoomDescription: \"Crea una sala con el botón + a la izquierda.\",\n loadingMessages: \"Cargando mensajes…\",\n loadMessagesErrorTitle: \"No se pudieron cargar los mensajes\",\n loadMessagesErrorFallback:\n \"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.\",\n emptyThreadTitle: \"¿De qué hablamos?\",\n emptyThreadDescription: (roomName) =>\n `Escribe el primer mensaje en «${roomName}».`,\n loadingOlderMessages: \"Cargando mensajes anteriores…\",\n mentionListLabel: \"Insertar mención\",\n messagePlaceholder: \"Mensaje… · / comandos\",\n messagePlaceholderNoRoom: \"Selecciona una sala…\",\n messageAriaLabel: \"Mensaje\",\n attachAriaLabel: \"Adjuntar\",\n attachPhotos: \"Fotos\",\n attachFiles: \"Archivos\",\n attachAudio: \"Grabar nota de voz\",\n attachAudioFile: \"Enviar audio guardado\",\n attachmentPending: \"Vista previa · conexión lista\",\n voiceRecordTitle: \"Grabar nota de voz\",\n voiceRecordHint: \"Pulsa grabar, habla y envía cuando termines.\",\n voiceRecordStart: \"Grabar\",\n voiceRecordStop: \"Detener\",\n voiceRecordSend: \"Enviar audio\",\n voiceRecordCancel: \"Cancelar\",\n voiceRecordPermissionError:\n \"No pudimos usar el micrófono. Revisa los permisos del navegador.\",\n screenCaptureAriaLabel: \"Capturar pantalla\",\n screenRecordTitle: \"Captura de pantalla\",\n screenRecordHint:\n \"Inicia y elige compartir la pestaña de Traky (no solo el panel del chat). Puedes cerrar el chat y seguir navegando (máx. 1 min).\",\n screenRecordStart: \"Iniciar captura\",\n screenRecordStop: \"Detener\",\n screenRecordSend: \"Enviar video\",\n screenRecordCancel: \"Cancelar\",\n screenRecordPermissionError:\n \"No se pudo iniciar la captura. Revisa los permisos del navegador.\",\n screenRecordMaxReached: \"Se alcanzó el máximo de 1 minuto.\",\n screenRecordBackgroundHint:\n \"Puedes cerrar el chat; la grabación continúa. Vuelve aquí para detener y enviar.\",\n screenRecordOpenChatToFinish: \"Abrir chat · captura en curso\",\n sendSrOnly: \"Enviar\",\n orgRoomSuffix: \" · Sala de toda la organización\",\n orgRoomTooltip: \" — toda la organización (no se elimina)\",\n slashMentionHint: \"menciones (guías y paquetes)\",\n selectRoomHint: \"Selecciona una sala o crea una nueva.\",\n supportChat: \"Soporte Traky\",\n supportChatTooltip: \"Chat con el equipo de soporte de Traky\",\n supportRoomDescription:\n \"Canal directo con soporte Traky para tu organización.\",\n};\n\nconst DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {\n kindOrder: DEFAULT_MENTION_KIND_ORDER,\n kindIcons: {\n guide: IconTruckLoading,\n package: IconPackage,\n ai: IconSparkles,\n },\n categoryMeta: {\n guide: {\n label: \"Guía\",\n hint: \"Guías de envío\",\n keywords: [\"guía\", \"guia\", \"guide\", \"envío\", \"envio\"],\n },\n package: {\n label: \"Paquete\",\n hint: \"Paquetes de la empresa (WR / tracking)\",\n keywords: [\"paquete\", \"paquetes\", \"package\", \"trk\", \"wr\"],\n },\n ai: {\n label: \"Asistencia de IA\",\n hint: \"Consultas sobre operación y datos (próximamente)\",\n keywords: [\"ia\", \"ai\", \"asistencia\", \"inteligencia\", \"asistente\"],\n disabled: true,\n },\n },\n};\n\nfunction isMentionEntryDisabled(\n entry: InternalChatMentionListEntry,\n config: InternalChatMentionConfig,\n): boolean {\n if (entry.type !== \"category\") return false;\n return Boolean(\n entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,\n );\n}\n\nfunction stepMentionHighlight(\n entries: InternalChatMentionListEntry[],\n config: InternalChatMentionConfig,\n current: number,\n delta: 1 | -1,\n): number {\n if (entries.length === 0) return 0;\n let next = current;\n for (let i = 0; i < entries.length; i++) {\n next = (next + delta + entries.length) % entries.length;\n if (!isMentionEntryDisabled(entries[next]!, config)) return next;\n }\n return current;\n}\n\nfunction formatUnreadBadge(count: number): string {\n if (count <= 0) return \"\";\n if (count > 9) return \"9+\";\n return String(count);\n}\n\n/** Contador en círculo perfecto (sin `min-w`/`px` que lo ovalan). */\nfunction unreadCountBadgeClasses(label: string): string {\n const wide = label.length > 1;\n return cn(\n \"pointer-events-none absolute flex shrink-0 items-center justify-center rounded-full bg-destructive font-bold tabular-nums leading-none text-destructive-foreground shadow-sm\",\n wide\n ? \"-right-1 -top-1 size-5 text-[0.5rem]\"\n : \"-right-0.5 -top-0.5 size-4 text-[0.625rem]\",\n );\n}\n\nfunction MessageReceiptTicks({\n status,\n}: {\n status: InternalChatMessageReceiptStatus;\n}) {\n if (status === \"pending\") {\n return (\n <IconLoader2\n className=\"size-3 shrink-0 animate-spin text-muted-foreground\"\n aria-hidden\n />\n );\n }\n if (status === \"sent\") {\n return (\n <IconCheck\n className=\"size-3.5 shrink-0 text-muted-foreground\"\n aria-hidden\n />\n );\n }\n const read = status === \"read\";\n return (\n <IconChecks\n className={cn(\n \"size-3.5 shrink-0\",\n read ? \"text-sky-500 dark:text-sky-400\" : \"text-muted-foreground\",\n )}\n aria-hidden\n />\n );\n}\n\nfunction formatAudioDuration(ms: number): string {\n const totalSec = Math.max(0, Math.round(ms / 1000));\n const m = Math.floor(totalSec / 60);\n const s = totalSec % 60;\n return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\nfunction pseudoWaveformBars(seed: string, count = 28): number[] {\n let h = 0;\n for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;\n return Array.from({ length: count }, (_, i) => {\n h = (h * 1103515245 + 12345 + i) >>> 0;\n return 0.25 + (h % 75) / 100;\n });\n}\n\nfunction InternalChatAudioBubble({\n audio,\n seed,\n mine,\n}: {\n audio: InternalChatMessageAudio;\n seed: string;\n mine: boolean;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [durationMs, setDurationMs] = useState(audio.durationMs ?? 0);\n const [progress, setProgress] = useState(0);\n\n const bars = useMemo(() => pseudoWaveformBars(seed), [seed]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setDurationMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [audio.src]);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n const displayMs = durationMs || audio.durationMs || 0;\n\n return (\n <div className=\"flex min-w-[min(100%,14rem)] items-center gap-2\">\n <audio ref={ref} src={audio.src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant={mine ? \"secondary\" : \"outline\"}\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar audio\" : \"Reproducir audio\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n <div className=\"flex h-7 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[3px] shrink-0 rounded-full transition-colors\",\n lit\n ? mine\n ? \"bg-foreground/70\"\n : \"bg-primary\"\n : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction InternalChatAttachmentStack({\n items,\n labels,\n}: {\n items: InternalChatMessageAttachment[];\n labels: InternalChatPanelLabels;\n}) {\n if (items.length === 0) return null;\n return (\n <div className=\"flex flex-col gap-1.5\">\n {items.map((att, i) => (\n <div\n key={`${att.name}-${i}`}\n className=\"overflow-hidden rounded-lg border border-border bg-muted/40\"\n >\n {att.kind === \"video\" && att.url ? (\n <video\n src={att.url}\n controls\n playsInline\n className=\"max-h-48 w-full bg-black object-contain\"\n />\n ) : att.kind === \"image\" && att.url ? (\n <img\n src={att.url}\n alt={att.name}\n className=\"max-h-40 w-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center gap-2 px-3 py-2 text-sm\">\n {att.kind === \"image\" ? (\n <IconPhoto className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : att.kind === \"video\" ? (\n <IconScreenShare className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : (\n <IconFile className=\"size-4 shrink-0 text-muted-foreground\" />\n )}\n <span className=\"min-w-0 truncate font-medium\">{att.name}</span>\n {att.sizeLabel ? (\n <span className=\"ml-auto shrink-0 text-xs text-muted-foreground\">\n {att.sizeLabel}\n </span>\n ) : null}\n </div>\n )}\n {!att.url ? (\n <p className=\"border-t border-border px-3 py-1 text-[0.65rem] text-muted-foreground\">\n {labels.attachmentPending}\n </p>\n ) : null}\n </div>\n ))}\n </div>\n );\n}\n\nfunction InternalChatVoiceRecorderBar({\n labels,\n onClose,\n onSendBlob,\n onSendLegacy,\n}: {\n labels: InternalChatPanelLabels;\n onClose: () => void;\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>;\n onSendLegacy?: () => void | Promise<void>;\n}) {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const [recording, setRecording] = useState(false);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [durationMs, setDurationMs] = useState(0);\n const [error, setError] = useState<string | null>(null);\n const [sending, setSending] = useState(false);\n\n const stopTracks = useCallback((recorder: MediaRecorder | null) => {\n recorder?.stream.getTracks().forEach((t) => t.stop());\n }, []);\n\n const startRecording = useCallback(async () => {\n setError(null);\n setBlob(null);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const mime = MediaRecorder.isTypeSupported(\"audio/webm\")\n ? \"audio/webm\"\n : \"audio/mp4\";\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n const next = new Blob(chunksRef.current, { type: mime });\n setBlob(next);\n const ms = Math.max(\n 0,\n Date.now() - (startedAtRef.current || Date.now()),\n );\n setDurationMs(ms);\n stopTracks(recorder);\n recorderRef.current = null;\n };\n startedAtRef.current = Date.now();\n recorder.start();\n setRecording(true);\n } catch {\n setError(labels.voiceRecordPermissionError);\n }\n }, [labels.voiceRecordPermissionError, stopTracks]);\n\n const stopRecording = useCallback(() => {\n const rec = recorderRef.current;\n if (!rec || rec.state === \"inactive\") return;\n rec.stop();\n setRecording(false);\n }, []);\n\n const handleClose = useCallback(() => {\n if (recording) stopRecording();\n stopTracks(recorderRef.current);\n recorderRef.current = null;\n onClose();\n }, [onClose, recording, stopRecording, stopTracks]);\n\n const handleSend = useCallback(async () => {\n if (sending) return;\n setSending(true);\n try {\n if (blob && onSendBlob) {\n await onSendBlob(blob, durationMs);\n } else if (onSendLegacy) {\n await onSendLegacy();\n }\n onClose();\n } finally {\n setSending(false);\n }\n }, [blob, durationMs, onClose, onSendBlob, onSendLegacy, sending]);\n\n const previewUrl = useMemo(\n () => (blob ? URL.createObjectURL(blob) : null),\n [blob],\n );\n\n useEffect(() => {\n return () => {\n if (previewUrl) URL.revokeObjectURL(previewUrl);\n };\n }, [previewUrl]);\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.voiceRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.voiceRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.voiceRecordCancel}\n onClick={handleClose}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? \"Pulsa el cuadrado rojo para terminar.\"\n : blob\n ? \"Escucha la vista previa y envía cuando quieras.\"\n : labels.voiceRecordHint}\n </p>\n )}\n <div className=\"mt-2 flex items-center gap-2\">\n {previewUrl && blob ? (\n <VoiceRecorderPreviewBar\n src={previewUrl}\n durationMs={durationMs}\n />\n ) : recording ? (\n <>\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 shrink-0 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.voiceRecordStop}\n </Button>\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando…\n </span>\n </>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={() => void startRecording()}\n >\n <IconMicrophone className=\"size-3.5\" />\n {labels.voiceRecordStart}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || (!blob && !onSendLegacy)}\n onClick={() => void handleSend()}\n >\n {labels.voiceRecordSend}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction VoiceRecorderPreviewBar({\n src,\n durationMs,\n}: {\n src: string;\n durationMs: number;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [progress, setProgress] = useState(0);\n const [resolvedMs, setResolvedMs] = useState(durationMs);\n const bars = useMemo(() => pseudoWaveformBars(\"voice-recorder-preview\"), []);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setResolvedMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [src]);\n\n const displayMs = resolvedMs || durationMs;\n\n return (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <audio ref={ref} src={src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar vista previa\" : \"Reproducir vista previa\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex h-6 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[2px] shrink-0 rounded-full transition-colors\",\n lit ? \"bg-primary\" : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction pickScreenRecorderMime(): string {\n const candidates = [\n \"video/webm;codecs=vp9\",\n \"video/webm;codecs=vp8\",\n \"video/webm\",\n \"video/mp4\",\n ];\n return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? \"video/webm\";\n}\n\ntype ScreenRecorderSession = {\n recording: boolean;\n elapsedMs: number;\n blob: Blob | null;\n error: string | null;\n maxReached: boolean;\n sending: boolean;\n previewUrl: string | null;\n canSend: boolean;\n timerLabel: string;\n active: boolean;\n startCapture: () => Promise<void>;\n stopRecording: () => void;\n cancelSession: () => void;\n sendRecording: () => Promise<void>;\n};\n\nfunction useInternalChatScreenRecorderSession(\n labels: InternalChatPanelLabels,\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>,\n onRecordingChange?: (recording: boolean) => void,\n): ScreenRecorderSession {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const streamRef = useRef<MediaStream | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const durationMsRef = useRef(0);\n const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);\n const maxTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const previewUrlRef = useRef<string | null>(null);\n\n const [recording, setRecording] = useState(false);\n const [elapsedMs, setElapsedMs] = useState(0);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [maxReached, setMaxReached] = useState(false);\n const [sending, setSending] = useState(false);\n const [previewUrl, setPreviewUrl] = useState<string | null>(null);\n\n const clearTimers = useCallback(() => {\n if (tickRef.current) {\n clearInterval(tickRef.current);\n tickRef.current = null;\n }\n if (maxTimerRef.current) {\n clearTimeout(maxTimerRef.current);\n maxTimerRef.current = null;\n }\n }, []);\n\n const stopStream = useCallback(() => {\n streamRef.current?.getTracks().forEach((t) => t.stop());\n streamRef.current = null;\n }, []);\n\n const revokePreview = useCallback(() => {\n if (previewUrlRef.current) {\n URL.revokeObjectURL(previewUrlRef.current);\n previewUrlRef.current = null;\n }\n setPreviewUrl(null);\n }, []);\n\n const stopRecording = useCallback(\n (reason?: \"max\") => {\n const rec = recorderRef.current;\n if (rec && rec.state !== \"inactive\") {\n rec.stop();\n } else {\n clearTimers();\n stopStream();\n setRecording(false);\n onRecordingChange?.(false);\n }\n if (reason === \"max\") setMaxReached(true);\n },\n [clearTimers, onRecordingChange, stopStream],\n );\n\n const startCapture = useCallback(async () => {\n setError(null);\n revokePreview();\n setBlob(null);\n setMaxReached(false);\n setElapsedMs(0);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getDisplayMedia({\n video: true,\n audio: false,\n preferCurrentTab: true,\n } as DisplayMediaStreamOptions);\n streamRef.current = stream;\n const videoTrack = stream.getVideoTracks()[0];\n videoTrack?.addEventListener(\"ended\", () => stopRecording());\n\n const mime = pickScreenRecorderMime();\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n clearTimers();\n const finalMs = Math.min(\n SCREEN_RECORD_MAX_MS,\n Math.max(0, Date.now() - (startedAtRef.current || Date.now())),\n );\n durationMsRef.current = finalMs;\n setElapsedMs(finalMs);\n const next = new Blob(chunksRef.current, {\n type: recorder.mimeType || mime,\n });\n setBlob(next);\n const url = URL.createObjectURL(next);\n previewUrlRef.current = url;\n setPreviewUrl(url);\n stopStream();\n recorderRef.current = null;\n setRecording(false);\n onRecordingChange?.(false);\n };\n startedAtRef.current = Date.now();\n recorder.start(500);\n setRecording(true);\n onRecordingChange?.(true);\n tickRef.current = setInterval(() => {\n const ms = Date.now() - startedAtRef.current;\n const clamped = Math.min(ms, SCREEN_RECORD_MAX_MS);\n setElapsedMs(clamped);\n if (ms >= SCREEN_RECORD_MAX_MS) stopRecording(\"max\");\n }, 200);\n maxTimerRef.current = setTimeout(\n () => stopRecording(\"max\"),\n SCREEN_RECORD_MAX_MS,\n );\n } catch {\n stopStream();\n setError(labels.screenRecordPermissionError);\n setRecording(false);\n onRecordingChange?.(false);\n }\n }, [\n clearTimers,\n labels.screenRecordPermissionError,\n onRecordingChange,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const cancelSession = useCallback(() => {\n if (recording) stopRecording();\n clearTimers();\n stopStream();\n recorderRef.current = null;\n revokePreview();\n setBlob(null);\n setError(null);\n setMaxReached(false);\n setElapsedMs(0);\n durationMsRef.current = 0;\n setRecording(false);\n onRecordingChange?.(false);\n }, [\n clearTimers,\n onRecordingChange,\n recording,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const sendRecording = useCallback(async () => {\n if (sending || !blob || !onSendBlob) return;\n setSending(true);\n try {\n await onSendBlob(blob, durationMsRef.current || elapsedMs);\n cancelSession();\n } finally {\n setSending(false);\n }\n }, [blob, cancelSession, elapsedMs, onSendBlob, sending]);\n\n useEffect(() => {\n return () => {\n clearTimers();\n if (recorderRef.current?.state === \"recording\") {\n recorderRef.current.stop();\n }\n stopStream();\n revokePreview();\n };\n }, [clearTimers, revokePreview, stopStream]);\n\n const timerLabel = `${formatAudioDuration(elapsedMs)} / ${formatAudioDuration(SCREEN_RECORD_MAX_MS)}`;\n const active = recording || Boolean(blob) || Boolean(error);\n\n return {\n recording,\n elapsedMs,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend: Boolean(blob) && Boolean(onSendBlob),\n timerLabel,\n active,\n startCapture,\n stopRecording: () => stopRecording(),\n cancelSession,\n sendRecording,\n };\n}\n\nfunction InternalChatScreenRecorderBar({\n labels,\n session,\n onDismiss,\n}: {\n labels: InternalChatPanelLabels;\n session: ScreenRecorderSession;\n onDismiss: () => void;\n}) {\n const {\n recording,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend,\n timerLabel,\n startCapture,\n stopRecording,\n cancelSession,\n sendRecording,\n } = session;\n\n const handleDismiss = () => {\n if (!recording && !blob) {\n cancelSession();\n }\n onDismiss();\n };\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.screenRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.screenRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={\n recording\n ? \"Ocultar panel (la grabación continúa)\"\n : labels.screenRecordCancel\n }\n onClick={handleDismiss}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? labels.screenRecordBackgroundHint\n : maxReached\n ? labels.screenRecordMaxReached\n : blob\n ? \"Revisa el video y envíalo al chat.\"\n : labels.screenRecordHint}\n </p>\n )}\n {recording ? (\n <p className=\"mt-1 text-xs font-medium tabular-nums text-destructive\">\n {timerLabel}\n </p>\n ) : null}\n <div className=\"mt-2 flex flex-col gap-2\">\n {previewUrl && blob ? (\n <video\n src={previewUrl}\n controls\n playsInline\n className=\"max-h-32 w-full rounded-md border border-border bg-black object-contain\"\n />\n ) : null}\n <div className=\"flex flex-wrap items-center gap-2\">\n {recording ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.screenRecordStop}\n </Button>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n disabled={Boolean(blob)}\n onClick={() => void startCapture()}\n >\n <IconScreenShare className=\"size-3.5\" />\n {labels.screenRecordStart}\n </Button>\n )}\n {recording ? (\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando pantalla\n </span>\n ) : null}\n {(recording || blob) && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 text-xs text-muted-foreground\"\n onClick={cancelSession}\n >\n {labels.screenRecordCancel}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || !canSend}\n onClick={() => void sendRecording()}\n >\n {labels.screenRecordSend}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\nfunction renderMessageTextWithMentions(text: string): ReactNode {\n const parts = text.split(/(@\\[[^\\]]+\\])/g);\n return parts.map((part, index) => {\n const mention = /^@\\[(.+)\\]$/.exec(part);\n if (mention) {\n return (\n <span\n key={index}\n className=\"inline-flex max-w-full items-center rounded-md border border-primary/35 bg-primary/12 px-1.5 py-0.5 font-medium text-primary\"\n >\n {mention[1]}\n </span>\n );\n }\n return <span key={index}>{part}</span>;\n });\n}\n\nexport type InternalChatPanelProps = {\n title: string;\n rooms: InternalChatRoom[];\n activeRoomId: string | null;\n onActiveRoomChange: (roomId: string) => void;\n messages: InternalChatMessageLine[];\n messagesStatus: InternalChatMessagesStatus;\n messagesError?: string | null;\n onLoadOlderMessages?: () => void | Promise<void>;\n isLoadingOlder?: boolean;\n hasOlderMessages?: boolean;\n mentionGuideItems?: InternalChatMentionItem[];\n mentionPackageItems?: InternalChatMentionItem[];\n mentionConfig?: InternalChatMentionConfig;\n /** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */\n mentionServerFilteredItems?: boolean;\n mentionCatalogLoading?: boolean;\n onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;\n typingLine?: string | null;\n onSendMessage: (text: string) => void | Promise<void>;\n onCreateRoom: (name: string) => void | Promise<void>;\n onDeleteRoom: (roomId: string) => void | Promise<void>;\n onOpenChange?: (open: boolean) => void;\n onDraftChange?: (draft: string) => void;\n onUserGesture?: () => void;\n isSending?: boolean;\n isCreatingRoom?: boolean;\n isDeletingRoom?: boolean;\n onDeleteRoomBlocked?: (reason: string) => void;\n labels?: Partial<InternalChatPanelLabels>;\n className?: string;\n /** Mensajes no vistos (icono del header: badge rojo, hasta «9+»). */\n unreadTotal?: number;\n /** No leídos por sala (badge en el rail de salas). */\n unreadByRoomId?: Record<string, number>;\n /** Se invoca al abrir el panel o al cambiar de sala con el chat visible. */\n onMarkRoomRead?: (roomId: string) => void;\n /** Conexión BFF: el contenedor sube el archivo. */\n onAttachPhotos?: (file: File) => void | Promise<void>;\n onAttachFiles?: (file: File) => void | Promise<void>;\n /** Tras grabar en el panel (conexión BFF / subida). */\n onSendAudioBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Atajo legacy: el host abre su propio flujo sin el grabador integrado. */\n onSendAudio?: () => void | Promise<void>;\n onAttachAudioFile?: (file: File) => void | Promise<void>;\n /** Video de captura de pantalla (máx. 1 min en el grabador integrado). */\n onSendScreenRecordingBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Abre el drawer al montar (p. ej. `/admin/.../chat`). */\n defaultOpen?: boolean;\n /** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */\n hideTrigger?: boolean;\n};\n\nfunction InternalChatPanel({\n title,\n rooms,\n activeRoomId,\n onActiveRoomChange,\n messages,\n messagesStatus,\n messagesError = null,\n onLoadOlderMessages,\n isLoadingOlder = false,\n hasOlderMessages = false,\n mentionGuideItems = [],\n mentionPackageItems = [],\n mentionConfig = DEFAULT_MENTION_CONFIG,\n mentionServerFilteredItems = false,\n mentionCatalogLoading = false,\n onMentionCatalogChange,\n typingLine = null,\n onSendMessage,\n onCreateRoom,\n onDeleteRoom,\n onOpenChange,\n onDraftChange,\n onUserGesture,\n isSending = false,\n isCreatingRoom = false,\n isDeletingRoom = false,\n onDeleteRoomBlocked,\n labels: labelsPartial,\n className,\n unreadTotal = 0,\n unreadByRoomId,\n onMarkRoomRead,\n onAttachPhotos,\n onAttachFiles,\n onSendAudioBlob,\n onSendAudio,\n onSendScreenRecordingBlob,\n defaultOpen = false,\n hideTrigger = false,\n}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(defaultOpen);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [voiceRecorderOpen, setVoiceRecorderOpen] = useState(false);\n const [screenRecorderOpen, setScreenRecorderOpen] = useState(false);\n const screenRecorderSession = useInternalChatScreenRecorderSession(\n labels,\n onSendScreenRecordingBlob,\n useCallback((isRecording: boolean) => {\n if (isRecording) setScreenRecorderOpen(true);\n }, []),\n );\n const [mentionStart, setMentionStart] = useState<number | null>(null);\n const [mentionQuery, setMentionQuery] = useState(\"\");\n const [mentionHighlight, setMentionHighlight] = useState(0);\n const lastMentionQueryRef = useRef(\"\");\n const loadingOlderMessagesRef = useRef(false);\n\n const formRef = useRef<HTMLFormElement>(null);\n const scrollerRef = useRef<HTMLDivElement>(null);\n const messagesListRef = useRef<HTMLDivElement>(null);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const photoInputRef = useRef<HTMLInputElement>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n const supportRoom = useMemo(\n () => rooms.find((r) => r.isSupport) ?? null,\n [rooms],\n );\n\n const teamRooms = useMemo(\n () => rooms.filter((r) => !r.isSupport),\n [rooms],\n );\n\n const activeRoom = useMemo(\n () => rooms.find((r) => r.id === activeRoomId) ?? null,\n [rooms, activeRoomId],\n );\n\n const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {\n if (!mentionServerFilteredItems) return undefined;\n const seg = parseMentionSegment(mentionQuery, mentionConfig);\n if (seg.phase !== \"item\") return undefined;\n return { serverFilteredItems: true };\n }, [mentionServerFilteredItems, mentionQuery, mentionConfig]);\n\n const mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ),\n [\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ],\n );\n\n const mentionSeg = useMemo(\n () => parseMentionSegment(mentionQuery, mentionConfig),\n [mentionQuery, mentionConfig],\n );\n\n const mentionOpen =\n Boolean(activeRoom) &&\n mentionStart !== null &&\n (mentionEntries.length > 0 ||\n mentionCatalogLoading ||\n (mentionServerFilteredItems && mentionSeg.phase === \"item\"));\n\n const canSend =\n draft.trim().length > 0 &&\n !isSending &&\n Boolean(activeRoomId);\n\n const messagesScrollTail = useMemo(() => {\n if (messages.length === 0) {\n return `empty:${messagesStatus}`;\n }\n const last = messages[messages.length - 1]!;\n const attachCount = last.attachments?.length ?? 0;\n const hasAudio = last.audio ? 1 : 0;\n return [\n messages.length,\n last.id,\n last.text.length,\n attachCount,\n hasAudio,\n last.receiptStatus ?? \"\",\n messagesStatus,\n ].join(\"|\");\n }, [messages, messagesStatus]);\n\n const scrollMessagesToBottom = useCallback(() => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n }, []);\n\n const scheduleScrollMessagesToBottom = useCallback(() => {\n scrollMessagesToBottom();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollMessagesToBottom();\n rafNested = requestAnimationFrame(scrollMessagesToBottom);\n });\n const timeouts = [50, 150, 320].map((ms) =>\n window.setTimeout(scrollMessagesToBottom, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [scrollMessagesToBottom]);\n\n const setOpenWithNotify = useCallback(\n (next: boolean) => {\n if (next) onUserGesture?.();\n setOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, onUserGesture],\n );\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n onMarkRoomRead?.(activeRoomId);\n }, [open, activeRoomId, onMarkRoomRead]);\n\n useEffect(() => {\n if (!open) return;\n if (screenRecorderSession.active) {\n setScreenRecorderOpen(true);\n }\n }, [open, screenRecorderSession.active]);\n\n const handleMessagesScroll = useCallback(() => {\n const node = scrollerRef.current;\n if (!node || !activeRoomId || !onLoadOlderMessages) return;\n if (loadingOlderMessagesRef.current) return;\n if (node.scrollTop > 120) return;\n if (!hasOlderMessages || isLoadingOlder) return;\n loadingOlderMessagesRef.current = true;\n const prevHeight = node.scrollHeight;\n const prevTop = node.scrollTop;\n void Promise.resolve(onLoadOlderMessages()).finally(() => {\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n loadingOlderMessagesRef.current = false;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open || !activeRoomId) return;\n if (loadingOlderMessagesRef.current) return;\n return scheduleScrollMessagesToBottom();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n typingLine,\n draft,\n voiceRecorderOpen,\n screenRecorderOpen,\n scheduleScrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n const scroller = scrollerRef.current;\n const list = messagesListRef.current;\n const textarea = textareaRef.current;\n if (!scroller) return;\n\n const pinBottom = () => {\n if (loadingOlderMessagesRef.current) return;\n scrollMessagesToBottom();\n };\n\n const ro = new ResizeObserver(pinBottom);\n ro.observe(scroller);\n if (list) ro.observe(list);\n if (textarea) ro.observe(textarea);\n const form = formRef.current;\n if (form) ro.observe(form);\n\n return () => ro.disconnect();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n scrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!mentionOpen || mentionEntries.length === 0) return;\n setMentionHighlight((h) => Math.min(h, mentionEntries.length - 1));\n }, [mentionOpen, mentionEntries.length]);\n\n useEffect(() => {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }, [activeRoomId]);\n\n useEffect(() => {\n if (!onMentionCatalogChange) return;\n if (mentionStart === null) {\n onMentionCatalogChange({\n open: false,\n phase: \"category\",\n filter: \"\",\n });\n return;\n }\n if (mentionSeg.phase === \"category\") {\n onMentionCatalogChange({\n open: true,\n phase: \"category\",\n filter: mentionSeg.filter,\n });\n return;\n }\n onMentionCatalogChange({\n open: true,\n phase: \"item\",\n kind: mentionSeg.kind,\n filter: mentionSeg.filter,\n });\n }, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);\n\n function closeMentionMenu() {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }\n\n function syncMentionMenu(nextDraft: string, cursor: number) {\n const m = parseSlashMention(nextDraft, cursor);\n if (!m) {\n closeMentionMenu();\n return;\n }\n const seg = parseMentionSegment(m.query, mentionConfig);\n const listOptions: BuildMentionListOptions | undefined =\n mentionServerFilteredItems && seg.phase === \"item\"\n ? { serverFilteredItems: true }\n : undefined;\n const entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n listOptions,\n );\n const keepOpenOnEmpty =\n mentionCatalogLoading ||\n (mentionServerFilteredItems && seg.phase === \"item\");\n if (entries.length === 0 && !keepOpenOnEmpty) {\n closeMentionMenu();\n return;\n }\n if (m.query !== lastMentionQueryRef.current) {\n setMentionHighlight(0);\n lastMentionQueryRef.current = m.query;\n }\n setMentionStart(m.start);\n setMentionQuery(m.query);\n }\n\n function replaceSlashSegment(newSegment: string) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const newDraft = `${before}/${newSegment}${after}`;\n const slashEnd = before.length + 1 + newSegment.length;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n setMentionQuery(newSegment);\n lastMentionQueryRef.current = newSegment;\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(slashEnd, slashEnd);\n });\n }\n\n function applyCategoryPick(kind: InternalChatMentionKind) {\n if (mentionConfig.categoryMeta[kind]?.disabled) return;\n replaceSlashSegment(`${kind}:`);\n setMentionHighlight(0);\n }\n\n function applyItemPick(item: InternalChatMentionItem) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const token = `@[${item.insertLabel}]`;\n const needsSpace =\n after.length > 0 && !/^\\s/.test(after) && !after.startsWith(\"\\n\");\n const newDraft = `${before}${token}${needsSpace ? \" \" : \"\"}${after}`;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n closeMentionMenu();\n const newPos = before.length + token.length + (needsSpace ? 1 : 0);\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(newPos, newPos);\n });\n }\n\n async function onSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (mentionOpen) return;\n const text = draft.trim();\n if (!text || !activeRoomId) return;\n onUserGesture?.();\n try {\n await onSendMessage(text);\n setDraft(\"\");\n onDraftChange?.(\"\");\n scheduleScrollMessagesToBottom();\n } catch {\n /* errores vía contenedor */\n }\n }\n\n async function handleCreateRoom() {\n const name = newRoomName.trim();\n if (!name) return;\n try {\n await onCreateRoom(name);\n setNewRoomName(\"\");\n setCreateOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n async function handleConfirmDeleteRoom() {\n if (!activeRoom) return;\n try {\n await onDeleteRoom(activeRoom.id);\n setDeleteOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n function requestDeleteRoom() {\n if (!activeRoom) return;\n if (activeRoom.isOrganization) {\n onDeleteRoomBlocked?.(\"La sala Organización no se puede eliminar.\");\n return;\n }\n if (activeRoom.isSupport) {\n onDeleteRoomBlocked?.(\"La sala de soporte no se puede eliminar.\");\n return;\n }\n if (teamRooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n {!hideTrigger ? (\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={\n unreadTotal > 0\n ? `${labels.openChat} · ${unreadTotal} sin leer`\n : labels.openChat\n }\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {unreadTotal > 0 ? (\n <span\n className={unreadCountBadgeClasses(formatUnreadBadge(unreadTotal))}\n aria-hidden\n >\n {formatUnreadBadge(unreadTotal)}\n </span>\n ) : null}\n {screenRecorderSession.recording ? (\n <span\n className=\"absolute -top-0.5 -left-0.5 size-2.5 animate-pulse rounded-full bg-destructive ring-2 ring-background\"\n aria-hidden\n />\n ) : null}\n </Button>\n ) : !open ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"gap-2\"\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {labels.openChat}\n </Button>\n ) : null}\n\n {!open && screenRecorderSession.active ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"fixed bottom-6 right-6 z-[200] h-9 gap-2 shadow-lg\"\n onClick={() => setOpenWithNotify(true)}\n >\n <span className=\"size-2 animate-pulse rounded-full bg-destructive-foreground\" />\n <span className=\"text-xs font-medium tabular-nums\">\n {screenRecorderSession.recording\n ? `REC ${screenRecorderSession.timerLabel}`\n : screenRecorderSession.blob\n ? labels.screenRecordOpenChatToFinish\n : labels.screenRecordTitle}\n </span>\n </Button>\n ) : null}\n\n <Dialog open={createOpen} onOpenChange={setCreateOpen}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <DialogTitle>{labels.newRoomTitle}</DialogTitle>\n <DialogDescription>{labels.newRoomDescription}</DialogDescription>\n </DialogHeader>\n <Input\n value={newRoomName}\n onChange={(e) => setNewRoomName(e.target.value)}\n placeholder={labels.newRoomPlaceholder}\n autoFocus\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n void handleCreateRoom();\n }\n }}\n />\n <DialogFooter className=\"gap-2 sm:gap-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => {\n setCreateOpen(false);\n setNewRoomName(\"\");\n }}\n >\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleCreateRoom()}\n disabled={isCreatingRoom}\n >\n {labels.create}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{labels.deleteRoomTitle}</AlertDialogTitle>\n <AlertDialogDescription>\n {labels.deleteRoomDescription(activeRoom?.name ?? \"la sala\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{labels.cancel}</AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n onClick={() => void handleConfirmDeleteRoom()}\n disabled={isDeletingRoom}\n >\n {labels.deleteConfirm}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <Drawer\n open={open}\n onOpenChange={setOpenWithNotify}\n direction=\"right\"\n shouldScaleBackground={false}\n >\n <DrawerContent\n className={cn(\n \"gap-0 overflow-hidden p-0 shadow-xl\",\n \"data-[vaul-drawer-direction=right]:inset-y-3\",\n \"data-[vaul-drawer-direction=right]:right-3\",\n \"data-[vaul-drawer-direction=right]:left-auto\",\n \"data-[vaul-drawer-direction=right]:h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:max-h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:sm:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:rounded-2xl\",\n \"data-[vaul-drawer-direction=right]:border\",\n )}\n >\n <div className=\"flex h-full min-h-0 flex-row\">\n <div className=\"flex w-[3.25rem] shrink-0 flex-col items-center gap-2 border-r border-border py-4\">\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.newRoom}\n onClick={() => setCreateOpen(true)}\n >\n <IconPlus className=\"size-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">{labels.newRoom}</TooltipContent>\n </Tooltip>\n\n <div className=\"flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1\">\n {teamRooms.map((room) => {\n const selected = room.id === activeRoomId;\n return (\n <Tooltip key={room.id} delayDuration={300}>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={() => onActiveRoomChange(room.id)}\n className={cn(\n \"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-colors\",\n selected\n ? \"border-primary bg-primary/15\"\n : \"border-transparent bg-muted hover:bg-muted/80\",\n )}\n aria-label={`Sala ${room.name}${room.isOrganization ? \" (toda la organización)\" : \"\"}`}\n aria-current={selected ? \"true\" : undefined}\n >\n <span className=\"text-xs font-semibold leading-none\">\n {roomInitialLetter(room.name)}\n </span>\n {(unreadByRoomId?.[room.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(unreadByRoomId![room.id]!),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![room.id]!)}\n </span>\n ) : null}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {room.isOrganization\n ? `${room.name}${labels.orgRoomTooltip}`\n : room.name}\n </TooltipContent>\n </Tooltip>\n );\n })}\n </div>\n\n {supportRoom ? (\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\n \"relative size-9 shrink-0 rounded-full border shadow-sm\",\n activeRoomId === supportRoom.id\n ? \"border-sky-600 bg-sky-600 text-white hover:bg-sky-600/90 hover:text-white dark:border-sky-500 dark:bg-sky-500\"\n : \"border-sky-500/70 bg-sky-500/20 text-sky-800 hover:bg-sky-500/30 dark:border-sky-400/60 dark:bg-sky-500/25 dark:text-sky-100\",\n )}\n aria-label={labels.supportChatTooltip}\n aria-current={\n activeRoomId === supportRoom.id ? \"true\" : undefined\n }\n onClick={() => onActiveRoomChange(supportRoom.id)}\n >\n <IconHeadset className=\"size-4 stroke-[2.25]\" />\n {(unreadByRoomId?.[supportRoom.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(\n unreadByRoomId![supportRoom.id]!,\n ),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![supportRoom.id]!)}\n </span>\n ) : null}\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {labels.supportChatTooltip}\n </TooltipContent>\n </Tooltip>\n ) : null}\n\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.roomMenu}\n >\n <IconDotsVertical className=\"size-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"left\" align=\"end\" className=\"w-48\">\n <DropdownMenuItem onClick={() => setCreateOpen(true)}>\n <IconPlus className=\"size-4\" />\n {labels.newRoom}\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem\n disabled={\n teamRooms.length <= 1 ||\n activeRoom?.isOrganization ||\n activeRoom?.isSupport\n }\n className=\"text-destructive focus:text-destructive\"\n onClick={requestDeleteRoom}\n >\n <IconTrash className=\"size-4\" />\n {labels.deleteRoom}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <div className=\"flex items-start justify-between gap-4 px-5 pt-5 pb-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <DrawerTitle className=\"flex items-center gap-2 font-medium\">\n <IconMessages className=\"size-5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{title}</span>\n </DrawerTitle>\n <DrawerDescription className=\"line-clamp-2\">\n {activeRoom ? (\n activeRoom.isSupport ? (\n <span>{labels.supportRoomDescription}</span>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {activeRoom.name}\n </span>\n {activeRoom.isOrganization ? (\n <>{labels.orgRoomSuffix}</>\n ) : null}\n {\" · \"}\n <kbd className=\"pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]\">\n /\n </kbd>{\" \"}\n {labels.slashMentionHint}\n </>\n )\n ) : (\n labels.selectRoomHint\n )}\n </DrawerDescription>\n </div>\n <DrawerClose asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.closeChat}\n >\n <IconX className=\"size-4\" />\n </Button>\n </DrawerClose>\n </div>\n\n <div\n ref={scrollerRef}\n className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n onScroll={handleMessagesScroll}\n >\n {!activeRoom ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.noRoomTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.noRoomDescription}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"loading\" ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-2 text-muted-foreground\">\n <IconLoader2 className=\"size-6 animate-spin\" aria-hidden />\n <span className=\"text-sm\">{labels.loadingMessages}</span>\n </div>\n ) : messagesStatus === \"error\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>\n <EmptyDescription className=\"text-left\">\n {messagesError ?? labels.loadMessagesErrorFallback}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"empty\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.emptyThreadTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.emptyThreadDescription(activeRoom.name)}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : (\n <div\n ref={messagesListRef}\n className=\"flex flex-col gap-4 px-5 pt-2 pb-6\"\n >\n {isLoadingOlder ? (\n <div className=\"flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground\">\n <IconLoader2\n className=\"size-3.5 shrink-0 animate-spin\"\n aria-hidden\n />\n {labels.loadingOlderMessages}\n </div>\n ) : null}\n {messages.map((message) => {\n const mine = message.role === \"me\";\n return (\n <Message\n key={message.id}\n align={mine ? \"end\" : \"start\"}\n >\n {mine ? null : (\n <MessageAvatar className=\"size-8 text-xs font-medium\">\n {message.initials}\n </MessageAvatar>\n )}\n <MessageContent className=\"gap-0.5\">\n {mine ? null : (\n <MessageHeader className=\"px-3 pb-0 leading-snug\">\n {message.author}\n </MessageHeader>\n )}\n {message.attachments?.length ? (\n <InternalChatAttachmentStack\n items={message.attachments}\n labels={labels}\n />\n ) : null}\n {message.audio ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"py-2\">\n <InternalChatAudioBubble\n audio={message.audio}\n seed={message.id}\n mine={mine}\n />\n </BubbleContent>\n </Bubble>\n ) : null}\n {message.text.trim() ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"break-words whitespace-pre-wrap\">\n {renderMessageTextWithMentions(message.text)}\n </BubbleContent>\n </Bubble>\n ) : null}\n <MessageFooter className=\"flex items-center justify-end gap-1 px-3 pt-0 leading-none\">\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {formatMessageTime(message.at)}\n </span>\n {mine && message.receiptStatus ? (\n <span\n className=\"inline-flex shrink-0\"\n title={\n message.receiptStatus === \"pending\"\n ? \"Enviando\"\n : message.receiptStatus === \"sent\"\n ? \"Enviado\"\n : message.receiptStatus === \"delivered\"\n ? \"Entregado\"\n : \"Leído\"\n }\n >\n <MessageReceiptTicks\n status={message.receiptStatus}\n />\n </span>\n ) : null}\n </MessageFooter>\n </MessageContent>\n </Message>\n );\n })}\n </div>\n )}\n {activeRoom && typingLine ? (\n <div\n className=\"border-t border-border px-5 py-2 text-xs text-muted-foreground\"\n aria-live=\"polite\"\n >\n {typingLine}\n </div>\n ) : null}\n </div>\n\n {voiceRecorderOpen && activeRoom ? (\n <InternalChatVoiceRecorderBar\n labels={labels}\n onClose={() => setVoiceRecorderOpen(false)}\n onSendBlob={onSendAudioBlob}\n onSendLegacy={onSendAudio}\n />\n ) : null}\n\n {activeRoom && screenRecorderOpen ? (\n <InternalChatScreenRecorderBar\n labels={labels}\n session={screenRecorderSession}\n onDismiss={() => setScreenRecorderOpen(false)}\n />\n ) : null}\n\n <form\n ref={formRef}\n onSubmit={onSubmit}\n className=\"relative px-5 pt-2 pb-5\"\n >\n {mentionOpen ? (\n <div\n role=\"listbox\"\n aria-label={labels.mentionListLabel}\n className=\"absolute bottom-full left-5 right-5 z-50 mb-1 flex max-h-60 flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md\"\n >\n <div className=\"shrink-0 border-b border-border px-2 py-1.5 text-[0.7rem] text-muted-foreground\">\n {mentionSeg.phase === \"category\" ? (\n <>\n <span className=\"font-medium text-foreground\">\n Tipo\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n Tab\n </kbd>\n /\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ↵\n </kbd>\n </>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {DEFAULT_MENTION_KIND_LABEL[mentionSeg.kind]}\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ←\n </kbd>\n </>\n )}\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto p-1\">\n {mentionCatalogLoading && mentionEntries.length === 0 ? (\n <div className=\"flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground\">\n <IconLoader2 className=\"size-4 animate-spin\" />\n Buscando…\n </div>\n ) : null}\n {!mentionCatalogLoading &&\n mentionEntries.length === 0 &&\n mentionSeg.phase === \"item\" ? (\n <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n Sin resultados\n </p>\n ) : null}\n {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n const categoryDisabled = isMentionEntryDisabled(\n entry,\n mentionConfig,\n );\n return (\n <button\n key={`cat-${entry.kind}`}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n aria-disabled={categoryDisabled}\n disabled={categoryDisabled}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n categoryDisabled &&\n \"cursor-not-allowed opacity-50\",\n !categoryDisabled &&\n (index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\"),\n )}\n onMouseEnter={() => {\n if (!categoryDisabled) setMentionHighlight(index);\n }}\n onMouseDown={(event) => {\n event.preventDefault();\n if (categoryDisabled) return;\n applyCategoryPick(entry.kind);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {entry.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {entry.hint}\n </span>\n </span>\n </button>\n );\n }\n const item = entry.data;\n const IconComp = item.icon;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\",\n )}\n onMouseEnter={() => setMentionHighlight(index)}\n onMouseDown={(event) => {\n event.preventDefault();\n applyItemPick(item);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {item.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {item.subtitle}\n </span>\n </span>\n </button>\n );\n })}\n </div>\n </div>\n ) : null}\n\n <InputGroup>\n <InputGroupTextarea\n ref={textareaRef}\n value={draft}\n onChange={(event) => {\n const v = event.target.value;\n const cursor =\n event.target.selectionStart ?? v.length;\n setDraft(v);\n onDraftChange?.(v);\n syncMentionMenu(v, cursor);\n }}\n onSelect={(event) => {\n const t = event.target as HTMLTextAreaElement;\n syncMentionMenu(\n t.value,\n t.selectionStart ?? t.value.length,\n );\n }}\n placeholder={\n activeRoom\n ? labels.messagePlaceholder\n : labels.messagePlaceholderNoRoom\n }\n aria-label={labels.messageAriaLabel}\n className=\"min-h-14\"\n disabled={!activeRoom}\n onKeyDown={(event) => {\n onUserGesture?.();\n if (mentionOpen) {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n 1,\n ),\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n -1,\n ),\n );\n return;\n }\n if (event.key === \"Backspace\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"ArrowLeft\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Tab\") {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n closeMentionMenu();\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n formRef.current?.requestSubmit();\n }\n }}\n />\n <InputGroupAddon align=\"block-end\" className=\"pt-1\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <InputGroupButton\n aria-label={labels.attachAriaLabel}\n type=\"button\"\n size=\"icon-sm\"\n variant=\"outline\"\n disabled={!activeRoom}\n >\n <IconPlus className=\"size-4\" />\n </InputGroupButton>\n </DropdownMenuTrigger>\n <DropdownMenuContent\n align=\"start\"\n side=\"top\"\n className=\"w-48\"\n >\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n photoInputRef.current?.click();\n }}\n >\n <IconPhoto className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n fileInputRef.current?.click();\n }}\n >\n <IconPaperclip className=\"size-4\" />\n {labels.attachFiles}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"button\"\n variant={\n screenRecorderOpen || screenRecorderSession.recording\n ? \"default\"\n : \"outline\"\n }\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.screenCaptureAriaLabel}\n aria-pressed={\n screenRecorderOpen || screenRecorderSession.recording\n }\n className={cn(\n !screenRecorderOpen &&\n !screenRecorderSession.recording &&\n \"text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setVoiceRecorderOpen(false);\n setScreenRecorderOpen((prev) => !prev);\n }}\n >\n <IconScreenShare className=\"size-4\" />\n </InputGroupButton>\n <input\n ref={photoInputRef}\n type=\"file\"\n accept=\"image/*\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachPhotos) void onAttachPhotos(file);\n e.target.value = \"\";\n }}\n />\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachFiles) void onAttachFiles(file);\n e.target.value = \"\";\n }}\n />\n <div className=\"ml-auto flex shrink-0 items-center gap-1\">\n <InputGroupButton\n type=\"button\"\n variant={voiceRecorderOpen ? \"default\" : \"outline\"}\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.attachAudio}\n aria-pressed={voiceRecorderOpen}\n className={cn(\n !voiceRecorderOpen &&\n \"text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setScreenRecorderOpen(false);\n setVoiceRecorderOpen((open) => !open);\n }}\n >\n <IconMicrophone className=\"size-4\" />\n </InputGroupButton>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\n </div>\n </InputGroupAddon>\n </InputGroup>\n </form>\n </div>\n </div>\n </DrawerContent>\n </Drawer>\n </>\n );\n}\n\nexport {\n InternalChatPanel,\n DEFAULT_MENTION_CONFIG,\n DEFAULT_LABELS as DEFAULT_INTERNAL_CHAT_PANEL_LABELS,\n};\n\nexport { initialsFromDisplayName } from \"@/components/ui/internal-chat-mention\";\n\nexport type {\n BuildMentionListOptions,\n InternalChatMentionCatalogState,\n InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
26
+ "content": "\"use client\";\n\nimport {\n IconArrowUp,\n IconCheck,\n IconChecks,\n IconDotsVertical,\n IconFile,\n IconHeadset,\n IconLoader2,\n IconMicrophone,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPhoto,\n IconPlayerPause,\n IconPlayerPlay,\n IconSquare,\n IconPlus,\n IconScreenShare,\n IconSparkles,\n IconTrash,\n IconTruckLoading,\n IconX,\n type Icon,\n} from \"@tabler/icons-react\";\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type FormEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription,\n DrawerTitle,\n} from \"@/components/ui/drawer\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Message,\n MessageAvatar,\n MessageContent,\n MessageFooter,\n MessageHeader,\n} from \"@/components/ui/message\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport {\n buildMentionList,\n DEFAULT_MENTION_KIND_LABEL,\n DEFAULT_MENTION_KIND_ORDER,\n formatMessageTime,\n parseMentionSegment,\n parseSlashMention,\n roomInitialLetter,\n type BuildMentionListOptions,\n type InternalChatMentionCatalogState,\n type InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\n type InternalChatMentionListEntry,\n} from \"@/components/ui/internal-chat-mention\";\n\nexport type InternalChatRoom = {\n id: string;\n name: string;\n isOrganization?: boolean;\n /** Canal reservado con el administrador / soporte Traky. */\n isSupport?: boolean;\n};\n\n/** Acuse de recibo estilo mensajería (1 palomita = enviado, 2 grises = llegó, 2 azules = leído). */\nexport type InternalChatMessageReceiptStatus =\n | \"pending\"\n | \"sent\"\n | \"delivered\"\n | \"read\";\n\nexport type InternalChatMessageAttachment = {\n kind: \"image\" | \"file\" | \"video\";\n name: string;\n url?: string;\n sizeLabel?: string;\n};\n\nconst SCREEN_RECORD_MAX_MS = 60_000;\n\nexport type InternalChatMessageAudio = {\n src: string;\n durationMs?: number;\n};\n\nexport type InternalChatMessageLine = {\n id: string;\n role: \"me\" | \"team\";\n author: string;\n initials: string;\n text: string;\n at: string;\n kind?: \"text\" | \"audio\";\n audio?: InternalChatMessageAudio;\n attachments?: InternalChatMessageAttachment[];\n /** Solo mensajes propios (`role: \"me\"`). */\n receiptStatus?: InternalChatMessageReceiptStatus;\n};\n\nexport type InternalChatMessagesStatus =\n | \"idle\"\n | \"loading\"\n | \"error\"\n | \"empty\"\n | \"ready\";\n\nexport type InternalChatPanelLabels = {\n openChat: string;\n newRoom: string;\n roomMenu: string;\n deleteRoom: string;\n closeChat: string;\n newRoomTitle: string;\n newRoomDescription: string;\n newRoomPlaceholder: string;\n cancel: string;\n create: string;\n deleteRoomTitle: string;\n deleteRoomDescription: (roomName: string) => string;\n deleteConfirm: string;\n noRoomTitle: string;\n noRoomDescription: string;\n loadingMessages: string;\n loadMessagesErrorTitle: string;\n loadMessagesErrorFallback: string;\n emptyThreadTitle: string;\n emptyThreadDescription: (roomName: string) => string;\n loadingOlderMessages: string;\n mentionListLabel: string;\n messagePlaceholder: string;\n messagePlaceholderNoRoom: string;\n messageAriaLabel: string;\n attachAriaLabel: string;\n attachPhotos: string;\n attachFiles: string;\n attachAudio: string;\n attachAudioFile: string;\n attachmentPending: string;\n voiceRecordTitle: string;\n voiceRecordHint: string;\n voiceRecordStart: string;\n voiceRecordStop: string;\n voiceRecordSend: string;\n voiceRecordCancel: string;\n voiceRecordPermissionError: string;\n screenCaptureAriaLabel: string;\n screenRecordTitle: string;\n screenRecordHint: string;\n screenRecordStart: string;\n screenRecordStop: string;\n screenRecordSend: string;\n screenRecordCancel: string;\n screenRecordPermissionError: string;\n screenRecordMaxReached: string;\n screenRecordBackgroundHint: string;\n screenRecordOpenChatToFinish: string;\n sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: string;\n supportChat: string;\n supportChatTooltip: string;\n supportRoomDescription: string;\n};\n\nconst DEFAULT_LABELS: InternalChatPanelLabels = {\n openChat: \"Abrir chat de la empresa\",\n newRoom: \"Nueva sala\",\n roomMenu: \"Más acciones de salas\",\n deleteRoom: \"Eliminar sala actual\",\n closeChat: \"Cerrar chat\",\n newRoomTitle: \"Nueva sala\",\n newRoomDescription:\n \"Crea un espacio para conversar con tu equipo en tiempo real.\",\n newRoomPlaceholder: \"Nombre de la sala\",\n cancel: \"Cancelar\",\n create: \"Crear\",\n deleteRoomTitle: \"¿Eliminar esta sala?\",\n deleteRoomDescription: (roomName) =>\n `Se borrarán los mensajes de «${roomName}» en el servidor. Esta acción no se puede deshacer.`,\n deleteConfirm: \"Eliminar\",\n noRoomTitle: \"Sin sala\",\n noRoomDescription: \"Crea una sala con el botón + a la izquierda.\",\n loadingMessages: \"Cargando mensajes…\",\n loadMessagesErrorTitle: \"No se pudieron cargar los mensajes\",\n loadMessagesErrorFallback:\n \"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.\",\n emptyThreadTitle: \"¿De qué hablamos?\",\n emptyThreadDescription: (roomName) =>\n `Escribe el primer mensaje en «${roomName}».`,\n loadingOlderMessages: \"Cargando mensajes anteriores…\",\n mentionListLabel: \"Insertar mención\",\n messagePlaceholder: \"Mensaje… · / comandos\",\n messagePlaceholderNoRoom: \"Selecciona una sala…\",\n messageAriaLabel: \"Mensaje\",\n attachAriaLabel: \"Adjuntar\",\n attachPhotos: \"Fotos\",\n attachFiles: \"Archivos\",\n attachAudio: \"Grabar nota de voz\",\n attachAudioFile: \"Enviar audio guardado\",\n attachmentPending: \"Vista previa · conexión lista\",\n voiceRecordTitle: \"Grabar nota de voz\",\n voiceRecordHint: \"Pulsa grabar, habla y envía cuando termines.\",\n voiceRecordStart: \"Grabar\",\n voiceRecordStop: \"Detener\",\n voiceRecordSend: \"Enviar audio\",\n voiceRecordCancel: \"Cancelar\",\n voiceRecordPermissionError:\n \"No pudimos usar el micrófono. Revisa los permisos del navegador.\",\n screenCaptureAriaLabel: \"Capturar pantalla\",\n screenRecordTitle: \"Captura de pantalla\",\n screenRecordHint:\n \"Inicia y elige compartir la pestaña de Traky (no solo el panel del chat). Puedes cerrar el chat y seguir navegando (máx. 1 min).\",\n screenRecordStart: \"Iniciar captura\",\n screenRecordStop: \"Detener\",\n screenRecordSend: \"Enviar video\",\n screenRecordCancel: \"Cancelar\",\n screenRecordPermissionError:\n \"No se pudo iniciar la captura. Revisa los permisos del navegador.\",\n screenRecordMaxReached: \"Se alcanzó el máximo de 1 minuto.\",\n screenRecordBackgroundHint:\n \"Puedes cerrar el chat; la grabación continúa. Vuelve aquí para detener y enviar.\",\n screenRecordOpenChatToFinish: \"Abrir chat · captura en curso\",\n sendSrOnly: \"Enviar\",\n orgRoomSuffix: \" · Sala de toda la organización\",\n orgRoomTooltip: \" — toda la organización (no se elimina)\",\n slashMentionHint: \"menciones (guías y paquetes)\",\n selectRoomHint: \"Selecciona una sala o crea una nueva.\",\n supportChat: \"Soporte Traky\",\n supportChatTooltip: \"Chat con el equipo de soporte de Traky\",\n supportRoomDescription:\n \"Canal directo con soporte Traky para tu organización.\",\n};\n\nconst DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {\n kindOrder: DEFAULT_MENTION_KIND_ORDER,\n kindIcons: {\n guide: IconTruckLoading,\n package: IconPackage,\n ai: IconSparkles,\n },\n categoryMeta: {\n guide: {\n label: \"Guía\",\n hint: \"Guías de envío\",\n keywords: [\"guía\", \"guia\", \"guide\", \"envío\", \"envio\"],\n },\n package: {\n label: \"Paquete\",\n hint: \"Paquetes de la empresa (WR / tracking)\",\n keywords: [\"paquete\", \"paquetes\", \"package\", \"trk\", \"wr\"],\n },\n ai: {\n label: \"Asistencia de IA\",\n hint: \"Consultas sobre operación y datos (próximamente)\",\n keywords: [\"ia\", \"ai\", \"asistencia\", \"inteligencia\", \"asistente\"],\n disabled: true,\n },\n },\n};\n\nfunction isMentionEntryDisabled(\n entry: InternalChatMentionListEntry,\n config: InternalChatMentionConfig,\n): boolean {\n if (entry.type !== \"category\") return false;\n return Boolean(\n entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,\n );\n}\n\nfunction stepMentionHighlight(\n entries: InternalChatMentionListEntry[],\n config: InternalChatMentionConfig,\n current: number,\n delta: 1 | -1,\n): number {\n if (entries.length === 0) return 0;\n let next = current;\n for (let i = 0; i < entries.length; i++) {\n next = (next + delta + entries.length) % entries.length;\n if (!isMentionEntryDisabled(entries[next]!, config)) return next;\n }\n return current;\n}\n\nfunction formatUnreadBadge(count: number): string {\n if (count <= 0) return \"\";\n if (count > 9) return \"9+\";\n return String(count);\n}\n\n/** Contador en círculo perfecto (sin `min-w`/`px` que lo ovalan). */\nfunction unreadCountBadgeClasses(label: string): string {\n const wide = label.length > 1;\n return cn(\n \"pointer-events-none absolute flex shrink-0 items-center justify-center rounded-full bg-destructive font-bold tabular-nums leading-none text-destructive-foreground shadow-sm\",\n wide\n ? \"-right-1 -top-1 size-5 text-[0.5rem]\"\n : \"-right-0.5 -top-0.5 size-4 text-[0.625rem]\",\n );\n}\n\nfunction MessageReceiptTicks({\n status,\n}: {\n status: InternalChatMessageReceiptStatus;\n}) {\n if (status === \"pending\") {\n return (\n <IconLoader2\n className=\"size-3 shrink-0 animate-spin text-muted-foreground\"\n aria-hidden\n />\n );\n }\n if (status === \"sent\") {\n return (\n <IconCheck\n className=\"size-3.5 shrink-0 text-muted-foreground\"\n aria-hidden\n />\n );\n }\n const read = status === \"read\";\n return (\n <IconChecks\n className={cn(\n \"size-3.5 shrink-0\",\n read ? \"text-sky-500 dark:text-sky-400\" : \"text-muted-foreground\",\n )}\n aria-hidden\n />\n );\n}\n\nfunction formatAudioDuration(ms: number): string {\n const totalSec = Math.max(0, Math.round(ms / 1000));\n const m = Math.floor(totalSec / 60);\n const s = totalSec % 60;\n return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\nfunction pseudoWaveformBars(seed: string, count = 28): number[] {\n let h = 0;\n for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;\n return Array.from({ length: count }, (_, i) => {\n h = (h * 1103515245 + 12345 + i) >>> 0;\n return 0.25 + (h % 75) / 100;\n });\n}\n\nfunction InternalChatAudioBubble({\n audio,\n seed,\n mine,\n}: {\n audio: InternalChatMessageAudio;\n seed: string;\n mine: boolean;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [durationMs, setDurationMs] = useState(audio.durationMs ?? 0);\n const [progress, setProgress] = useState(0);\n\n const bars = useMemo(() => pseudoWaveformBars(seed), [seed]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setDurationMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [audio.src]);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n const displayMs = durationMs || audio.durationMs || 0;\n\n return (\n <div className=\"flex min-w-[min(100%,14rem)] items-center gap-2\">\n <audio ref={ref} src={audio.src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant={mine ? \"secondary\" : \"outline\"}\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar audio\" : \"Reproducir audio\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n <div className=\"flex h-7 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[3px] shrink-0 rounded-full transition-colors\",\n lit\n ? mine\n ? \"bg-foreground/70\"\n : \"bg-primary\"\n : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction InternalChatAttachmentStack({\n items,\n labels,\n}: {\n items: InternalChatMessageAttachment[];\n labels: InternalChatPanelLabels;\n}) {\n if (items.length === 0) return null;\n return (\n <div className=\"flex flex-col gap-1.5\">\n {items.map((att, i) => (\n <div\n key={`${att.name}-${i}`}\n className=\"overflow-hidden rounded-lg border border-border bg-muted/40\"\n >\n {att.kind === \"video\" && att.url ? (\n <video\n src={att.url}\n controls\n playsInline\n className=\"max-h-48 w-full bg-black object-contain\"\n />\n ) : att.kind === \"image\" && att.url ? (\n <img\n src={att.url}\n alt={att.name}\n className=\"max-h-40 w-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center gap-2 px-3 py-2 text-sm\">\n {att.kind === \"image\" ? (\n <IconPhoto className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : att.kind === \"video\" ? (\n <IconScreenShare className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : (\n <IconFile className=\"size-4 shrink-0 text-muted-foreground\" />\n )}\n <span className=\"min-w-0 truncate font-medium\">{att.name}</span>\n {att.sizeLabel ? (\n <span className=\"ml-auto shrink-0 text-xs text-muted-foreground\">\n {att.sizeLabel}\n </span>\n ) : null}\n </div>\n )}\n {!att.url ? (\n <p className=\"border-t border-border px-3 py-1 text-[0.65rem] text-muted-foreground\">\n {labels.attachmentPending}\n </p>\n ) : null}\n </div>\n ))}\n </div>\n );\n}\n\nfunction InternalChatVoiceRecorderBar({\n labels,\n onClose,\n onSendBlob,\n onSendLegacy,\n}: {\n labels: InternalChatPanelLabels;\n onClose: () => void;\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>;\n onSendLegacy?: () => void | Promise<void>;\n}) {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const [recording, setRecording] = useState(false);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [durationMs, setDurationMs] = useState(0);\n const [error, setError] = useState<string | null>(null);\n const [sending, setSending] = useState(false);\n\n const stopTracks = useCallback((recorder: MediaRecorder | null) => {\n recorder?.stream.getTracks().forEach((t) => t.stop());\n }, []);\n\n const startRecording = useCallback(async () => {\n setError(null);\n setBlob(null);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const mime = MediaRecorder.isTypeSupported(\"audio/webm\")\n ? \"audio/webm\"\n : \"audio/mp4\";\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n const next = new Blob(chunksRef.current, { type: mime });\n setBlob(next);\n const ms = Math.max(\n 0,\n Date.now() - (startedAtRef.current || Date.now()),\n );\n setDurationMs(ms);\n stopTracks(recorder);\n recorderRef.current = null;\n };\n startedAtRef.current = Date.now();\n recorder.start();\n setRecording(true);\n } catch {\n setError(labels.voiceRecordPermissionError);\n }\n }, [labels.voiceRecordPermissionError, stopTracks]);\n\n const stopRecording = useCallback(() => {\n const rec = recorderRef.current;\n if (!rec || rec.state === \"inactive\") return;\n rec.stop();\n setRecording(false);\n }, []);\n\n const handleClose = useCallback(() => {\n if (recording) stopRecording();\n stopTracks(recorderRef.current);\n recorderRef.current = null;\n onClose();\n }, [onClose, recording, stopRecording, stopTracks]);\n\n const handleSend = useCallback(async () => {\n if (sending) return;\n setSending(true);\n try {\n if (blob && onSendBlob) {\n await onSendBlob(blob, durationMs);\n } else if (onSendLegacy) {\n await onSendLegacy();\n }\n onClose();\n } finally {\n setSending(false);\n }\n }, [blob, durationMs, onClose, onSendBlob, onSendLegacy, sending]);\n\n const previewUrl = useMemo(\n () => (blob ? URL.createObjectURL(blob) : null),\n [blob],\n );\n\n useEffect(() => {\n return () => {\n if (previewUrl) URL.revokeObjectURL(previewUrl);\n };\n }, [previewUrl]);\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.voiceRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.voiceRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.voiceRecordCancel}\n onClick={handleClose}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? \"Pulsa el cuadrado rojo para terminar.\"\n : blob\n ? \"Escucha la vista previa y envía cuando quieras.\"\n : labels.voiceRecordHint}\n </p>\n )}\n <div className=\"mt-2 flex items-center gap-2\">\n {previewUrl && blob ? (\n <VoiceRecorderPreviewBar\n src={previewUrl}\n durationMs={durationMs}\n />\n ) : recording ? (\n <>\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 shrink-0 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.voiceRecordStop}\n </Button>\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando…\n </span>\n </>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={() => void startRecording()}\n >\n <IconMicrophone className=\"size-3.5\" />\n {labels.voiceRecordStart}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || (!blob && !onSendLegacy)}\n onClick={() => void handleSend()}\n >\n {labels.voiceRecordSend}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction VoiceRecorderPreviewBar({\n src,\n durationMs,\n}: {\n src: string;\n durationMs: number;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [progress, setProgress] = useState(0);\n const [resolvedMs, setResolvedMs] = useState(durationMs);\n const bars = useMemo(() => pseudoWaveformBars(\"voice-recorder-preview\"), []);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setResolvedMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [src]);\n\n const displayMs = resolvedMs || durationMs;\n\n return (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <audio ref={ref} src={src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar vista previa\" : \"Reproducir vista previa\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex h-6 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[2px] shrink-0 rounded-full transition-colors\",\n lit ? \"bg-primary\" : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction pickScreenRecorderMime(): string {\n const candidates = [\n \"video/webm;codecs=vp9\",\n \"video/webm;codecs=vp8\",\n \"video/webm\",\n \"video/mp4\",\n ];\n return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? \"video/webm\";\n}\n\ntype ScreenRecorderSession = {\n recording: boolean;\n elapsedMs: number;\n blob: Blob | null;\n error: string | null;\n maxReached: boolean;\n sending: boolean;\n previewUrl: string | null;\n canSend: boolean;\n timerLabel: string;\n active: boolean;\n startCapture: () => Promise<void>;\n stopRecording: () => void;\n cancelSession: () => void;\n sendRecording: () => Promise<void>;\n};\n\nfunction useInternalChatScreenRecorderSession(\n labels: InternalChatPanelLabels,\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>,\n onRecordingChange?: (recording: boolean) => void,\n): ScreenRecorderSession {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const streamRef = useRef<MediaStream | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const durationMsRef = useRef(0);\n const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);\n const maxTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const previewUrlRef = useRef<string | null>(null);\n\n const [recording, setRecording] = useState(false);\n const [elapsedMs, setElapsedMs] = useState(0);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [maxReached, setMaxReached] = useState(false);\n const [sending, setSending] = useState(false);\n const [previewUrl, setPreviewUrl] = useState<string | null>(null);\n\n const clearTimers = useCallback(() => {\n if (tickRef.current) {\n clearInterval(tickRef.current);\n tickRef.current = null;\n }\n if (maxTimerRef.current) {\n clearTimeout(maxTimerRef.current);\n maxTimerRef.current = null;\n }\n }, []);\n\n const stopStream = useCallback(() => {\n streamRef.current?.getTracks().forEach((t) => t.stop());\n streamRef.current = null;\n }, []);\n\n const revokePreview = useCallback(() => {\n if (previewUrlRef.current) {\n URL.revokeObjectURL(previewUrlRef.current);\n previewUrlRef.current = null;\n }\n setPreviewUrl(null);\n }, []);\n\n const stopRecording = useCallback(\n (reason?: \"max\") => {\n const rec = recorderRef.current;\n if (rec && rec.state !== \"inactive\") {\n rec.stop();\n } else {\n clearTimers();\n stopStream();\n setRecording(false);\n onRecordingChange?.(false);\n }\n if (reason === \"max\") setMaxReached(true);\n },\n [clearTimers, onRecordingChange, stopStream],\n );\n\n const startCapture = useCallback(async () => {\n setError(null);\n revokePreview();\n setBlob(null);\n setMaxReached(false);\n setElapsedMs(0);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getDisplayMedia({\n video: true,\n audio: false,\n preferCurrentTab: true,\n } as DisplayMediaStreamOptions);\n streamRef.current = stream;\n const videoTrack = stream.getVideoTracks()[0];\n videoTrack?.addEventListener(\"ended\", () => stopRecording());\n\n const mime = pickScreenRecorderMime();\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n clearTimers();\n const finalMs = Math.min(\n SCREEN_RECORD_MAX_MS,\n Math.max(0, Date.now() - (startedAtRef.current || Date.now())),\n );\n durationMsRef.current = finalMs;\n setElapsedMs(finalMs);\n const next = new Blob(chunksRef.current, {\n type: recorder.mimeType || mime,\n });\n setBlob(next);\n const url = URL.createObjectURL(next);\n previewUrlRef.current = url;\n setPreviewUrl(url);\n stopStream();\n recorderRef.current = null;\n setRecording(false);\n onRecordingChange?.(false);\n };\n startedAtRef.current = Date.now();\n recorder.start(500);\n setRecording(true);\n onRecordingChange?.(true);\n tickRef.current = setInterval(() => {\n const ms = Date.now() - startedAtRef.current;\n const clamped = Math.min(ms, SCREEN_RECORD_MAX_MS);\n setElapsedMs(clamped);\n if (ms >= SCREEN_RECORD_MAX_MS) stopRecording(\"max\");\n }, 200);\n maxTimerRef.current = setTimeout(\n () => stopRecording(\"max\"),\n SCREEN_RECORD_MAX_MS,\n );\n } catch {\n stopStream();\n setError(labels.screenRecordPermissionError);\n setRecording(false);\n onRecordingChange?.(false);\n }\n }, [\n clearTimers,\n labels.screenRecordPermissionError,\n onRecordingChange,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const cancelSession = useCallback(() => {\n if (recording) stopRecording();\n clearTimers();\n stopStream();\n recorderRef.current = null;\n revokePreview();\n setBlob(null);\n setError(null);\n setMaxReached(false);\n setElapsedMs(0);\n durationMsRef.current = 0;\n setRecording(false);\n onRecordingChange?.(false);\n }, [\n clearTimers,\n onRecordingChange,\n recording,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const sendRecording = useCallback(async () => {\n if (sending || !blob || !onSendBlob) return;\n setSending(true);\n try {\n await onSendBlob(blob, durationMsRef.current || elapsedMs);\n cancelSession();\n } finally {\n setSending(false);\n }\n }, [blob, cancelSession, elapsedMs, onSendBlob, sending]);\n\n useEffect(() => {\n return () => {\n clearTimers();\n if (recorderRef.current?.state === \"recording\") {\n recorderRef.current.stop();\n }\n stopStream();\n revokePreview();\n };\n }, [clearTimers, revokePreview, stopStream]);\n\n const timerLabel = `${formatAudioDuration(elapsedMs)} / ${formatAudioDuration(SCREEN_RECORD_MAX_MS)}`;\n const active = recording || Boolean(blob) || Boolean(error);\n\n return {\n recording,\n elapsedMs,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend: Boolean(blob) && Boolean(onSendBlob),\n timerLabel,\n active,\n startCapture,\n stopRecording: () => stopRecording(),\n cancelSession,\n sendRecording,\n };\n}\n\nfunction InternalChatScreenRecorderBar({\n labels,\n session,\n onDismiss,\n}: {\n labels: InternalChatPanelLabels;\n session: ScreenRecorderSession;\n onDismiss: () => void;\n}) {\n const {\n recording,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend,\n timerLabel,\n startCapture,\n stopRecording,\n cancelSession,\n sendRecording,\n } = session;\n\n const handleDismiss = () => {\n if (!recording && !blob) {\n cancelSession();\n }\n onDismiss();\n };\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.screenRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.screenRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={\n recording\n ? \"Ocultar panel (la grabación continúa)\"\n : labels.screenRecordCancel\n }\n onClick={handleDismiss}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? labels.screenRecordBackgroundHint\n : maxReached\n ? labels.screenRecordMaxReached\n : blob\n ? \"Revisa el video y envíalo al chat.\"\n : labels.screenRecordHint}\n </p>\n )}\n {recording ? (\n <p className=\"mt-1 text-xs font-medium tabular-nums text-destructive\">\n {timerLabel}\n </p>\n ) : null}\n <div className=\"mt-2 flex flex-col gap-2\">\n {previewUrl && blob ? (\n <video\n src={previewUrl}\n controls\n playsInline\n className=\"max-h-32 w-full rounded-md border border-border bg-black object-contain\"\n />\n ) : null}\n <div className=\"flex flex-wrap items-center gap-2\">\n {recording ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.screenRecordStop}\n </Button>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n disabled={Boolean(blob)}\n onClick={() => void startCapture()}\n >\n <IconScreenShare className=\"size-3.5\" />\n {labels.screenRecordStart}\n </Button>\n )}\n {recording ? (\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando pantalla\n </span>\n ) : null}\n {(recording || blob) && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 text-xs text-muted-foreground\"\n onClick={cancelSession}\n >\n {labels.screenRecordCancel}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || !canSend}\n onClick={() => void sendRecording()}\n >\n {labels.screenRecordSend}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\nfunction renderMessageTextWithMentions(text: string): ReactNode {\n const parts = text.split(/(@\\[[^\\]]+\\])/g);\n return parts.map((part, index) => {\n const mention = /^@\\[(.+)\\]$/.exec(part);\n if (mention) {\n return (\n <span\n key={index}\n className=\"inline-flex max-w-full items-center rounded-md border border-primary/35 bg-primary/12 px-1.5 py-0.5 font-medium text-primary\"\n >\n {mention[1]}\n </span>\n );\n }\n return <span key={index}>{part}</span>;\n });\n}\n\nexport type InternalChatPanelProps = {\n title: string;\n rooms: InternalChatRoom[];\n activeRoomId: string | null;\n onActiveRoomChange: (roomId: string) => void;\n messages: InternalChatMessageLine[];\n messagesStatus: InternalChatMessagesStatus;\n messagesError?: string | null;\n onLoadOlderMessages?: () => void | Promise<void>;\n isLoadingOlder?: boolean;\n hasOlderMessages?: boolean;\n mentionGuideItems?: InternalChatMentionItem[];\n mentionPackageItems?: InternalChatMentionItem[];\n mentionConfig?: InternalChatMentionConfig;\n /** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */\n mentionServerFilteredItems?: boolean;\n mentionCatalogLoading?: boolean;\n onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;\n typingLine?: string | null;\n onSendMessage: (text: string) => void | Promise<void>;\n onCreateRoom: (name: string) => void | Promise<void>;\n onDeleteRoom: (roomId: string) => void | Promise<void>;\n onOpenChange?: (open: boolean) => void;\n onDraftChange?: (draft: string) => void;\n onUserGesture?: () => void;\n isSending?: boolean;\n isCreatingRoom?: boolean;\n isDeletingRoom?: boolean;\n onDeleteRoomBlocked?: (reason: string) => void;\n labels?: Partial<InternalChatPanelLabels>;\n className?: string;\n /** Mensajes no vistos (icono del header: badge rojo, hasta «9+»). */\n unreadTotal?: number;\n /** No leídos por sala (badge en el rail de salas). */\n unreadByRoomId?: Record<string, number>;\n /** Se invoca al abrir el panel o al cambiar de sala con el chat visible. */\n onMarkRoomRead?: (roomId: string) => void;\n /** Conexión BFF: el contenedor sube el archivo. */\n onAttachPhotos?: (file: File) => void | Promise<void>;\n onAttachFiles?: (file: File) => void | Promise<void>;\n /** Tras grabar en el panel (conexión BFF / subida). */\n onSendAudioBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Atajo legacy: el host abre su propio flujo sin el grabador integrado. */\n onSendAudio?: () => void | Promise<void>;\n onAttachAudioFile?: (file: File) => void | Promise<void>;\n /** Video de captura de pantalla (máx. 1 min en el grabador integrado). */\n onSendScreenRecordingBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Abre el drawer al montar (p. ej. `/admin/.../chat`). */\n defaultOpen?: boolean;\n /** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */\n hideTrigger?: boolean;\n /**\n * Página dedicada (`defaultOpen` + `hideTrigger`): el drawer no es modal\n * y no bloquea clics en el resto del admin (sin overlay a pantalla completa).\n */\n embedded?: boolean;\n};\n\nfunction InternalChatPanel({\n title,\n rooms,\n activeRoomId,\n onActiveRoomChange,\n messages,\n messagesStatus,\n messagesError = null,\n onLoadOlderMessages,\n isLoadingOlder = false,\n hasOlderMessages = false,\n mentionGuideItems = [],\n mentionPackageItems = [],\n mentionConfig = DEFAULT_MENTION_CONFIG,\n mentionServerFilteredItems = false,\n mentionCatalogLoading = false,\n onMentionCatalogChange,\n typingLine = null,\n onSendMessage,\n onCreateRoom,\n onDeleteRoom,\n onOpenChange,\n onDraftChange,\n onUserGesture,\n isSending = false,\n isCreatingRoom = false,\n isDeletingRoom = false,\n onDeleteRoomBlocked,\n labels: labelsPartial,\n className,\n unreadTotal = 0,\n unreadByRoomId,\n onMarkRoomRead,\n onAttachPhotos,\n onAttachFiles,\n onSendAudioBlob,\n onSendAudio,\n onSendScreenRecordingBlob,\n defaultOpen = false,\n hideTrigger = false,\n embedded = false,\n}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(defaultOpen);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [voiceRecorderOpen, setVoiceRecorderOpen] = useState(false);\n const [screenRecorderOpen, setScreenRecorderOpen] = useState(false);\n const screenRecorderSession = useInternalChatScreenRecorderSession(\n labels,\n onSendScreenRecordingBlob,\n useCallback((isRecording: boolean) => {\n if (isRecording) setScreenRecorderOpen(true);\n }, []),\n );\n const [mentionStart, setMentionStart] = useState<number | null>(null);\n const [mentionQuery, setMentionQuery] = useState(\"\");\n const [mentionHighlight, setMentionHighlight] = useState(0);\n const lastMentionQueryRef = useRef(\"\");\n const loadingOlderMessagesRef = useRef(false);\n\n const formRef = useRef<HTMLFormElement>(null);\n const scrollerRef = useRef<HTMLDivElement>(null);\n const messagesListRef = useRef<HTMLDivElement>(null);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const photoInputRef = useRef<HTMLInputElement>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n const supportRoom = useMemo(\n () => rooms.find((r) => r.isSupport) ?? null,\n [rooms],\n );\n\n const teamRooms = useMemo(\n () => rooms.filter((r) => !r.isSupport),\n [rooms],\n );\n\n const activeRoom = useMemo(\n () => rooms.find((r) => r.id === activeRoomId) ?? null,\n [rooms, activeRoomId],\n );\n\n const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {\n if (!mentionServerFilteredItems) return undefined;\n const seg = parseMentionSegment(mentionQuery, mentionConfig);\n if (seg.phase !== \"item\") return undefined;\n return { serverFilteredItems: true };\n }, [mentionServerFilteredItems, mentionQuery, mentionConfig]);\n\n const mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ),\n [\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ],\n );\n\n const mentionSeg = useMemo(\n () => parseMentionSegment(mentionQuery, mentionConfig),\n [mentionQuery, mentionConfig],\n );\n\n const mentionOpen =\n Boolean(activeRoom) &&\n mentionStart !== null &&\n (mentionEntries.length > 0 ||\n mentionCatalogLoading ||\n (mentionServerFilteredItems && mentionSeg.phase === \"item\"));\n\n const canSend =\n draft.trim().length > 0 &&\n !isSending &&\n Boolean(activeRoomId);\n\n const messagesScrollTail = useMemo(() => {\n if (messages.length === 0) {\n return `empty:${messagesStatus}`;\n }\n const last = messages[messages.length - 1]!;\n const attachCount = last.attachments?.length ?? 0;\n const hasAudio = last.audio ? 1 : 0;\n return [\n messages.length,\n last.id,\n last.text.length,\n attachCount,\n hasAudio,\n last.receiptStatus ?? \"\",\n messagesStatus,\n ].join(\"|\");\n }, [messages, messagesStatus]);\n\n const scrollMessagesToBottom = useCallback(() => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n }, []);\n\n const scheduleScrollMessagesToBottom = useCallback(() => {\n scrollMessagesToBottom();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollMessagesToBottom();\n rafNested = requestAnimationFrame(scrollMessagesToBottom);\n });\n const timeouts = [50, 150, 320].map((ms) =>\n window.setTimeout(scrollMessagesToBottom, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [scrollMessagesToBottom]);\n\n const setOpenWithNotify = useCallback(\n (next: boolean) => {\n if (next) onUserGesture?.();\n setOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, onUserGesture],\n );\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n onMarkRoomRead?.(activeRoomId);\n }, [open, activeRoomId, onMarkRoomRead]);\n\n useEffect(() => {\n if (!open) return;\n if (screenRecorderSession.active) {\n setScreenRecorderOpen(true);\n }\n }, [open, screenRecorderSession.active]);\n\n const handleMessagesScroll = useCallback(() => {\n const node = scrollerRef.current;\n if (!node || !activeRoomId || !onLoadOlderMessages) return;\n if (loadingOlderMessagesRef.current) return;\n if (node.scrollTop > 120) return;\n if (!hasOlderMessages || isLoadingOlder) return;\n loadingOlderMessagesRef.current = true;\n const prevHeight = node.scrollHeight;\n const prevTop = node.scrollTop;\n void Promise.resolve(onLoadOlderMessages()).finally(() => {\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n loadingOlderMessagesRef.current = false;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open || !activeRoomId) return;\n if (loadingOlderMessagesRef.current) return;\n return scheduleScrollMessagesToBottom();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n typingLine,\n voiceRecorderOpen,\n screenRecorderOpen,\n scheduleScrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n const scroller = scrollerRef.current;\n const list = messagesListRef.current;\n const textarea = textareaRef.current;\n if (!scroller) return;\n\n const pinBottom = () => {\n if (loadingOlderMessagesRef.current) return;\n scrollMessagesToBottom();\n };\n\n const ro = new ResizeObserver(pinBottom);\n ro.observe(scroller);\n if (list) ro.observe(list);\n if (textarea) ro.observe(textarea);\n const form = formRef.current;\n if (form) ro.observe(form);\n\n return () => ro.disconnect();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n scrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!mentionOpen || mentionEntries.length === 0) return;\n setMentionHighlight((h) => Math.min(h, mentionEntries.length - 1));\n }, [mentionOpen, mentionEntries.length]);\n\n useEffect(() => {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }, [activeRoomId]);\n\n useEffect(() => {\n if (!onMentionCatalogChange) return;\n if (mentionStart === null) {\n onMentionCatalogChange({\n open: false,\n phase: \"category\",\n filter: \"\",\n });\n return;\n }\n if (mentionSeg.phase === \"category\") {\n onMentionCatalogChange({\n open: true,\n phase: \"category\",\n filter: mentionSeg.filter,\n });\n return;\n }\n onMentionCatalogChange({\n open: true,\n phase: \"item\",\n kind: mentionSeg.kind,\n filter: mentionSeg.filter,\n });\n }, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);\n\n function closeMentionMenu() {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }\n\n function syncMentionMenu(nextDraft: string, cursor: number) {\n const m = parseSlashMention(nextDraft, cursor);\n if (!m) {\n closeMentionMenu();\n return;\n }\n const seg = parseMentionSegment(m.query, mentionConfig);\n const listOptions: BuildMentionListOptions | undefined =\n mentionServerFilteredItems && seg.phase === \"item\"\n ? { serverFilteredItems: true }\n : undefined;\n const entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n listOptions,\n );\n const keepOpenOnEmpty =\n mentionCatalogLoading ||\n (mentionServerFilteredItems && seg.phase === \"item\");\n if (entries.length === 0 && !keepOpenOnEmpty) {\n closeMentionMenu();\n return;\n }\n if (m.query !== lastMentionQueryRef.current) {\n setMentionHighlight(0);\n lastMentionQueryRef.current = m.query;\n }\n setMentionStart(m.start);\n setMentionQuery(m.query);\n }\n\n function replaceSlashSegment(newSegment: string) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const newDraft = `${before}/${newSegment}${after}`;\n const slashEnd = before.length + 1 + newSegment.length;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n setMentionQuery(newSegment);\n lastMentionQueryRef.current = newSegment;\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(slashEnd, slashEnd);\n });\n }\n\n function applyCategoryPick(kind: InternalChatMentionKind) {\n if (mentionConfig.categoryMeta[kind]?.disabled) return;\n replaceSlashSegment(`${kind}:`);\n setMentionHighlight(0);\n }\n\n function applyItemPick(item: InternalChatMentionItem) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const token = `@[${item.insertLabel}]`;\n const needsSpace =\n after.length > 0 && !/^\\s/.test(after) && !after.startsWith(\"\\n\");\n const newDraft = `${before}${token}${needsSpace ? \" \" : \"\"}${after}`;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n closeMentionMenu();\n const newPos = before.length + token.length + (needsSpace ? 1 : 0);\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(newPos, newPos);\n });\n }\n\n async function onSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (mentionOpen) return;\n const text = draft.trim();\n if (!text || !activeRoomId) return;\n onUserGesture?.();\n try {\n await onSendMessage(text);\n setDraft(\"\");\n onDraftChange?.(\"\");\n scheduleScrollMessagesToBottom();\n } catch {\n /* errores vía contenedor */\n }\n }\n\n async function handleCreateRoom() {\n const name = newRoomName.trim();\n if (!name) return;\n try {\n await onCreateRoom(name);\n setNewRoomName(\"\");\n setCreateOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n async function handleConfirmDeleteRoom() {\n if (!activeRoom) return;\n try {\n await onDeleteRoom(activeRoom.id);\n setDeleteOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n function requestDeleteRoom() {\n if (!activeRoom) return;\n if (activeRoom.isOrganization) {\n onDeleteRoomBlocked?.(\"La sala Organización no se puede eliminar.\");\n return;\n }\n if (activeRoom.isSupport) {\n onDeleteRoomBlocked?.(\"La sala de soporte no se puede eliminar.\");\n return;\n }\n if (teamRooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n {!hideTrigger ? (\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={\n unreadTotal > 0\n ? `${labels.openChat} · ${unreadTotal} sin leer`\n : labels.openChat\n }\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {unreadTotal > 0 ? (\n <span\n className={unreadCountBadgeClasses(formatUnreadBadge(unreadTotal))}\n aria-hidden\n >\n {formatUnreadBadge(unreadTotal)}\n </span>\n ) : null}\n {screenRecorderSession.recording ? (\n <span\n className=\"absolute -top-0.5 -left-0.5 size-2.5 animate-pulse rounded-full bg-destructive ring-2 ring-background\"\n aria-hidden\n />\n ) : null}\n </Button>\n ) : !open ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"gap-2\"\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {labels.openChat}\n </Button>\n ) : null}\n\n {!open && screenRecorderSession.active ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"fixed bottom-6 right-6 z-[200] h-9 gap-2 shadow-lg\"\n onClick={() => setOpenWithNotify(true)}\n >\n <span className=\"size-2 animate-pulse rounded-full bg-destructive-foreground\" />\n <span className=\"text-xs font-medium tabular-nums\">\n {screenRecorderSession.recording\n ? `REC ${screenRecorderSession.timerLabel}`\n : screenRecorderSession.blob\n ? labels.screenRecordOpenChatToFinish\n : labels.screenRecordTitle}\n </span>\n </Button>\n ) : null}\n\n <Dialog open={createOpen} onOpenChange={setCreateOpen}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <DialogTitle>{labels.newRoomTitle}</DialogTitle>\n <DialogDescription>{labels.newRoomDescription}</DialogDescription>\n </DialogHeader>\n <Input\n value={newRoomName}\n onChange={(e) => setNewRoomName(e.target.value)}\n placeholder={labels.newRoomPlaceholder}\n autoFocus\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n void handleCreateRoom();\n }\n }}\n />\n <DialogFooter className=\"gap-2 sm:gap-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => {\n setCreateOpen(false);\n setNewRoomName(\"\");\n }}\n >\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleCreateRoom()}\n disabled={isCreatingRoom}\n >\n {labels.create}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{labels.deleteRoomTitle}</AlertDialogTitle>\n <AlertDialogDescription>\n {labels.deleteRoomDescription(activeRoom?.name ?? \"la sala\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{labels.cancel}</AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n onClick={() => void handleConfirmDeleteRoom()}\n disabled={isDeletingRoom}\n >\n {labels.deleteConfirm}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <Drawer\n open={open}\n onOpenChange={setOpenWithNotify}\n direction=\"right\"\n shouldScaleBackground={false}\n modal={!embedded}\n >\n <DrawerContent\n className={cn(\n \"gap-0 overflow-hidden p-0 shadow-xl\",\n \"data-[vaul-drawer-direction=right]:inset-y-3\",\n \"data-[vaul-drawer-direction=right]:right-3\",\n \"data-[vaul-drawer-direction=right]:left-auto\",\n \"data-[vaul-drawer-direction=right]:h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:max-h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:sm:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:rounded-2xl\",\n \"data-[vaul-drawer-direction=right]:border\",\n )}\n >\n <div className=\"flex h-full min-h-0 flex-row\">\n <div className=\"flex w-[3.25rem] shrink-0 flex-col items-center gap-2 border-r border-border py-4\">\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.newRoom}\n onClick={() => setCreateOpen(true)}\n >\n <IconPlus className=\"size-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">{labels.newRoom}</TooltipContent>\n </Tooltip>\n\n <div className=\"flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1\">\n {teamRooms.map((room) => {\n const selected = room.id === activeRoomId;\n return (\n <Tooltip key={room.id} delayDuration={300}>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={() => onActiveRoomChange(room.id)}\n className={cn(\n \"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-colors\",\n selected\n ? \"border-primary bg-primary/15\"\n : \"border-transparent bg-muted hover:bg-muted/80\",\n )}\n aria-label={`Sala ${room.name}${room.isOrganization ? \" (toda la organización)\" : \"\"}`}\n aria-current={selected ? \"true\" : undefined}\n >\n <span className=\"text-xs font-semibold leading-none\">\n {roomInitialLetter(room.name)}\n </span>\n {(unreadByRoomId?.[room.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(unreadByRoomId![room.id]!),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![room.id]!)}\n </span>\n ) : null}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {room.isOrganization\n ? `${room.name}${labels.orgRoomTooltip}`\n : room.name}\n </TooltipContent>\n </Tooltip>\n );\n })}\n </div>\n\n {supportRoom ? (\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\n \"relative size-9 shrink-0 rounded-full border shadow-sm\",\n activeRoomId === supportRoom.id\n ? \"border-sky-600 bg-sky-600 text-white hover:bg-sky-600/90 hover:text-white dark:border-sky-500 dark:bg-sky-500\"\n : \"border-sky-500/70 bg-sky-500/20 text-sky-800 hover:bg-sky-500/30 dark:border-sky-400/60 dark:bg-sky-500/25 dark:text-sky-100\",\n )}\n aria-label={labels.supportChatTooltip}\n aria-current={\n activeRoomId === supportRoom.id ? \"true\" : undefined\n }\n onClick={() => onActiveRoomChange(supportRoom.id)}\n >\n <IconHeadset className=\"size-4 stroke-[2.25]\" />\n {(unreadByRoomId?.[supportRoom.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(\n unreadByRoomId![supportRoom.id]!,\n ),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![supportRoom.id]!)}\n </span>\n ) : null}\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {labels.supportChatTooltip}\n </TooltipContent>\n </Tooltip>\n ) : null}\n\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.roomMenu}\n >\n <IconDotsVertical className=\"size-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"left\" align=\"end\" className=\"w-48\">\n <DropdownMenuItem onClick={() => setCreateOpen(true)}>\n <IconPlus className=\"size-4\" />\n {labels.newRoom}\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem\n disabled={\n teamRooms.length <= 1 ||\n activeRoom?.isOrganization ||\n activeRoom?.isSupport\n }\n className=\"text-destructive focus:text-destructive\"\n onClick={requestDeleteRoom}\n >\n <IconTrash className=\"size-4\" />\n {labels.deleteRoom}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <div className=\"flex items-start justify-between gap-4 px-5 pt-5 pb-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <DrawerTitle className=\"flex items-center gap-2 font-medium\">\n <IconMessages className=\"size-5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{title}</span>\n </DrawerTitle>\n <DrawerDescription className=\"line-clamp-2\">\n {activeRoom ? (\n activeRoom.isSupport ? (\n <span>{labels.supportRoomDescription}</span>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {activeRoom.name}\n </span>\n {activeRoom.isOrganization ? (\n <>{labels.orgRoomSuffix}</>\n ) : null}\n {\" · \"}\n <kbd className=\"pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]\">\n /\n </kbd>{\" \"}\n {labels.slashMentionHint}\n </>\n )\n ) : (\n labels.selectRoomHint\n )}\n </DrawerDescription>\n </div>\n <DrawerClose asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.closeChat}\n >\n <IconX className=\"size-4\" />\n </Button>\n </DrawerClose>\n </div>\n\n <div\n ref={scrollerRef}\n className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n onScroll={handleMessagesScroll}\n >\n {!activeRoom ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.noRoomTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.noRoomDescription}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"loading\" ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-2 text-muted-foreground\">\n <IconLoader2 className=\"size-6 animate-spin\" aria-hidden />\n <span className=\"text-sm\">{labels.loadingMessages}</span>\n </div>\n ) : messagesStatus === \"error\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>\n <EmptyDescription className=\"text-left\">\n {messagesError ?? labels.loadMessagesErrorFallback}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"empty\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.emptyThreadTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.emptyThreadDescription(activeRoom.name)}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : (\n <div\n ref={messagesListRef}\n className=\"flex flex-col gap-4 px-5 pt-2 pb-6\"\n >\n {isLoadingOlder ? (\n <div className=\"flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground\">\n <IconLoader2\n className=\"size-3.5 shrink-0 animate-spin\"\n aria-hidden\n />\n {labels.loadingOlderMessages}\n </div>\n ) : null}\n {messages.map((message) => {\n const mine = message.role === \"me\";\n return (\n <Message\n key={message.id}\n align={mine ? \"end\" : \"start\"}\n >\n {mine ? null : (\n <MessageAvatar className=\"size-8 text-xs font-medium\">\n {message.initials}\n </MessageAvatar>\n )}\n <MessageContent className=\"gap-0.5\">\n {mine ? null : (\n <MessageHeader className=\"px-3 pb-0 leading-snug\">\n {message.author}\n </MessageHeader>\n )}\n {message.attachments?.length ? (\n <InternalChatAttachmentStack\n items={message.attachments}\n labels={labels}\n />\n ) : null}\n {message.audio ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"py-2\">\n <InternalChatAudioBubble\n audio={message.audio}\n seed={message.id}\n mine={mine}\n />\n </BubbleContent>\n </Bubble>\n ) : null}\n {message.text.trim() ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"break-words whitespace-pre-wrap\">\n {renderMessageTextWithMentions(message.text)}\n </BubbleContent>\n </Bubble>\n ) : null}\n <MessageFooter className=\"flex items-center justify-end gap-1 px-3 pt-0 leading-none\">\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {formatMessageTime(message.at)}\n </span>\n {mine && message.receiptStatus ? (\n <span\n className=\"inline-flex shrink-0\"\n title={\n message.receiptStatus === \"pending\"\n ? \"Enviando\"\n : message.receiptStatus === \"sent\"\n ? \"Enviado\"\n : message.receiptStatus === \"delivered\"\n ? \"Entregado\"\n : \"Leído\"\n }\n >\n <MessageReceiptTicks\n status={message.receiptStatus}\n />\n </span>\n ) : null}\n </MessageFooter>\n </MessageContent>\n </Message>\n );\n })}\n </div>\n )}\n {activeRoom && typingLine ? (\n <div\n className=\"border-t border-border px-5 py-2 text-xs text-muted-foreground\"\n aria-live=\"polite\"\n >\n {typingLine}\n </div>\n ) : null}\n </div>\n\n {voiceRecorderOpen && activeRoom ? (\n <InternalChatVoiceRecorderBar\n labels={labels}\n onClose={() => setVoiceRecorderOpen(false)}\n onSendBlob={onSendAudioBlob}\n onSendLegacy={onSendAudio}\n />\n ) : null}\n\n {activeRoom && screenRecorderOpen ? (\n <InternalChatScreenRecorderBar\n labels={labels}\n session={screenRecorderSession}\n onDismiss={() => setScreenRecorderOpen(false)}\n />\n ) : null}\n\n <form\n ref={formRef}\n onSubmit={onSubmit}\n className=\"relative px-5 pt-2 pb-5\"\n >\n {mentionOpen ? (\n <div\n role=\"listbox\"\n aria-label={labels.mentionListLabel}\n className=\"absolute bottom-full left-5 right-5 z-50 mb-1 flex max-h-60 flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md\"\n >\n <div className=\"shrink-0 border-b border-border px-2 py-1.5 text-[0.7rem] text-muted-foreground\">\n {mentionSeg.phase === \"category\" ? (\n <>\n <span className=\"font-medium text-foreground\">\n Tipo\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n Tab\n </kbd>\n /\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ↵\n </kbd>\n </>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {DEFAULT_MENTION_KIND_LABEL[mentionSeg.kind]}\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ←\n </kbd>\n </>\n )}\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto p-1\">\n {mentionCatalogLoading && mentionEntries.length === 0 ? (\n <div className=\"flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground\">\n <IconLoader2 className=\"size-4 animate-spin\" />\n Buscando…\n </div>\n ) : null}\n {!mentionCatalogLoading &&\n mentionEntries.length === 0 &&\n mentionSeg.phase === \"item\" ? (\n <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n Sin resultados\n </p>\n ) : null}\n {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n const categoryDisabled = isMentionEntryDisabled(\n entry,\n mentionConfig,\n );\n return (\n <button\n key={`cat-${entry.kind}`}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n aria-disabled={categoryDisabled}\n disabled={categoryDisabled}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n categoryDisabled &&\n \"cursor-not-allowed opacity-50\",\n !categoryDisabled &&\n (index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\"),\n )}\n onMouseEnter={() => {\n if (!categoryDisabled) setMentionHighlight(index);\n }}\n onMouseDown={(event) => {\n event.preventDefault();\n if (categoryDisabled) return;\n applyCategoryPick(entry.kind);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {entry.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {entry.hint}\n </span>\n </span>\n </button>\n );\n }\n const item = entry.data;\n const IconComp = item.icon;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\",\n )}\n onMouseEnter={() => setMentionHighlight(index)}\n onMouseDown={(event) => {\n event.preventDefault();\n applyItemPick(item);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {item.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {item.subtitle}\n </span>\n </span>\n </button>\n );\n })}\n </div>\n </div>\n ) : null}\n\n <InputGroup>\n <InputGroupTextarea\n ref={textareaRef}\n value={draft}\n onChange={(event) => {\n const v = event.target.value;\n const cursor =\n event.target.selectionStart ?? v.length;\n setDraft(v);\n onDraftChange?.(v);\n syncMentionMenu(v, cursor);\n }}\n onSelect={(event) => {\n const t = event.target as HTMLTextAreaElement;\n syncMentionMenu(\n t.value,\n t.selectionStart ?? t.value.length,\n );\n }}\n placeholder={\n activeRoom\n ? labels.messagePlaceholder\n : labels.messagePlaceholderNoRoom\n }\n aria-label={labels.messageAriaLabel}\n className=\"min-h-14\"\n disabled={!activeRoom}\n onKeyDown={(event) => {\n onUserGesture?.();\n if (mentionOpen) {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n 1,\n ),\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n -1,\n ),\n );\n return;\n }\n if (event.key === \"Backspace\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"ArrowLeft\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Tab\") {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n closeMentionMenu();\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n formRef.current?.requestSubmit();\n }\n }}\n />\n <InputGroupAddon align=\"block-end\" className=\"pt-1\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <InputGroupButton\n aria-label={labels.attachAriaLabel}\n type=\"button\"\n size=\"icon-sm\"\n variant=\"outline\"\n disabled={!activeRoom}\n >\n <IconPlus className=\"size-4\" />\n </InputGroupButton>\n </DropdownMenuTrigger>\n <DropdownMenuContent\n align=\"start\"\n side=\"top\"\n className=\"w-48\"\n >\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n photoInputRef.current?.click();\n }}\n >\n <IconPhoto className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n fileInputRef.current?.click();\n }}\n >\n <IconPaperclip className=\"size-4\" />\n {labels.attachFiles}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"button\"\n variant={\n screenRecorderOpen || screenRecorderSession.recording\n ? \"default\"\n : \"outline\"\n }\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.screenCaptureAriaLabel}\n aria-pressed={\n screenRecorderOpen || screenRecorderSession.recording\n }\n className={cn(\n !screenRecorderOpen &&\n !screenRecorderSession.recording &&\n \"text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setVoiceRecorderOpen(false);\n setScreenRecorderOpen((prev) => !prev);\n }}\n >\n <IconScreenShare className=\"size-4\" />\n </InputGroupButton>\n <input\n ref={photoInputRef}\n type=\"file\"\n accept=\"image/*\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachPhotos) void onAttachPhotos(file);\n e.target.value = \"\";\n }}\n />\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachFiles) void onAttachFiles(file);\n e.target.value = \"\";\n }}\n />\n <div className=\"ml-auto flex shrink-0 items-center gap-1\">\n <InputGroupButton\n type=\"button\"\n variant={voiceRecorderOpen ? \"default\" : \"outline\"}\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.attachAudio}\n aria-pressed={voiceRecorderOpen}\n className={cn(\n !voiceRecorderOpen &&\n \"text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setScreenRecorderOpen(false);\n setVoiceRecorderOpen((open) => !open);\n }}\n >\n <IconMicrophone className=\"size-4\" />\n </InputGroupButton>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\n </div>\n </InputGroupAddon>\n </InputGroup>\n </form>\n </div>\n </div>\n </DrawerContent>\n </Drawer>\n </>\n );\n}\n\nexport {\n InternalChatPanel,\n DEFAULT_MENTION_CONFIG,\n DEFAULT_LABELS as DEFAULT_INTERNAL_CHAT_PANEL_LABELS,\n};\n\nexport { initialsFromDisplayName } from \"@/components/ui/internal-chat-mention\";\n\nexport type {\n BuildMentionListOptions,\n InternalChatMentionCatalogState,\n InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
27
27
  "type": "registry:ui",
28
28
  "target": ""
29
29
  }
@@ -1278,6 +1278,11 @@ export type InternalChatPanelProps = {
1278
1278
  defaultOpen?: boolean;
1279
1279
  /** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */
1280
1280
  hideTrigger?: boolean;
1281
+ /**
1282
+ * Página dedicada (`defaultOpen` + `hideTrigger`): el drawer no es modal
1283
+ * y no bloquea clics en el resto del admin (sin overlay a pantalla completa).
1284
+ */
1285
+ embedded?: boolean;
1281
1286
  };
1282
1287
 
1283
1288
  function InternalChatPanel({
@@ -1320,6 +1325,7 @@ function InternalChatPanel({
1320
1325
  onSendScreenRecordingBlob,
1321
1326
  defaultOpen = false,
1322
1327
  hideTrigger = false,
1328
+ embedded = false,
1323
1329
  }: InternalChatPanelProps) {
1324
1330
  const labels = { ...DEFAULT_LABELS, ...labelsPartial };
1325
1331
 
@@ -1503,7 +1509,6 @@ function InternalChatPanel({
1503
1509
  activeRoomId,
1504
1510
  messagesScrollTail,
1505
1511
  typingLine,
1506
- draft,
1507
1512
  voiceRecorderOpen,
1508
1513
  screenRecorderOpen,
1509
1514
  scheduleScrollMessagesToBottom,
@@ -1839,6 +1844,7 @@ function InternalChatPanel({
1839
1844
  onOpenChange={setOpenWithNotify}
1840
1845
  direction="right"
1841
1846
  shouldScaleBackground={false}
1847
+ modal={!embedded}
1842
1848
  >
1843
1849
  <DrawerContent
1844
1850
  className={cn(
@@ -1278,6 +1278,11 @@ export type InternalChatPanelProps = {
1278
1278
  defaultOpen?: boolean;
1279
1279
  /** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */
1280
1280
  hideTrigger?: boolean;
1281
+ /**
1282
+ * Página dedicada (`defaultOpen` + `hideTrigger`): el drawer no es modal
1283
+ * y no bloquea clics en el resto del admin (sin overlay a pantalla completa).
1284
+ */
1285
+ embedded?: boolean;
1281
1286
  };
1282
1287
 
1283
1288
  function InternalChatPanel({
@@ -1320,6 +1325,7 @@ function InternalChatPanel({
1320
1325
  onSendScreenRecordingBlob,
1321
1326
  defaultOpen = false,
1322
1327
  hideTrigger = false,
1328
+ embedded = false,
1323
1329
  }: InternalChatPanelProps) {
1324
1330
  const labels = { ...DEFAULT_LABELS, ...labelsPartial };
1325
1331
 
@@ -1503,7 +1509,6 @@ function InternalChatPanel({
1503
1509
  activeRoomId,
1504
1510
  messagesScrollTail,
1505
1511
  typingLine,
1506
- draft,
1507
1512
  voiceRecorderOpen,
1508
1513
  screenRecorderOpen,
1509
1514
  scheduleScrollMessagesToBottom,
@@ -1839,6 +1844,7 @@ function InternalChatPanel({
1839
1844
  onOpenChange={setOpenWithNotify}
1840
1845
  direction="right"
1841
1846
  shouldScaleBackground={false}
1847
+ modal={!embedded}
1842
1848
  >
1843
1849
  <DrawerContent
1844
1850
  className={cn(