Merge pull request 'Consistencia y mensajes del chat de la IA fix #179 fix #178' (#183) from issue/179-consistencia-y-mensajes-del-chat-de-la-ia into main

Reviewed-on: #183
This commit was merged in pull request #183.
This commit is contained in:
2026-03-13 18:17:31 +00:00
2 changed files with 191 additions and 136 deletions

View File

@@ -28,6 +28,12 @@ import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAISubjectChat, useAISubjectChat,
useConversationBySubject, useConversationBySubject,
@@ -371,11 +377,8 @@ export function IAAsignaturaTab({
<Button <Button
onClick={() => { onClick={() => {
// 1. Limpiamos el ID
setActiveChatId(undefined) setActiveChatId(undefined)
// 2. Marcamos que ya hubo una "interacción inicial" para que el useEffect no actúe
hasInitialSelected.current = true hasInitialSelected.current = true
// 3. Limpiamos estados visuales
setIsCreatingNewChat(true) setIsCreatingNewChat(true)
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
@@ -389,29 +392,34 @@ export function IAAsignaturaTab({
<MessageSquarePlus size={18} /> Nuevo Chat <MessageSquarePlus size={18} /> Nuevo Chat
</Button> </Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1 pr-3"> <div className="flex flex-col gap-1 pr-3">
{/* CORRECCIÓN: Mapear ambos casos */} {' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => ( {(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div <div
key={chat.id} key={chat.id}
className={cn( className={cn(
'group relative flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all', // Agregamos 'overflow-hidden' para que nada salga de este cuadro
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id activeChatId === chat.id
? 'bg-teal-50 text-teal-900' ? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100', : 'text-slate-600 hover:bg-slate-100',
)} )}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
> >
<FileText size={14} className="shrink-0 opacity-50" />
{editingId === chat.id ? ( {editingId === chat.id ? (
<div className="flex flex-1 items-center gap-1"> <div className="flex min-w-0 flex-1 items-center">
<input <input
autoFocus autoFocus
className="w-full rounded bg-white px-1 text-xs ring-1 ring-teal-400 outline-none" className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName} value={tempName}
onChange={(e) => setTempName(e.target.value)} onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)} // Guardar al hacer clic fuera onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id) if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null) if (e.key === 'Escape') setEditingId(null)
@@ -420,54 +428,78 @@ export function IAAsignaturaTab({
</div> </div>
) : ( ) : (
<> <>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span <span
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className="flex-1 cursor-pointer truncate" className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
title={chat.nombre || chat.titulo}
> >
{/* CORRECCIÓN: Usar 'nombre' si así se llama en tu DB */}
{chat.nombre || chat.titulo || 'Conversación'} {chat.nombre || chat.titulo || 'Conversación'}
</span> </span>
<div className="flex opacity-0 transition-opacity group-hover:opacity-100"> {/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
)}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingId(chat.id) setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '') setTempName(chat.nombre || chat.titulo || '')
}} }}
className="p-1 hover:text-teal-600" className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
> >
<Edit2 size={12} /> <Edit2 size={14} />
</button> </button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
{/* Botón para Archivar/Desarchivar dinámico */} <Tooltip>
<TooltipTrigger asChild>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
// Si el estado actual es ACTIVA, mandamos ARCHIVADA. Si no, viceversa.
const nuevoEstado = const nuevoEstado =
chat.estado === 'ACTIVA' ? 'ARCHIVADA' : 'ACTIVA' chat.estado === 'ACTIVA'
updateStatus({ id: chat.id, estado: nuevoEstado }) ? 'ARCHIVADA'
: 'ACTIVA'
updateStatus({
id: chat.id,
estado: nuevoEstado,
})
}} }}
className={cn( className={cn(
'p-1 transition-colors', 'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA' chat.estado === 'ACTIVA'
? 'hover:text-red-500' ? 'hover:text-red-500'
: 'hover:text-teal-600', : 'hover:text-teal-600',
)} )}
title={
chat.estado === 'ACTIVA'
? 'Archivar chat'
: 'Desarchivar chat'
}
> >
{chat.estado === 'ACTIVA' ? ( {chat.estado === 'ACTIVA' ? (
<Archive size={12} /> <Archive size={14} />
) : ( ) : (
/* Icono de Desarchivar */ <History size={14} className="scale-x-[-1]" />
<History size={12} className="scale-x-[-1]" />
)} )}
</button> </button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</> </>
)} )}

View File

@@ -13,8 +13,8 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
RotateCcw,
Loader2, Loader2,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -22,10 +22,17 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard' import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAIPlanChat, useAIPlanChat,
useConversationByPlan, useConversationByPlan,
@@ -507,21 +514,30 @@ function RouteComponent() {
<div <div
key={chat.id} key={chat.id}
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${ className={`group relative flex w-full items-center justify-between overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900' ? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50' : 'text-slate-600 hover:bg-slate-50'
}`} }`}
> >
{/* LADO IZQUIERDO: Icono + Texto con Tooltip */}
<div className="flex min-w-0 flex-1 items-center gap-3">
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
{/* Este contenedor es el que obliga al span a truncarse */}
<div className="max-w-[calc(100%-48px)] min-w-0 flex-1">
<span <span
ref={editingChatId === chat.id ? editableRef : null} ref={
editingChatId === chat.id ? editableRef : null
}
contentEditable={editingChatId === chat.id} contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
className={`truncate pr-14 transition-all outline-none ${ className={`block truncate outline-none ${
editingChatId === chat.id editingChatId === chat.id
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500' ? 'max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded bg-white px-1 break-all shadow-sm ring-1 ring-teal-500'
: 'cursor-pointer' : 'cursor-pointer'
}`} }`}
onDoubleClick={(e) => { onDoubleClick={(e) => {
@@ -531,52 +547,66 @@ function RouteComponent() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
const newTitle = e.currentTarget.textContent || '' e.currentTarget.blur()
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingChatId(null) setEditingChatId(null)
e.currentTarget.textContent =
e.currentTarget.textContent = chat.nombre || '' chat.nombre || ''
} }
}} }}
onBlur={(e) => { onBlur={(e) => {
if (editingChatId === chat.id) { if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || '' const newTitle =
if (newTitle !== chat.nombre) { e.currentTarget.textContent?.trim() || ''
updateTitleMutation({ id: chat.id, nombre: newTitle }) if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
nombre: newTitle,
})
} }
setEditingChatId(null) setEditingChatId(null)
} }
}} }}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}}
> >
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`} {chat.nombre ||
`Chat ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
</TooltipTrigger>
{/* ACCIONES */} {/* Tooltip: Solo aparece si no estás editando y el texto es largo */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100"> {editingChatId !== chat.id && (
<TooltipContent
side="right"
className="max-w-[280px] break-all"
>
{chat.nombre || 'Conversación'}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
{/* LADO DERECHO: Acciones con shrink-0 para que no se muevan */}
<div
className={`flex shrink-0 items-center gap-1 pl-2 opacity-0 transition-opacity group-hover:opacity-100 ${
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
}`}
>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingChatId(chat.id) setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50) setTimeout(() => editableRef.current?.focus(), 50)
}} }}
className="p-1 text-slate-400 hover:text-teal-600" className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
> >
<Send size={12} className="rotate-45" /> <Send size={12} className="rotate-45" />
</button> </button>
<button <button
onClick={(e) => archiveChat(e, chat.id)} onClick={(e) => archiveChat(e, chat.id)}
className="p-1 text-slate-400 hover:text-amber-600" className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
> >
<Archive size={14} /> <Archive size={14} />
</button> </button>
@@ -584,24 +614,26 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
/* ... Resto del código de archivados (sin cambios) ... */ /* Sección de archivados */
<div className="animate-in fade-in slide-in-from-left-2"> <div className="animate-in fade-in slide-in-from-left-2 px-1">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
</p> </p>
{archivedChats.map((chat) => ( {archivedChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400" className="group relative mb-1 flex w-full items-center justify-between overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
> >
<div className="flex min-w-0 flex-1 items-center gap-3">
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8"> <span className="block min-w-0 flex-1 truncate">
{chat.nombre || {chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`} `Archivado ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600" className="ml-2 shrink-0 rounded bg-slate-50/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
@@ -721,35 +753,26 @@ function RouteComponent() {
) )
})} })}
{(isSending || isSyncing) &&
optimisticMessage &&
!chatMessages.some(
(m) => m.content === optimisticMessage,
) && (
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
{optimisticMessage}
</div>
</div>
)}
{(isSending || isSyncing) && ( {(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300"> <div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm"> <div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div> </div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase"> </div>
{isSyncing <span className="text-[10px] font-medium text-slate-400 italic">
? 'Actualizando historial...' La IA está analizando tu solicitud...
: 'Esperando respuesta...'}
</span> </span>
</div> </div>
</div> </div>
</div>
)} )}
</> </>
)} )}