Merge pull request 'Mejorar experiencia de usuario #141' (#144) from issue/141-mejorar-experiencia-de-usuario into main

Reviewed-on: #144
This commit was merged in pull request #144.
This commit is contained in:
2026-02-26 22:05:01 +00:00

View File

@@ -128,16 +128,15 @@ function RouteComponent() {
) )
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
// 1. Hacemos un cast de la definición a nuestra interfaz
const definicion = data?.estructuras_plan const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion ?.definicion as EstructuraDefinicion
// Encadenamiento opcional para evitar errores si data es null
if (!definicion.properties) return [] if (!definicion.properties) return []
return Object.entries(definicion.properties).map(([key, value]) => ({ return Object.entries(definicion.properties).map(([key, value]) => ({
key, key,
label: value.title, label: value.title,
// 2. Aquí value ya no es unknown, es parte de nuestra interfaz
value: String(value.description || ''), value: String(value.description || ''),
})) }))
}, [data]) }, [data])
@@ -146,18 +145,32 @@ function RouteComponent() {
}, [lastConversation, activeChatId]) }, [lastConversation, activeChatId])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
// Forzamos el cast a Array de nuestra interfaz // 1. Si no hay ID o no hay data del chat, retornamos vacío
const json = (activeChatData?.conversacion_json || if (!activeChatId || !activeChatData) return []
const json = (activeChatData.conversacion_json ||
[]) as unknown as Array<ChatMessageJSON> []) as unknown as Array<ChatMessageJSON>
// Ahora .map() funcionará sin errores // 2. Verificamos que 'json' sea realmente un array antes de mapear
if (!Array.isArray(json)) return []
return json.map((msg, index: number) => { return json.map((msg, index: number) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!msg?.user) {
return {
id: `err-${index}`,
role: 'assistant',
content: '',
suggestions: [],
}
}
const isAssistant = msg.user === 'assistant' const isAssistant = msg.user === 'assistant'
return { return {
id: `${activeChatId}-${index}`, id: `${activeChatId}-${index}`,
role: isAssistant ? 'assistant' : 'user', role: isAssistant ? 'assistant' : 'user',
content: isAssistant ? msg.message : msg.prompt, content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
isRefusal: isAssistant && msg.refusal === true, isRefusal: isAssistant && msg.refusal === true,
suggestions: suggestions:
isAssistant && msg.recommendations isAssistant && msg.recommendations
@@ -178,6 +191,7 @@ function RouteComponent() {
} }
}) })
}, [activeChatData, activeChatId, availableFields]) }, [activeChatData, activeChatId, availableFields])
const scrollToBottom = () => { const scrollToBottom = () => {
if (scrollRef.current) { if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix // Buscamos el viewport interno del ScrollArea de Radix
@@ -192,25 +206,45 @@ function RouteComponent() {
} }
} }
} }
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
}
}, [lastConversation])
// Auto-scroll cuando cambian los mensajes o cuando la IA está cargando
useEffect(() => { useEffect(() => {
scrollToBottom() scrollToBottom()
}, [chatMessages, isLoading]) }, [chatMessages, isLoading])
useEffect(() => { useEffect(() => {
// Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes if (isLoadingConv || !lastConversation) return
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
if (
!activeChatId &&
lastConversation &&
lastConversation.length > 0 &&
!isCreationMode
) {
setActiveChatId(lastConversation[0].id)
}
}, [lastConversation, activeChatId])
const isChatStillActive = activeChats.some(
(chat) => chat.id === activeChatId,
)
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// Caso A: El chat actual ya no es válido (fue archivado o borrado)
if (activeChatId && !isChatStillActive && !isCreationMode) {
setActiveChatId(undefined)
setMessages([])
return // Salimos para evitar ejecuciones extra en este render
}
// Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar)
if (!activeChatId && activeChats.length > 0 && !isCreationMode) {
setActiveChatId(activeChats[0].id)
}
// Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso
if (activeChats.length === 0 && activeChatId && !isCreationMode) {
setActiveChatId(undefined)
}
}, [activeChats, activeChatId, isLoadingConv, messages.length])
useEffect(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
if (!state?.campo_edit || availableFields.length === 0) return if (!state?.campo_edit || availableFields.length === 0) return
@@ -252,6 +286,9 @@ function RouteComponent() {
if (activeChatId === id) { if (activeChatId === id) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) setMessages([])
setOptimisticMessage(null)
setInput('')
setSelectedFields([])
} }
}, },
}, },
@@ -331,6 +368,9 @@ function RouteComponent() {
setIsSending(true) setIsSending(true)
setOptimisticMessage(rawText) setOptimisticMessage(rawText)
setInput('') setInput('')
setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
try { try {
const payload: any = { const payload: any = {
planId: planId, planId: planId,
@@ -370,16 +410,6 @@ function RouteComponent() {
) )
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
}
}, [lastConversation])
const removeSelectedField = (fieldKey: string) => { const removeSelectedField = (fieldKey: string) => {
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey)) setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
} }
@@ -555,11 +585,31 @@ function RouteComponent() {
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{!activeChatId &&
chatMessages.length === 0 &&
!optimisticMessage ? (
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
<MessageSquarePlus
size={48}
className="mb-4 text-slate-300"
/>
<h3 className="text-lg font-medium text-slate-900">
No hay un chat seleccionado
</h3>
<p className="text-sm text-slate-500">
Selecciona un chat del historial o crea uno nuevo para
empezar.
</p>
</div>
) : (
<>
{chatMessages.map((msg: any) => ( {chatMessages.map((msg: any) => (
<div <div
key={msg.id} key={msg.id}
className={`flex max-w-[85%] flex-col ${ className={`flex max-w-[85%] flex-col ${
msg.role === 'user' ? 'ml-auto items-end' : 'items-start' msg.role === 'user'
? 'ml-auto items-end'
: 'items-start'
}`} }`}
> >
<div <div
@@ -592,13 +642,16 @@ function RouteComponent() {
planId={planId} planId={planId}
currentDatos={data?.datos} currentDatos={data?.datos}
activeChatId={activeChatId} activeChatId={activeChatId}
onApplySuccess={(key) => removeSelectedField(key)} onApplySuccess={(key) =>
removeSelectedField(key)
}
/> />
</div> </div>
)} )}
</div> </div>
</div> </div>
))} ))}
{optimisticMessage && ( {optimisticMessage && (
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end"> <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"> <div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
@@ -606,6 +659,7 @@ function RouteComponent() {
</div> </div>
</div> </div>
)} )}
{isSending && ( {isSending && (
<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-left-2 flex flex-col items-start duration-300">
<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">
@@ -622,6 +676,8 @@ function RouteComponent() {
</div> </div>
</div> </div>
)} )}
</>
)}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -651,25 +707,42 @@ function RouteComponent() {
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */} {/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl"> <div className="...">
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase"> <div className="...">Seleccionar campo para IA</div>
Seleccionar campo para IA
</div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
{availableFields.map((field) => ( {availableFields.map((field) => {
// 1. Verificamos si el campo ya está en la lista de seleccionados
const isAlreadySelected = selectedFields.some(
(f) => f.key === field.key,
)
return (
<button <button
key={field.key} key={field.key}
onClick={() => toggleField(field)} onClick={() => !isAlreadySelected && toggleField(field)}
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50" // 2. Aplicamos el atributo disabled
disabled={isAlreadySelected}
className={`group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isAlreadySelected
? 'cursor-not-allowed bg-slate-50 opacity-50' // Estilo visual de deshabilitado
: 'hover:bg-teal-50'
}`}
>
<span
className={`text-slate-700 ${!isAlreadySelected && 'group-hover:text-teal-700'}`}
> >
<span className="text-slate-700 group-hover:text-teal-700">
{field.label} {field.label}
</span> </span>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" /> {isAlreadySelected && (
<div className="flex items-center gap-1 text-[10px] font-medium text-teal-600">
<Check size={12} />
<span>Seleccionado</span>
</div>
)} )}
</button> </button>
))} )
})}
</div> </div>
</div> </div>
)} )}