@traky-app/ui 1.6.1 → 1.6.3
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 +1 -1
- package/registry/traky/json/internal-chat-mention.json +1 -1
- package/registry/traky/json/internal-chat-panel.json +1 -1
- package/registry/traky/ui/internal-chat-mention.ts +34 -3
- package/registry/traky/ui/internal-chat-panel.tsx +147 -12
- package/src/ui/internal-chat-mention.ts +34 -3
- package/src/ui/internal-chat-panel.tsx +147 -12
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "ui/internal-chat-mention.ts",
|
|
12
|
-
"content": "import type { Icon } from \"@tabler/icons-react\";\n\nexport type InternalChatMentionKind = \"guide\" | \"package\";\n\nexport type InternalChatMentionItem = {\n id: string;\n kind: InternalChatMentionKind;\n label: string;\n subtitle: string;\n insertLabel: string;\n icon: Icon;\n};\n\nexport type InternalChatMentionCategoryMeta = {\n label: string;\n hint: string;\n keywords: string[];\n};\n\nexport type InternalChatMentionListEntry =\n | {\n type: \"category\";\n kind: InternalChatMentionKind;\n label: string;\n hint: string;\n icon: Icon;\n }\n | { type: \"item\"; data: InternalChatMentionItem };\n\nexport const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [\n \"guide\",\n \"package\",\n];\n\nexport const DEFAULT_MENTION_KIND_LABEL: Record<\n InternalChatMentionKind,\n string\n> = {\n guide: \"Guías\",\n package: \"Paquetes\",\n};\n\nexport type InternalChatMentionConfig = {\n kindOrder: InternalChatMentionKind[];\n categoryMeta: Record<\n InternalChatMentionKind,\n InternalChatMentionCategoryMeta\n >;\n kindIcons: Record<InternalChatMentionKind, Icon>;\n kindAliases?: Record<string, InternalChatMentionKind>;\n};\n\nfunction resolveKindAlias(\n raw: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionKind | null {\n const s = raw.trim().toLowerCase();\n if (!s) return null;\n\n const aliases: Record<string, InternalChatMentionKind> = {\n guide: \"guide\",\n guía: \"guide\",\n guia: \"guide\",\n paquete: \"package\",\n paquetes: \"package\",\n package: \"package\",\n ...config.kindAliases,\n };\n\n const direct = aliases[s];\n if (direct) return direct;\n\n for (const kind of config.kindOrder) {\n const meta = config.categoryMeta[kind];\n if (kind === s) return kind;\n if (meta.label.toLowerCase() === s) return kind;\n if (meta.keywords.some((k) => k === s)) return kind;\n }\n\n return null;\n}\n\nexport function parseMentionSegment(\n query: string,\n config: InternalChatMentionConfig,\n):\n | { phase: \"category\"; filter: string }\n | { phase: \"item\"; kind: InternalChatMentionKind; filter: string } {\n const colon = query.indexOf(\":\");\n if (colon === -1) {\n return { phase: \"category\", filter: query };\n }\n const head = query.slice(0, colon).trim().toLowerCase();\n const tail = query.slice(colon + 1);\n if (head === \"\") {\n return { phase: \"category\", filter: query.replace(\":\", \"\") };\n }\n const kind = resolveKindAlias(head, config);\n if (!kind) {\n return { phase: \"category\", filter: query };\n }\n return { phase: \"item\", kind, filter: tail };\n}\n\nexport function parseSlashMention(\n draft: string,\n cursor: number,\n): { start: number; query: string } | null {\n const before = draft.slice(0, cursor);\n const lastSlash = before.lastIndexOf(\"/\");\n if (lastSlash === -1) return null;\n const charBefore = lastSlash === 0 ? \" \" : draft[lastSlash - 1];\n if (charBefore !== \" \" && charBefore !== \"\\n\" && lastSlash !== 0) {\n return null;\n }\n const afterSlash = before.slice(lastSlash + 1);\n if (afterSlash.includes(\"\\n\")) return null;\n return { start: lastSlash, query: afterSlash };\n}\n\nfunction filterCategoryEntries(\n filterText: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n const rows: InternalChatMentionListEntry[] = config.kindOrder.map((kind) => ({\n type: \"category\",\n kind,\n label: config.categoryMeta[kind].label,\n hint: config.categoryMeta[kind].hint,\n icon: config.kindIcons[kind],\n }));\n if (!q) return rows;\n const byAlias = resolveKindAlias(q, config);\n return rows.filter((row) => {\n if (row.type !== \"category\") return false;\n if (byAlias && row.kind === byAlias) return true;\n return (\n row.label.toLowerCase().includes(q) ||\n row.hint.toLowerCase().includes(q) ||\n config.categoryMeta[row.kind].keywords.some(\n (k) => k.includes(q) || q.includes(k),\n )\n );\n });\n}\n\nfunction filterItemsByKind(\n kind: InternalChatMentionKind,\n filterText: string,\n catalog: InternalChatMentionItem[],\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n let items = catalog.filter((i) => i.kind === kind);\n if (q) {\n items = items.filter(\n (item) =>\n item.label.toLowerCase().includes(q) ||\n item.subtitle.toLowerCase().includes(q) ||\n item.insertLabel.toLowerCase().includes(q),\n );\n }\n return items.map((data) => ({ type: \"item\", data }));\n}\n\nexport function buildMentionList(\n query: string,\n guideItems: InternalChatMentionItem[],\n packageItems: InternalChatMentionItem[],\n config: InternalChatMentionConfig,\n): InternalChatMentionListEntry[] {\n const seg = parseMentionSegment(query, config);\n if (seg.phase === \"category\") {\n return filterCategoryEntries(seg.filter, config);\n }\n const pool = seg.kind === \"guide\" ? guideItems : packageItems;\n return filterItemsByKind(seg.kind
|
|
12
|
+
"content": "import type { Icon } from \"@tabler/icons-react\";\n\nexport type InternalChatMentionKind = \"guide\" | \"package\" | \"ai\";\n\nexport type InternalChatMentionItem = {\n id: string;\n kind: InternalChatMentionKind;\n label: string;\n subtitle: string;\n insertLabel: string;\n icon: Icon;\n};\n\nexport type InternalChatMentionCategoryMeta = {\n label: string;\n hint: string;\n keywords: string[];\n /** Visible en el menú de tipo pero no seleccionable (p. ej. próximamente). */\n disabled?: boolean;\n};\n\nexport type InternalChatMentionListEntry =\n | {\n type: \"category\";\n kind: InternalChatMentionKind;\n label: string;\n hint: string;\n icon: Icon;\n disabled?: boolean;\n }\n | { type: \"item\"; data: InternalChatMentionItem };\n\nexport type InternalChatMentionCatalogState = {\n open: boolean;\n phase: \"category\" | \"item\";\n kind?: InternalChatMentionKind;\n filter: string;\n};\n\nexport type BuildMentionListOptions = {\n /** Si true, los ítems ya vienen filtrados del servidor (sin filtro local por texto). */\n serverFilteredItems?: boolean;\n};\n\nexport const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [\n \"guide\",\n \"package\",\n \"ai\",\n];\n\nexport const DEFAULT_MENTION_KIND_LABEL: Record<\n InternalChatMentionKind,\n string\n> = {\n guide: \"Guías\",\n package: \"Paquetes\",\n ai: \"Asistencia de IA\",\n};\n\nexport type InternalChatMentionConfig = {\n kindOrder: InternalChatMentionKind[];\n categoryMeta: Record<\n InternalChatMentionKind,\n InternalChatMentionCategoryMeta\n >;\n kindIcons: Record<InternalChatMentionKind, Icon>;\n kindAliases?: Record<string, InternalChatMentionKind>;\n};\n\nfunction resolveKindAlias(\n raw: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionKind | null {\n const s = raw.trim().toLowerCase();\n if (!s) return null;\n\n const aliases: Record<string, InternalChatMentionKind> = {\n guide: \"guide\",\n guía: \"guide\",\n guia: \"guide\",\n paquete: \"package\",\n paquetes: \"package\",\n package: \"package\",\n ia: \"ai\",\n ai: \"ai\",\n asistencia: \"ai\",\n ...config.kindAliases,\n };\n\n const direct = aliases[s];\n if (direct) return direct;\n\n for (const kind of config.kindOrder) {\n const meta = config.categoryMeta[kind];\n if (kind === s) return kind;\n if (meta.label.toLowerCase() === s) return kind;\n if (meta.keywords.some((k) => k === s)) return kind;\n }\n\n return null;\n}\n\nexport function parseMentionSegment(\n query: string,\n config: InternalChatMentionConfig,\n):\n | { phase: \"category\"; filter: string }\n | { phase: \"item\"; kind: InternalChatMentionKind; filter: string } {\n const colon = query.indexOf(\":\");\n if (colon === -1) {\n return { phase: \"category\", filter: query };\n }\n const head = query.slice(0, colon).trim().toLowerCase();\n const tail = query.slice(colon + 1);\n if (head === \"\") {\n return { phase: \"category\", filter: query.replace(\":\", \"\") };\n }\n const kind = resolveKindAlias(head, config);\n if (!kind) {\n return { phase: \"category\", filter: query };\n }\n return { phase: \"item\", kind, filter: tail };\n}\n\nexport function parseSlashMention(\n draft: string,\n cursor: number,\n): { start: number; query: string } | null {\n const before = draft.slice(0, cursor);\n const lastSlash = before.lastIndexOf(\"/\");\n if (lastSlash === -1) return null;\n const charBefore = lastSlash === 0 ? \" \" : draft[lastSlash - 1];\n if (charBefore !== \" \" && charBefore !== \"\\n\" && lastSlash !== 0) {\n return null;\n }\n const afterSlash = before.slice(lastSlash + 1);\n if (afterSlash.includes(\"\\n\")) return null;\n return { start: lastSlash, query: afterSlash };\n}\n\nfunction filterCategoryEntries(\n filterText: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n const rows: InternalChatMentionListEntry[] = config.kindOrder.map((kind) => ({\n type: \"category\",\n kind,\n label: config.categoryMeta[kind].label,\n hint: config.categoryMeta[kind].hint,\n icon: config.kindIcons[kind],\n disabled: config.categoryMeta[kind].disabled,\n }));\n if (!q) return rows;\n const byAlias = resolveKindAlias(q, config);\n return rows.filter((row) => {\n if (row.type !== \"category\") return false;\n if (byAlias && row.kind === byAlias) return true;\n return (\n row.label.toLowerCase().includes(q) ||\n row.hint.toLowerCase().includes(q) ||\n config.categoryMeta[row.kind].keywords.some(\n (k) => k.includes(q) || q.includes(k),\n )\n );\n });\n}\n\nfunction filterItemsByKind(\n kind: InternalChatMentionKind,\n filterText: string,\n catalog: InternalChatMentionItem[],\n skipTextFilter = false,\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n let items = catalog.filter((i) => i.kind === kind);\n if (!skipTextFilter && q) {\n items = items.filter(\n (item) =>\n item.label.toLowerCase().includes(q) ||\n item.subtitle.toLowerCase().includes(q) ||\n item.insertLabel.toLowerCase().includes(q),\n );\n }\n return items.map((data) => ({ type: \"item\", data }));\n}\n\nexport function buildMentionList(\n query: string,\n guideItems: InternalChatMentionItem[],\n packageItems: InternalChatMentionItem[],\n config: InternalChatMentionConfig,\n options?: BuildMentionListOptions,\n): InternalChatMentionListEntry[] {\n const seg = parseMentionSegment(query, config);\n if (seg.phase === \"category\") {\n return filterCategoryEntries(seg.filter, config);\n }\n if (seg.kind === \"ai\" || config.categoryMeta[seg.kind]?.disabled) {\n return [];\n }\n const pool = seg.kind === \"guide\" ? guideItems : packageItems;\n return filterItemsByKind(\n seg.kind,\n seg.filter,\n pool,\n Boolean(options?.serverFilteredItems),\n );\n}\n\nexport function roomInitialLetter(name: string): string {\n const t = name.trim();\n if (!t) return \"?\";\n const ch = [...t][0];\n return ch && /\\p{L}/u.test(ch) ? ch.toUpperCase() : \"?\";\n}\n\nexport function initialsFromDisplayName(name: string): string {\n const t = name.trim();\n if (!t) return \"?\";\n const parts = t.split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"?\";\n if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();\n return `${parts[0]![0]!}${parts[parts.length - 1]![0]!}`.toUpperCase();\n}\n\nexport function formatMessageTime(iso: string, locale = \"es\"): string {\n return new Intl.DateTimeFormat(locale, {\n hour: \"2-digit\",\n minute: \"2-digit\",\n hour12: false,\n }).format(new Date(iso));\n}\n",
|
|
13
13
|
"type": "registry:ui",
|
|
14
14
|
"target": ""
|
|
15
15
|
}
|
|
@@ -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 IconDotsVertical,\n IconLoader2,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPlus,\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 InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n\nexport type InternalChatRoom = {\n id: string;\n name: string;\n isOrganization?: boolean;\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};\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 sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: 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 y archivos\",\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};\n\nconst DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {\n kindOrder: DEFAULT_MENTION_KIND_ORDER,\n kindIcons: {\n guide: IconTruckLoading,\n package: IconPackage,\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 },\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 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};\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 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}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(false);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\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 textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const activeRoom = useMemo(\n () => rooms.find((r) => r.id === activeRoomId) ?? null,\n [rooms, activeRoomId],\n );\n\n const mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n ),\n [mentionQuery, mentionGuideItems, mentionPackageItems, mentionConfig],\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\n const canSend =\n draft.trim().length > 0 &&\n !isSending &&\n Boolean(activeRoomId);\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 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 loadingOlderMessagesRef.current = false;\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open) return;\n const scrollToEnd = () => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n };\n scrollToEnd();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollToEnd();\n rafNested = requestAnimationFrame(scrollToEnd);\n });\n const timeouts = [0, 50, 150].map((ms) =>\n window.setTimeout(scrollToEnd, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [open, activeRoomId, messages.length]);\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 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 entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n );\n if (entries.length === 0) {\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 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 } 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 (rooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={labels.openChat}\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n </Button>\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 {rooms.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 </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 <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 rooms.length <= 1 || activeRoom?.isOrganization\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 <>\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 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\"\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 className=\"flex flex-col gap-6 px-5 py-2\">\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>\n {mine ? null : (\n <MessageHeader>{message.author}</MessageHeader>\n )}\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 <MessageFooter>\n {formatMessageTime(message.at)}\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 <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 {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n return (\n <button\n key={`cat-${entry.kind}`}\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 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 (h + 1) % mentionEntries.length,\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n (h - 1 + mentionEntries.length) %\n mentionEntries.length,\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) return;\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) return;\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-44\"\n >\n <DropdownMenuItem>\n <IconPaperclip className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n className=\"ml-auto\"\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\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 InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
|
|
26
|
+
"content": "\"use client\";\n\nimport {\n IconArrowUp,\n IconDotsVertical,\n IconLoader2,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPlus,\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};\n\nexport type InternalChatMessageLine = {\n id: string;\n role: \"me\" | \"team\";\n author: string;\n initials: string;\n text: string;\n at: string;\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 sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: 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 y archivos\",\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};\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 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};\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}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(false);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\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 textareaRef = useRef<HTMLTextAreaElement>(null);\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 setOpenWithNotify = useCallback(\n (next: boolean) => {\n if (next) onUserGesture?.();\n setOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, onUserGesture],\n );\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 loadingOlderMessagesRef.current = false;\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open) return;\n const scrollToEnd = () => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n };\n scrollToEnd();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollToEnd();\n rafNested = requestAnimationFrame(scrollToEnd);\n });\n const timeouts = [0, 50, 150].map((ms) =>\n window.setTimeout(scrollToEnd, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [open, activeRoomId, messages.length]);\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 } 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 (rooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={labels.openChat}\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n </Button>\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 {rooms.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 </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 <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 rooms.length <= 1 || activeRoom?.isOrganization\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 <>\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 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\"\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 className=\"flex flex-col gap-6 px-5 py-2\">\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>\n {mine ? null : (\n <MessageHeader>{message.author}</MessageHeader>\n )}\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 <MessageFooter>\n {formatMessageTime(message.at)}\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 <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-44\"\n >\n <DropdownMenuItem>\n <IconPaperclip className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n className=\"ml-auto\"\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Icon } from "@tabler/icons-react";
|
|
2
2
|
|
|
3
|
-
export type InternalChatMentionKind = "guide" | "package";
|
|
3
|
+
export type InternalChatMentionKind = "guide" | "package" | "ai";
|
|
4
4
|
|
|
5
5
|
export type InternalChatMentionItem = {
|
|
6
6
|
id: string;
|
|
@@ -15,6 +15,8 @@ export type InternalChatMentionCategoryMeta = {
|
|
|
15
15
|
label: string;
|
|
16
16
|
hint: string;
|
|
17
17
|
keywords: string[];
|
|
18
|
+
/** Visible en el menú de tipo pero no seleccionable (p. ej. próximamente). */
|
|
19
|
+
disabled?: boolean;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export type InternalChatMentionListEntry =
|
|
@@ -24,12 +26,26 @@ export type InternalChatMentionListEntry =
|
|
|
24
26
|
label: string;
|
|
25
27
|
hint: string;
|
|
26
28
|
icon: Icon;
|
|
29
|
+
disabled?: boolean;
|
|
27
30
|
}
|
|
28
31
|
| { type: "item"; data: InternalChatMentionItem };
|
|
29
32
|
|
|
33
|
+
export type InternalChatMentionCatalogState = {
|
|
34
|
+
open: boolean;
|
|
35
|
+
phase: "category" | "item";
|
|
36
|
+
kind?: InternalChatMentionKind;
|
|
37
|
+
filter: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BuildMentionListOptions = {
|
|
41
|
+
/** Si true, los ítems ya vienen filtrados del servidor (sin filtro local por texto). */
|
|
42
|
+
serverFilteredItems?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
30
45
|
export const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [
|
|
31
46
|
"guide",
|
|
32
47
|
"package",
|
|
48
|
+
"ai",
|
|
33
49
|
];
|
|
34
50
|
|
|
35
51
|
export const DEFAULT_MENTION_KIND_LABEL: Record<
|
|
@@ -38,6 +54,7 @@ export const DEFAULT_MENTION_KIND_LABEL: Record<
|
|
|
38
54
|
> = {
|
|
39
55
|
guide: "Guías",
|
|
40
56
|
package: "Paquetes",
|
|
57
|
+
ai: "Asistencia de IA",
|
|
41
58
|
};
|
|
42
59
|
|
|
43
60
|
export type InternalChatMentionConfig = {
|
|
@@ -64,6 +81,9 @@ function resolveKindAlias(
|
|
|
64
81
|
paquete: "package",
|
|
65
82
|
paquetes: "package",
|
|
66
83
|
package: "package",
|
|
84
|
+
ia: "ai",
|
|
85
|
+
ai: "ai",
|
|
86
|
+
asistencia: "ai",
|
|
67
87
|
...config.kindAliases,
|
|
68
88
|
};
|
|
69
89
|
|
|
@@ -129,6 +149,7 @@ function filterCategoryEntries(
|
|
|
129
149
|
label: config.categoryMeta[kind].label,
|
|
130
150
|
hint: config.categoryMeta[kind].hint,
|
|
131
151
|
icon: config.kindIcons[kind],
|
|
152
|
+
disabled: config.categoryMeta[kind].disabled,
|
|
132
153
|
}));
|
|
133
154
|
if (!q) return rows;
|
|
134
155
|
const byAlias = resolveKindAlias(q, config);
|
|
@@ -149,10 +170,11 @@ function filterItemsByKind(
|
|
|
149
170
|
kind: InternalChatMentionKind,
|
|
150
171
|
filterText: string,
|
|
151
172
|
catalog: InternalChatMentionItem[],
|
|
173
|
+
skipTextFilter = false,
|
|
152
174
|
): InternalChatMentionListEntry[] {
|
|
153
175
|
const q = filterText.trim().toLowerCase();
|
|
154
176
|
let items = catalog.filter((i) => i.kind === kind);
|
|
155
|
-
if (q) {
|
|
177
|
+
if (!skipTextFilter && q) {
|
|
156
178
|
items = items.filter(
|
|
157
179
|
(item) =>
|
|
158
180
|
item.label.toLowerCase().includes(q) ||
|
|
@@ -168,13 +190,22 @@ export function buildMentionList(
|
|
|
168
190
|
guideItems: InternalChatMentionItem[],
|
|
169
191
|
packageItems: InternalChatMentionItem[],
|
|
170
192
|
config: InternalChatMentionConfig,
|
|
193
|
+
options?: BuildMentionListOptions,
|
|
171
194
|
): InternalChatMentionListEntry[] {
|
|
172
195
|
const seg = parseMentionSegment(query, config);
|
|
173
196
|
if (seg.phase === "category") {
|
|
174
197
|
return filterCategoryEntries(seg.filter, config);
|
|
175
198
|
}
|
|
199
|
+
if (seg.kind === "ai" || config.categoryMeta[seg.kind]?.disabled) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
176
202
|
const pool = seg.kind === "guide" ? guideItems : packageItems;
|
|
177
|
-
return filterItemsByKind(
|
|
203
|
+
return filterItemsByKind(
|
|
204
|
+
seg.kind,
|
|
205
|
+
seg.filter,
|
|
206
|
+
pool,
|
|
207
|
+
Boolean(options?.serverFilteredItems),
|
|
208
|
+
);
|
|
178
209
|
}
|
|
179
210
|
|
|
180
211
|
export function roomInitialLetter(name: string): string {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
IconPackage,
|
|
9
9
|
IconPaperclip,
|
|
10
10
|
IconPlus,
|
|
11
|
+
IconSparkles,
|
|
11
12
|
IconTrash,
|
|
12
13
|
IconTruckLoading,
|
|
13
14
|
IconX,
|
|
@@ -92,9 +93,12 @@ import {
|
|
|
92
93
|
parseMentionSegment,
|
|
93
94
|
parseSlashMention,
|
|
94
95
|
roomInitialLetter,
|
|
96
|
+
type BuildMentionListOptions,
|
|
97
|
+
type InternalChatMentionCatalogState,
|
|
95
98
|
type InternalChatMentionConfig,
|
|
96
99
|
type InternalChatMentionItem,
|
|
97
100
|
type InternalChatMentionKind,
|
|
101
|
+
type InternalChatMentionListEntry,
|
|
98
102
|
} from "@/components/ui/internal-chat-mention";
|
|
99
103
|
|
|
100
104
|
export type InternalChatRoom = {
|
|
@@ -198,6 +202,7 @@ const DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {
|
|
|
198
202
|
kindIcons: {
|
|
199
203
|
guide: IconTruckLoading,
|
|
200
204
|
package: IconPackage,
|
|
205
|
+
ai: IconSparkles,
|
|
201
206
|
},
|
|
202
207
|
categoryMeta: {
|
|
203
208
|
guide: {
|
|
@@ -210,9 +215,40 @@ const DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {
|
|
|
210
215
|
hint: "Paquetes de la empresa (WR / tracking)",
|
|
211
216
|
keywords: ["paquete", "paquetes", "package", "trk", "wr"],
|
|
212
217
|
},
|
|
218
|
+
ai: {
|
|
219
|
+
label: "Asistencia de IA",
|
|
220
|
+
hint: "Consultas sobre operación y datos (próximamente)",
|
|
221
|
+
keywords: ["ia", "ai", "asistencia", "inteligencia", "asistente"],
|
|
222
|
+
disabled: true,
|
|
223
|
+
},
|
|
213
224
|
},
|
|
214
225
|
};
|
|
215
226
|
|
|
227
|
+
function isMentionEntryDisabled(
|
|
228
|
+
entry: InternalChatMentionListEntry,
|
|
229
|
+
config: InternalChatMentionConfig,
|
|
230
|
+
): boolean {
|
|
231
|
+
if (entry.type !== "category") return false;
|
|
232
|
+
return Boolean(
|
|
233
|
+
entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function stepMentionHighlight(
|
|
238
|
+
entries: InternalChatMentionListEntry[],
|
|
239
|
+
config: InternalChatMentionConfig,
|
|
240
|
+
current: number,
|
|
241
|
+
delta: 1 | -1,
|
|
242
|
+
): number {
|
|
243
|
+
if (entries.length === 0) return 0;
|
|
244
|
+
let next = current;
|
|
245
|
+
for (let i = 0; i < entries.length; i++) {
|
|
246
|
+
next = (next + delta + entries.length) % entries.length;
|
|
247
|
+
if (!isMentionEntryDisabled(entries[next]!, config)) return next;
|
|
248
|
+
}
|
|
249
|
+
return current;
|
|
250
|
+
}
|
|
251
|
+
|
|
216
252
|
function renderMessageTextWithMentions(text: string): ReactNode {
|
|
217
253
|
const parts = text.split(/(@\[[^\]]+\])/g);
|
|
218
254
|
return parts.map((part, index) => {
|
|
@@ -245,6 +281,10 @@ export type InternalChatPanelProps = {
|
|
|
245
281
|
mentionGuideItems?: InternalChatMentionItem[];
|
|
246
282
|
mentionPackageItems?: InternalChatMentionItem[];
|
|
247
283
|
mentionConfig?: InternalChatMentionConfig;
|
|
284
|
+
/** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */
|
|
285
|
+
mentionServerFilteredItems?: boolean;
|
|
286
|
+
mentionCatalogLoading?: boolean;
|
|
287
|
+
onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;
|
|
248
288
|
typingLine?: string | null;
|
|
249
289
|
onSendMessage: (text: string) => void | Promise<void>;
|
|
250
290
|
onCreateRoom: (name: string) => void | Promise<void>;
|
|
@@ -274,6 +314,9 @@ function InternalChatPanel({
|
|
|
274
314
|
mentionGuideItems = [],
|
|
275
315
|
mentionPackageItems = [],
|
|
276
316
|
mentionConfig = DEFAULT_MENTION_CONFIG,
|
|
317
|
+
mentionServerFilteredItems = false,
|
|
318
|
+
mentionCatalogLoading = false,
|
|
319
|
+
onMentionCatalogChange,
|
|
277
320
|
typingLine = null,
|
|
278
321
|
onSendMessage,
|
|
279
322
|
onCreateRoom,
|
|
@@ -310,6 +353,13 @@ function InternalChatPanel({
|
|
|
310
353
|
[rooms, activeRoomId],
|
|
311
354
|
);
|
|
312
355
|
|
|
356
|
+
const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {
|
|
357
|
+
if (!mentionServerFilteredItems) return undefined;
|
|
358
|
+
const seg = parseMentionSegment(mentionQuery, mentionConfig);
|
|
359
|
+
if (seg.phase !== "item") return undefined;
|
|
360
|
+
return { serverFilteredItems: true };
|
|
361
|
+
}, [mentionServerFilteredItems, mentionQuery, mentionConfig]);
|
|
362
|
+
|
|
313
363
|
const mentionEntries = useMemo(
|
|
314
364
|
() =>
|
|
315
365
|
buildMentionList(
|
|
@@ -317,8 +367,15 @@ function InternalChatPanel({
|
|
|
317
367
|
mentionGuideItems,
|
|
318
368
|
mentionPackageItems,
|
|
319
369
|
mentionConfig,
|
|
370
|
+
mentionListOptions,
|
|
320
371
|
),
|
|
321
|
-
[
|
|
372
|
+
[
|
|
373
|
+
mentionQuery,
|
|
374
|
+
mentionGuideItems,
|
|
375
|
+
mentionPackageItems,
|
|
376
|
+
mentionConfig,
|
|
377
|
+
mentionListOptions,
|
|
378
|
+
],
|
|
322
379
|
);
|
|
323
380
|
|
|
324
381
|
const mentionSeg = useMemo(
|
|
@@ -329,7 +386,9 @@ function InternalChatPanel({
|
|
|
329
386
|
const mentionOpen =
|
|
330
387
|
Boolean(activeRoom) &&
|
|
331
388
|
mentionStart !== null &&
|
|
332
|
-
mentionEntries.length > 0
|
|
389
|
+
(mentionEntries.length > 0 ||
|
|
390
|
+
mentionCatalogLoading ||
|
|
391
|
+
(mentionServerFilteredItems && mentionSeg.phase === "item"));
|
|
333
392
|
|
|
334
393
|
const canSend =
|
|
335
394
|
draft.trim().length > 0 &&
|
|
@@ -404,6 +463,32 @@ function InternalChatPanel({
|
|
|
404
463
|
lastMentionQueryRef.current = "";
|
|
405
464
|
}, [activeRoomId]);
|
|
406
465
|
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (!onMentionCatalogChange) return;
|
|
468
|
+
if (mentionStart === null) {
|
|
469
|
+
onMentionCatalogChange({
|
|
470
|
+
open: false,
|
|
471
|
+
phase: "category",
|
|
472
|
+
filter: "",
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (mentionSeg.phase === "category") {
|
|
477
|
+
onMentionCatalogChange({
|
|
478
|
+
open: true,
|
|
479
|
+
phase: "category",
|
|
480
|
+
filter: mentionSeg.filter,
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
onMentionCatalogChange({
|
|
485
|
+
open: true,
|
|
486
|
+
phase: "item",
|
|
487
|
+
kind: mentionSeg.kind,
|
|
488
|
+
filter: mentionSeg.filter,
|
|
489
|
+
});
|
|
490
|
+
}, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);
|
|
491
|
+
|
|
407
492
|
function closeMentionMenu() {
|
|
408
493
|
setMentionStart(null);
|
|
409
494
|
setMentionQuery("");
|
|
@@ -416,13 +501,22 @@ function InternalChatPanel({
|
|
|
416
501
|
closeMentionMenu();
|
|
417
502
|
return;
|
|
418
503
|
}
|
|
504
|
+
const seg = parseMentionSegment(m.query, mentionConfig);
|
|
505
|
+
const listOptions: BuildMentionListOptions | undefined =
|
|
506
|
+
mentionServerFilteredItems && seg.phase === "item"
|
|
507
|
+
? { serverFilteredItems: true }
|
|
508
|
+
: undefined;
|
|
419
509
|
const entries = buildMentionList(
|
|
420
510
|
m.query,
|
|
421
511
|
mentionGuideItems,
|
|
422
512
|
mentionPackageItems,
|
|
423
513
|
mentionConfig,
|
|
514
|
+
listOptions,
|
|
424
515
|
);
|
|
425
|
-
|
|
516
|
+
const keepOpenOnEmpty =
|
|
517
|
+
mentionCatalogLoading ||
|
|
518
|
+
(mentionServerFilteredItems && seg.phase === "item");
|
|
519
|
+
if (entries.length === 0 && !keepOpenOnEmpty) {
|
|
426
520
|
closeMentionMenu();
|
|
427
521
|
return;
|
|
428
522
|
}
|
|
@@ -453,6 +547,7 @@ function InternalChatPanel({
|
|
|
453
547
|
}
|
|
454
548
|
|
|
455
549
|
function applyCategoryPick(kind: InternalChatMentionKind) {
|
|
550
|
+
if (mentionConfig.categoryMeta[kind]?.disabled) return;
|
|
456
551
|
replaceSlashSegment(`${kind}:`);
|
|
457
552
|
setMentionHighlight(0);
|
|
458
553
|
}
|
|
@@ -883,24 +978,49 @@ function InternalChatPanel({
|
|
|
883
978
|
)}
|
|
884
979
|
</div>
|
|
885
980
|
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
981
|
+
{mentionCatalogLoading && mentionEntries.length === 0 ? (
|
|
982
|
+
<div className="flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground">
|
|
983
|
+
<IconLoader2 className="size-4 animate-spin" />
|
|
984
|
+
Buscando…
|
|
985
|
+
</div>
|
|
986
|
+
) : null}
|
|
987
|
+
{!mentionCatalogLoading &&
|
|
988
|
+
mentionEntries.length === 0 &&
|
|
989
|
+
mentionSeg.phase === "item" ? (
|
|
990
|
+
<p className="px-2 py-4 text-center text-sm text-muted-foreground">
|
|
991
|
+
Sin resultados
|
|
992
|
+
</p>
|
|
993
|
+
) : null}
|
|
886
994
|
{mentionEntries.map((entry, index) => {
|
|
887
995
|
if (entry.type === "category") {
|
|
888
996
|
const IconComp = entry.icon;
|
|
997
|
+
const categoryDisabled = isMentionEntryDisabled(
|
|
998
|
+
entry,
|
|
999
|
+
mentionConfig,
|
|
1000
|
+
);
|
|
889
1001
|
return (
|
|
890
1002
|
<button
|
|
891
1003
|
key={`cat-${entry.kind}`}
|
|
892
1004
|
type="button"
|
|
893
1005
|
role="option"
|
|
894
1006
|
aria-selected={index === mentionHighlight}
|
|
1007
|
+
aria-disabled={categoryDisabled}
|
|
1008
|
+
disabled={categoryDisabled}
|
|
895
1009
|
className={cn(
|
|
896
1010
|
"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm",
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1011
|
+
categoryDisabled &&
|
|
1012
|
+
"cursor-not-allowed opacity-50",
|
|
1013
|
+
!categoryDisabled &&
|
|
1014
|
+
(index === mentionHighlight
|
|
1015
|
+
? "bg-accent text-accent-foreground"
|
|
1016
|
+
: "hover:bg-muted/80"),
|
|
900
1017
|
)}
|
|
901
|
-
onMouseEnter={() =>
|
|
1018
|
+
onMouseEnter={() => {
|
|
1019
|
+
if (!categoryDisabled) setMentionHighlight(index);
|
|
1020
|
+
}}
|
|
902
1021
|
onMouseDown={(event) => {
|
|
903
1022
|
event.preventDefault();
|
|
1023
|
+
if (categoryDisabled) return;
|
|
904
1024
|
applyCategoryPick(entry.kind);
|
|
905
1025
|
}}
|
|
906
1026
|
>
|
|
@@ -985,15 +1105,24 @@ function InternalChatPanel({
|
|
|
985
1105
|
if (event.key === "ArrowDown") {
|
|
986
1106
|
event.preventDefault();
|
|
987
1107
|
setMentionHighlight((h) =>
|
|
988
|
-
(
|
|
1108
|
+
stepMentionHighlight(
|
|
1109
|
+
mentionEntries,
|
|
1110
|
+
mentionConfig,
|
|
1111
|
+
h,
|
|
1112
|
+
1,
|
|
1113
|
+
),
|
|
989
1114
|
);
|
|
990
1115
|
return;
|
|
991
1116
|
}
|
|
992
1117
|
if (event.key === "ArrowUp") {
|
|
993
1118
|
event.preventDefault();
|
|
994
1119
|
setMentionHighlight((h) =>
|
|
995
|
-
(
|
|
996
|
-
|
|
1120
|
+
stepMentionHighlight(
|
|
1121
|
+
mentionEntries,
|
|
1122
|
+
mentionConfig,
|
|
1123
|
+
h,
|
|
1124
|
+
-1,
|
|
1125
|
+
),
|
|
997
1126
|
);
|
|
998
1127
|
return;
|
|
999
1128
|
}
|
|
@@ -1024,7 +1153,9 @@ function InternalChatPanel({
|
|
|
1024
1153
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
1025
1154
|
event.preventDefault();
|
|
1026
1155
|
const pick = mentionEntries[mentionHighlight];
|
|
1027
|
-
if (!pick)
|
|
1156
|
+
if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1028
1159
|
if (pick.type === "category") {
|
|
1029
1160
|
applyCategoryPick(pick.kind);
|
|
1030
1161
|
} else {
|
|
@@ -1035,7 +1166,9 @@ function InternalChatPanel({
|
|
|
1035
1166
|
if (event.key === "Tab") {
|
|
1036
1167
|
event.preventDefault();
|
|
1037
1168
|
const pick = mentionEntries[mentionHighlight];
|
|
1038
|
-
if (!pick)
|
|
1169
|
+
if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1039
1172
|
if (pick.type === "category") {
|
|
1040
1173
|
applyCategoryPick(pick.kind);
|
|
1041
1174
|
} else {
|
|
@@ -1109,6 +1242,8 @@ export {
|
|
|
1109
1242
|
export { initialsFromDisplayName } from "@/components/ui/internal-chat-mention";
|
|
1110
1243
|
|
|
1111
1244
|
export type {
|
|
1245
|
+
BuildMentionListOptions,
|
|
1246
|
+
InternalChatMentionCatalogState,
|
|
1112
1247
|
InternalChatMentionConfig,
|
|
1113
1248
|
InternalChatMentionItem,
|
|
1114
1249
|
InternalChatMentionKind,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Icon } from "@tabler/icons-react";
|
|
2
2
|
|
|
3
|
-
export type InternalChatMentionKind = "guide" | "package";
|
|
3
|
+
export type InternalChatMentionKind = "guide" | "package" | "ai";
|
|
4
4
|
|
|
5
5
|
export type InternalChatMentionItem = {
|
|
6
6
|
id: string;
|
|
@@ -15,6 +15,8 @@ export type InternalChatMentionCategoryMeta = {
|
|
|
15
15
|
label: string;
|
|
16
16
|
hint: string;
|
|
17
17
|
keywords: string[];
|
|
18
|
+
/** Visible en el menú de tipo pero no seleccionable (p. ej. próximamente). */
|
|
19
|
+
disabled?: boolean;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export type InternalChatMentionListEntry =
|
|
@@ -24,12 +26,26 @@ export type InternalChatMentionListEntry =
|
|
|
24
26
|
label: string;
|
|
25
27
|
hint: string;
|
|
26
28
|
icon: Icon;
|
|
29
|
+
disabled?: boolean;
|
|
27
30
|
}
|
|
28
31
|
| { type: "item"; data: InternalChatMentionItem };
|
|
29
32
|
|
|
33
|
+
export type InternalChatMentionCatalogState = {
|
|
34
|
+
open: boolean;
|
|
35
|
+
phase: "category" | "item";
|
|
36
|
+
kind?: InternalChatMentionKind;
|
|
37
|
+
filter: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BuildMentionListOptions = {
|
|
41
|
+
/** Si true, los ítems ya vienen filtrados del servidor (sin filtro local por texto). */
|
|
42
|
+
serverFilteredItems?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
30
45
|
export const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [
|
|
31
46
|
"guide",
|
|
32
47
|
"package",
|
|
48
|
+
"ai",
|
|
33
49
|
];
|
|
34
50
|
|
|
35
51
|
export const DEFAULT_MENTION_KIND_LABEL: Record<
|
|
@@ -38,6 +54,7 @@ export const DEFAULT_MENTION_KIND_LABEL: Record<
|
|
|
38
54
|
> = {
|
|
39
55
|
guide: "Guías",
|
|
40
56
|
package: "Paquetes",
|
|
57
|
+
ai: "Asistencia de IA",
|
|
41
58
|
};
|
|
42
59
|
|
|
43
60
|
export type InternalChatMentionConfig = {
|
|
@@ -64,6 +81,9 @@ function resolveKindAlias(
|
|
|
64
81
|
paquete: "package",
|
|
65
82
|
paquetes: "package",
|
|
66
83
|
package: "package",
|
|
84
|
+
ia: "ai",
|
|
85
|
+
ai: "ai",
|
|
86
|
+
asistencia: "ai",
|
|
67
87
|
...config.kindAliases,
|
|
68
88
|
};
|
|
69
89
|
|
|
@@ -129,6 +149,7 @@ function filterCategoryEntries(
|
|
|
129
149
|
label: config.categoryMeta[kind].label,
|
|
130
150
|
hint: config.categoryMeta[kind].hint,
|
|
131
151
|
icon: config.kindIcons[kind],
|
|
152
|
+
disabled: config.categoryMeta[kind].disabled,
|
|
132
153
|
}));
|
|
133
154
|
if (!q) return rows;
|
|
134
155
|
const byAlias = resolveKindAlias(q, config);
|
|
@@ -149,10 +170,11 @@ function filterItemsByKind(
|
|
|
149
170
|
kind: InternalChatMentionKind,
|
|
150
171
|
filterText: string,
|
|
151
172
|
catalog: InternalChatMentionItem[],
|
|
173
|
+
skipTextFilter = false,
|
|
152
174
|
): InternalChatMentionListEntry[] {
|
|
153
175
|
const q = filterText.trim().toLowerCase();
|
|
154
176
|
let items = catalog.filter((i) => i.kind === kind);
|
|
155
|
-
if (q) {
|
|
177
|
+
if (!skipTextFilter && q) {
|
|
156
178
|
items = items.filter(
|
|
157
179
|
(item) =>
|
|
158
180
|
item.label.toLowerCase().includes(q) ||
|
|
@@ -168,13 +190,22 @@ export function buildMentionList(
|
|
|
168
190
|
guideItems: InternalChatMentionItem[],
|
|
169
191
|
packageItems: InternalChatMentionItem[],
|
|
170
192
|
config: InternalChatMentionConfig,
|
|
193
|
+
options?: BuildMentionListOptions,
|
|
171
194
|
): InternalChatMentionListEntry[] {
|
|
172
195
|
const seg = parseMentionSegment(query, config);
|
|
173
196
|
if (seg.phase === "category") {
|
|
174
197
|
return filterCategoryEntries(seg.filter, config);
|
|
175
198
|
}
|
|
199
|
+
if (seg.kind === "ai" || config.categoryMeta[seg.kind]?.disabled) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
176
202
|
const pool = seg.kind === "guide" ? guideItems : packageItems;
|
|
177
|
-
return filterItemsByKind(
|
|
203
|
+
return filterItemsByKind(
|
|
204
|
+
seg.kind,
|
|
205
|
+
seg.filter,
|
|
206
|
+
pool,
|
|
207
|
+
Boolean(options?.serverFilteredItems),
|
|
208
|
+
);
|
|
178
209
|
}
|
|
179
210
|
|
|
180
211
|
export function roomInitialLetter(name: string): string {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
IconPackage,
|
|
9
9
|
IconPaperclip,
|
|
10
10
|
IconPlus,
|
|
11
|
+
IconSparkles,
|
|
11
12
|
IconTrash,
|
|
12
13
|
IconTruckLoading,
|
|
13
14
|
IconX,
|
|
@@ -92,9 +93,12 @@ import {
|
|
|
92
93
|
parseMentionSegment,
|
|
93
94
|
parseSlashMention,
|
|
94
95
|
roomInitialLetter,
|
|
96
|
+
type BuildMentionListOptions,
|
|
97
|
+
type InternalChatMentionCatalogState,
|
|
95
98
|
type InternalChatMentionConfig,
|
|
96
99
|
type InternalChatMentionItem,
|
|
97
100
|
type InternalChatMentionKind,
|
|
101
|
+
type InternalChatMentionListEntry,
|
|
98
102
|
} from "./internal-chat-mention";
|
|
99
103
|
|
|
100
104
|
export type InternalChatRoom = {
|
|
@@ -198,6 +202,7 @@ const DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {
|
|
|
198
202
|
kindIcons: {
|
|
199
203
|
guide: IconTruckLoading,
|
|
200
204
|
package: IconPackage,
|
|
205
|
+
ai: IconSparkles,
|
|
201
206
|
},
|
|
202
207
|
categoryMeta: {
|
|
203
208
|
guide: {
|
|
@@ -210,9 +215,40 @@ const DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {
|
|
|
210
215
|
hint: "Paquetes de la empresa (WR / tracking)",
|
|
211
216
|
keywords: ["paquete", "paquetes", "package", "trk", "wr"],
|
|
212
217
|
},
|
|
218
|
+
ai: {
|
|
219
|
+
label: "Asistencia de IA",
|
|
220
|
+
hint: "Consultas sobre operación y datos (próximamente)",
|
|
221
|
+
keywords: ["ia", "ai", "asistencia", "inteligencia", "asistente"],
|
|
222
|
+
disabled: true,
|
|
223
|
+
},
|
|
213
224
|
},
|
|
214
225
|
};
|
|
215
226
|
|
|
227
|
+
function isMentionEntryDisabled(
|
|
228
|
+
entry: InternalChatMentionListEntry,
|
|
229
|
+
config: InternalChatMentionConfig,
|
|
230
|
+
): boolean {
|
|
231
|
+
if (entry.type !== "category") return false;
|
|
232
|
+
return Boolean(
|
|
233
|
+
entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function stepMentionHighlight(
|
|
238
|
+
entries: InternalChatMentionListEntry[],
|
|
239
|
+
config: InternalChatMentionConfig,
|
|
240
|
+
current: number,
|
|
241
|
+
delta: 1 | -1,
|
|
242
|
+
): number {
|
|
243
|
+
if (entries.length === 0) return 0;
|
|
244
|
+
let next = current;
|
|
245
|
+
for (let i = 0; i < entries.length; i++) {
|
|
246
|
+
next = (next + delta + entries.length) % entries.length;
|
|
247
|
+
if (!isMentionEntryDisabled(entries[next]!, config)) return next;
|
|
248
|
+
}
|
|
249
|
+
return current;
|
|
250
|
+
}
|
|
251
|
+
|
|
216
252
|
function renderMessageTextWithMentions(text: string): ReactNode {
|
|
217
253
|
const parts = text.split(/(@\[[^\]]+\])/g);
|
|
218
254
|
return parts.map((part, index) => {
|
|
@@ -245,6 +281,10 @@ export type InternalChatPanelProps = {
|
|
|
245
281
|
mentionGuideItems?: InternalChatMentionItem[];
|
|
246
282
|
mentionPackageItems?: InternalChatMentionItem[];
|
|
247
283
|
mentionConfig?: InternalChatMentionConfig;
|
|
284
|
+
/** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */
|
|
285
|
+
mentionServerFilteredItems?: boolean;
|
|
286
|
+
mentionCatalogLoading?: boolean;
|
|
287
|
+
onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;
|
|
248
288
|
typingLine?: string | null;
|
|
249
289
|
onSendMessage: (text: string) => void | Promise<void>;
|
|
250
290
|
onCreateRoom: (name: string) => void | Promise<void>;
|
|
@@ -274,6 +314,9 @@ function InternalChatPanel({
|
|
|
274
314
|
mentionGuideItems = [],
|
|
275
315
|
mentionPackageItems = [],
|
|
276
316
|
mentionConfig = DEFAULT_MENTION_CONFIG,
|
|
317
|
+
mentionServerFilteredItems = false,
|
|
318
|
+
mentionCatalogLoading = false,
|
|
319
|
+
onMentionCatalogChange,
|
|
277
320
|
typingLine = null,
|
|
278
321
|
onSendMessage,
|
|
279
322
|
onCreateRoom,
|
|
@@ -310,6 +353,13 @@ function InternalChatPanel({
|
|
|
310
353
|
[rooms, activeRoomId],
|
|
311
354
|
);
|
|
312
355
|
|
|
356
|
+
const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {
|
|
357
|
+
if (!mentionServerFilteredItems) return undefined;
|
|
358
|
+
const seg = parseMentionSegment(mentionQuery, mentionConfig);
|
|
359
|
+
if (seg.phase !== "item") return undefined;
|
|
360
|
+
return { serverFilteredItems: true };
|
|
361
|
+
}, [mentionServerFilteredItems, mentionQuery, mentionConfig]);
|
|
362
|
+
|
|
313
363
|
const mentionEntries = useMemo(
|
|
314
364
|
() =>
|
|
315
365
|
buildMentionList(
|
|
@@ -317,8 +367,15 @@ function InternalChatPanel({
|
|
|
317
367
|
mentionGuideItems,
|
|
318
368
|
mentionPackageItems,
|
|
319
369
|
mentionConfig,
|
|
370
|
+
mentionListOptions,
|
|
320
371
|
),
|
|
321
|
-
[
|
|
372
|
+
[
|
|
373
|
+
mentionQuery,
|
|
374
|
+
mentionGuideItems,
|
|
375
|
+
mentionPackageItems,
|
|
376
|
+
mentionConfig,
|
|
377
|
+
mentionListOptions,
|
|
378
|
+
],
|
|
322
379
|
);
|
|
323
380
|
|
|
324
381
|
const mentionSeg = useMemo(
|
|
@@ -329,7 +386,9 @@ function InternalChatPanel({
|
|
|
329
386
|
const mentionOpen =
|
|
330
387
|
Boolean(activeRoom) &&
|
|
331
388
|
mentionStart !== null &&
|
|
332
|
-
mentionEntries.length > 0
|
|
389
|
+
(mentionEntries.length > 0 ||
|
|
390
|
+
mentionCatalogLoading ||
|
|
391
|
+
(mentionServerFilteredItems && mentionSeg.phase === "item"));
|
|
333
392
|
|
|
334
393
|
const canSend =
|
|
335
394
|
draft.trim().length > 0 &&
|
|
@@ -404,6 +463,32 @@ function InternalChatPanel({
|
|
|
404
463
|
lastMentionQueryRef.current = "";
|
|
405
464
|
}, [activeRoomId]);
|
|
406
465
|
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (!onMentionCatalogChange) return;
|
|
468
|
+
if (mentionStart === null) {
|
|
469
|
+
onMentionCatalogChange({
|
|
470
|
+
open: false,
|
|
471
|
+
phase: "category",
|
|
472
|
+
filter: "",
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (mentionSeg.phase === "category") {
|
|
477
|
+
onMentionCatalogChange({
|
|
478
|
+
open: true,
|
|
479
|
+
phase: "category",
|
|
480
|
+
filter: mentionSeg.filter,
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
onMentionCatalogChange({
|
|
485
|
+
open: true,
|
|
486
|
+
phase: "item",
|
|
487
|
+
kind: mentionSeg.kind,
|
|
488
|
+
filter: mentionSeg.filter,
|
|
489
|
+
});
|
|
490
|
+
}, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);
|
|
491
|
+
|
|
407
492
|
function closeMentionMenu() {
|
|
408
493
|
setMentionStart(null);
|
|
409
494
|
setMentionQuery("");
|
|
@@ -416,13 +501,22 @@ function InternalChatPanel({
|
|
|
416
501
|
closeMentionMenu();
|
|
417
502
|
return;
|
|
418
503
|
}
|
|
504
|
+
const seg = parseMentionSegment(m.query, mentionConfig);
|
|
505
|
+
const listOptions: BuildMentionListOptions | undefined =
|
|
506
|
+
mentionServerFilteredItems && seg.phase === "item"
|
|
507
|
+
? { serverFilteredItems: true }
|
|
508
|
+
: undefined;
|
|
419
509
|
const entries = buildMentionList(
|
|
420
510
|
m.query,
|
|
421
511
|
mentionGuideItems,
|
|
422
512
|
mentionPackageItems,
|
|
423
513
|
mentionConfig,
|
|
514
|
+
listOptions,
|
|
424
515
|
);
|
|
425
|
-
|
|
516
|
+
const keepOpenOnEmpty =
|
|
517
|
+
mentionCatalogLoading ||
|
|
518
|
+
(mentionServerFilteredItems && seg.phase === "item");
|
|
519
|
+
if (entries.length === 0 && !keepOpenOnEmpty) {
|
|
426
520
|
closeMentionMenu();
|
|
427
521
|
return;
|
|
428
522
|
}
|
|
@@ -453,6 +547,7 @@ function InternalChatPanel({
|
|
|
453
547
|
}
|
|
454
548
|
|
|
455
549
|
function applyCategoryPick(kind: InternalChatMentionKind) {
|
|
550
|
+
if (mentionConfig.categoryMeta[kind]?.disabled) return;
|
|
456
551
|
replaceSlashSegment(`${kind}:`);
|
|
457
552
|
setMentionHighlight(0);
|
|
458
553
|
}
|
|
@@ -883,24 +978,49 @@ function InternalChatPanel({
|
|
|
883
978
|
)}
|
|
884
979
|
</div>
|
|
885
980
|
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
|
981
|
+
{mentionCatalogLoading && mentionEntries.length === 0 ? (
|
|
982
|
+
<div className="flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground">
|
|
983
|
+
<IconLoader2 className="size-4 animate-spin" />
|
|
984
|
+
Buscando…
|
|
985
|
+
</div>
|
|
986
|
+
) : null}
|
|
987
|
+
{!mentionCatalogLoading &&
|
|
988
|
+
mentionEntries.length === 0 &&
|
|
989
|
+
mentionSeg.phase === "item" ? (
|
|
990
|
+
<p className="px-2 py-4 text-center text-sm text-muted-foreground">
|
|
991
|
+
Sin resultados
|
|
992
|
+
</p>
|
|
993
|
+
) : null}
|
|
886
994
|
{mentionEntries.map((entry, index) => {
|
|
887
995
|
if (entry.type === "category") {
|
|
888
996
|
const IconComp = entry.icon;
|
|
997
|
+
const categoryDisabled = isMentionEntryDisabled(
|
|
998
|
+
entry,
|
|
999
|
+
mentionConfig,
|
|
1000
|
+
);
|
|
889
1001
|
return (
|
|
890
1002
|
<button
|
|
891
1003
|
key={`cat-${entry.kind}`}
|
|
892
1004
|
type="button"
|
|
893
1005
|
role="option"
|
|
894
1006
|
aria-selected={index === mentionHighlight}
|
|
1007
|
+
aria-disabled={categoryDisabled}
|
|
1008
|
+
disabled={categoryDisabled}
|
|
895
1009
|
className={cn(
|
|
896
1010
|
"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm",
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1011
|
+
categoryDisabled &&
|
|
1012
|
+
"cursor-not-allowed opacity-50",
|
|
1013
|
+
!categoryDisabled &&
|
|
1014
|
+
(index === mentionHighlight
|
|
1015
|
+
? "bg-accent text-accent-foreground"
|
|
1016
|
+
: "hover:bg-muted/80"),
|
|
900
1017
|
)}
|
|
901
|
-
onMouseEnter={() =>
|
|
1018
|
+
onMouseEnter={() => {
|
|
1019
|
+
if (!categoryDisabled) setMentionHighlight(index);
|
|
1020
|
+
}}
|
|
902
1021
|
onMouseDown={(event) => {
|
|
903
1022
|
event.preventDefault();
|
|
1023
|
+
if (categoryDisabled) return;
|
|
904
1024
|
applyCategoryPick(entry.kind);
|
|
905
1025
|
}}
|
|
906
1026
|
>
|
|
@@ -985,15 +1105,24 @@ function InternalChatPanel({
|
|
|
985
1105
|
if (event.key === "ArrowDown") {
|
|
986
1106
|
event.preventDefault();
|
|
987
1107
|
setMentionHighlight((h) =>
|
|
988
|
-
(
|
|
1108
|
+
stepMentionHighlight(
|
|
1109
|
+
mentionEntries,
|
|
1110
|
+
mentionConfig,
|
|
1111
|
+
h,
|
|
1112
|
+
1,
|
|
1113
|
+
),
|
|
989
1114
|
);
|
|
990
1115
|
return;
|
|
991
1116
|
}
|
|
992
1117
|
if (event.key === "ArrowUp") {
|
|
993
1118
|
event.preventDefault();
|
|
994
1119
|
setMentionHighlight((h) =>
|
|
995
|
-
(
|
|
996
|
-
|
|
1120
|
+
stepMentionHighlight(
|
|
1121
|
+
mentionEntries,
|
|
1122
|
+
mentionConfig,
|
|
1123
|
+
h,
|
|
1124
|
+
-1,
|
|
1125
|
+
),
|
|
997
1126
|
);
|
|
998
1127
|
return;
|
|
999
1128
|
}
|
|
@@ -1024,7 +1153,9 @@ function InternalChatPanel({
|
|
|
1024
1153
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
1025
1154
|
event.preventDefault();
|
|
1026
1155
|
const pick = mentionEntries[mentionHighlight];
|
|
1027
|
-
if (!pick)
|
|
1156
|
+
if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1028
1159
|
if (pick.type === "category") {
|
|
1029
1160
|
applyCategoryPick(pick.kind);
|
|
1030
1161
|
} else {
|
|
@@ -1035,7 +1166,9 @@ function InternalChatPanel({
|
|
|
1035
1166
|
if (event.key === "Tab") {
|
|
1036
1167
|
event.preventDefault();
|
|
1037
1168
|
const pick = mentionEntries[mentionHighlight];
|
|
1038
|
-
if (!pick)
|
|
1169
|
+
if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1039
1172
|
if (pick.type === "category") {
|
|
1040
1173
|
applyCategoryPick(pick.kind);
|
|
1041
1174
|
} else {
|
|
@@ -1109,6 +1242,8 @@ export {
|
|
|
1109
1242
|
export { initialsFromDisplayName } from "./internal-chat-mention";
|
|
1110
1243
|
|
|
1111
1244
|
export type {
|
|
1245
|
+
BuildMentionListOptions,
|
|
1246
|
+
InternalChatMentionCatalogState,
|
|
1112
1247
|
InternalChatMentionConfig,
|
|
1113
1248
|
InternalChatMentionItem,
|
|
1114
1249
|
InternalChatMentionKind,
|