Python 64.6%
TypeScript 33.7%
CSS 0.8%
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 && !messages[id] ? (65 <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-6 space-y-5" aria-busy="true">66 <div className="flex justify-end"><div className="skeleton h-12 w-[60%]" /></div>67 <div className="space-y-2"><div className="skeleton h-14 w-full" /><div className="skeleton h-28 w-[92%]" /></div>68 <div className="flex justify-end"><div className="skeleton h-10 w-[45%]" /></div>69 <div className="skeleton h-36 w-[88%]" />70 </div>71 ) : !id || list.length === 0 ? (72 <EmptyState course={courseObj} onPick={(t) => handleSend(t, [])} term={courseObj?.term || ''} />73 ) : (74 <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-4 space-y-5">75 {list.map((m, i) => (76 <MessageBubble77 key={m.id}78 msg={m}79 conversationId={id}80 initials={initials}81 isLast={i === list.length - 1}82 onRegenerate={m.role === 'assistant' && lastAssistant?.id === m.id && !isStreaming && !m.id.startsWith('pending') ? () => regenerate(id, m.id, deep) : undefined}83 onFeedback={m.role === 'assistant' && !m.id.startsWith('pending') ? (fb) => feedback(id, m.id, fb) : undefined}84 />85 ))}86 </div>87 )}88 </div>89 {!atBottom && list.length > 0 && (90 <button onClick={() => { setAtBottom(true); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }}91 className="absolute right-4 bottom-[172px] sm:bottom-[150px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-float inline-flex items-center justify-center text-uqo-blue active:scale-95 transition" aria-label="Aller en bas">92 <ArrowDown size={18} />93 </button>94 )}95 <Composer96 conversationId={id || null}97 streaming={isStreaming}98 onSend={handleSend}99 onStop={() => id && stop(id)}100 deep={deep}101 onToggleDeep={() => setDeep((v) => !v)}102 course={activeCourse}103 onCourseChange={setCourse}104 courses={courseCodes.length ? courseCodes : ['IMM1003', 'IMM1033']}105 disabled={!user}106 />107 </div>108 );109}110