1import { useEffect, useMemo, useRef, useState } from 'react';2import { useNavigate, useParams } from 'react-router-dom';3import { useQuery } from '@tanstack/react-query';4import { ArrowDown } from 'lucide-react';5import { api } from '@/lib/api';6import type { Course } from '@/lib/types';7import { useAuth } from '@/stores/auth';8import { useChat } from '@/stores/chat';9import { useUI } from '@/stores/ui';10import { MessageBubble } from './message-bubble';11import { Composer } from './composer';12import { EmptyState } from './empty-state';1314export function ChatView() {15 const { id } = useParams();16 const nav = useNavigate();17 const user = useAuth((s) => s.user);18 const { course, setCourse } = useUI();19 const { messages, streaming, warnings, loadMessages, createConversation, send, stop, regenerate, feedback } = useChat();20 const [deep, setDeep] = useState(!!user?.preferences.deep);21 const [atBottom, setAtBottom] = useState(true);22 const scrollRef = useRef<HTMLDivElement>(null);23 const { data: courses } = useQuery({ queryKey: ['courses'], queryFn: () => api<Course[]>('/courses'), staleTime: 300_000 });24 const conv = useChat((s) => s.conversations.find((c) => c.id === id));25 const list = id ? messages[id] || [] : [];26 const isStreaming = id ? !!streaming[id] : false;27 const activeCourse = conv?.course || course;28 const courseObj = useMemo(() => courses?.find((c) => c.code === activeCourse), [courses, activeCourse]);29 const courseCodes = (courses || []).map((c) => c.code);3031 useEffect(() => {32 if (id && !messages[id]) loadMessages(id).catch(() => nav('/'));33 }, [id, messages, loadMessages, nav]);3435 useEffect(() => {36 if (atBottom && list.length > 0) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });37 }, [list, atBottom]);3839 const onScroll = () => {40 const el = scrollRef.current;41 if (!el) return;42 setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80);43 };4445 const handleSend = async (text: string, attachments: string[]) => {46 let convId = id;47 if (!convId) {48 const c = await createConversation(activeCourse);49 convId = c.id;50 nav(`/c/${c.id}`, { replace: true });51 }52 setAtBottom(true);53 await send(convId, text, attachments, deep);54 };5556 const initials = (user?.display_name || user?.email || 'É').slice(0, 2).toUpperCase();57 const lastAssistant = [...list].reverse().find((m) => m.role === 'assistant');5859 return (60 <div className="flex h-full flex-col">61 {isStreaming && <div className="progress-bar" aria-hidden="true" />}62 {id && warnings[id] && <div className="bg-[#fff8dc] text-[#7a6300] text-sm px-4 py-2 text-center">{warnings[id]}</div>}63 <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto scroll-thin">64 {!id || list.length === 0 ? (65 <EmptyState course={courseObj} onPick={(t) => handleSend(t, [])} term={courseObj?.term || ''} />66 ) : (67 <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-4 space-y-5">68 {list.map((m, i) => (69 <MessageBubble70 key={m.id}71 msg={m}72 conversationId={id}73 initials={initials}74 isLast={i === list.length - 1}75 onRegenerate={m.role === 'assistant' && lastAssistant?.id === m.id && !isStreaming && !m.id.startsWith('pending') ? () => regenerate(id, m.id, deep) : undefined}76 onFeedback={m.role === 'assistant' && !m.id.startsWith('pending') ? (fb) => feedback(id, m.id, fb) : undefined}77 />78 ))}79 </div>80 )}81 </div>82 {!atBottom && list.length > 0 && (83 <button onClick={() => { setAtBottom(true); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }}84 className="absolute right-4 bottom-[132px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-card inline-flex items-center justify-center text-uqo-blue" aria-label="Aller en bas">85 <ArrowDown size={18} />86 </button>87 )}88 <Composer89 conversationId={id || null}90 streaming={isStreaming}91 onSend={handleSend}92 onStop={() => id && stop(id)}93 deep={deep}94 onToggleDeep={() => setDeep((v) => !v)}95 course={activeCourse}96 onCourseChange={setCourse}97 courses={courseCodes.length ? courseCodes : ['IMM1003', 'IMM1033']}98 disabled={!user}99 />100 </div>101 );102}103