fixed page state while generating a message | fixed scrolling effect

This commit is contained in:
2026-09-19 06:54:13 -04:00
parent 616282cb35
commit 8b9e0df2b2
17 changed files with 1190 additions and 1227 deletions
+1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"test": "node --test src/**/*.test.js",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
+84 -554
View File
@@ -1,596 +1,140 @@
/* ======================================== import { useCallback, useState } from "react";
App Component — padhle (Backend-Connected)
======================================== */
import { useState, useRef, useCallback, useEffect } from "react";
import { useMatch, useNavigate } from "react-router-dom"; import { useMatch, useNavigate } from "react-router-dom";
import Sidebar from "./components/sidebar/Sidebar"; import Sidebar from "./components/sidebar/Sidebar";
import TopNav from "./components/top-nav/TopNav"; import TopNav from "./components/top-nav/TopNav";
import ChatHistory from "./components/chat-history/ChatHistory"; import ChatHistory from "./components/chat-history/ChatHistory";
import ChatInput from "./components/chat-input/ChatInput"; import ChatInput from "./components/chat-input/ChatInput";
import { useAuth } from "./lib/auth/SupabaseAuth";
import SignInModal from "./lib/auth/SignIn"; import SignInModal from "./lib/auth/SignIn";
import { resetAppState } from "./appState"; import { useAuth } from "./lib/auth/SupabaseAuth";
const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev import { useChat } from "./hooks/useChat";
import {
// Default selectors chapterData,
const DEFAULTS = { DEFAULTS,
grade: "Choose standard", getChapterLabel,
subject: "choose-subject", getSubjectLabel,
chapter: "choose-chapter", gradeItems,
}; subjectItems,
} from "./data/curriculum";
/* ─── Selector data ─── */
const gradeItems = [
{ id: "grade-6", label: "Grade 6", icon: "backpack" },
{ id: "grade-7", label: "Grade 7", icon: "auto_stories" },
{ id: "grade-8", label: "Grade 8", icon: "menu_book" },
{ id: "grade-9", label: "Grade 9", icon: "science" },
{ id: "grade-10", label: "Grade 10", icon: "calculate" },
{ id: "grade-11", label: "Grade 11", icon: "psychology" },
{ id: "grade-12", label: "Grade 12", icon: "school" },
];
const subjectItems = [
{ id: "math", label: "Mathematics" },
{ id: "science", label: "Science" },
{ id: "history", label: "History" },
{ id: "language", label: "Language Arts" },
{ id: "english", label: "English" },
{ id: "geography", label: "Geography" },
];
const chapterData = {
math: [
{ id: "ch-1", label: "Chapter 1: Rational Numbers" },
{ id: "ch-2", label: "Chapter 2: Linear Equations" },
{ id: "ch-3", label: "Chapter 3: Coordinate Geometry" },
{ id: "ch-4", label: "Chapter 4: Algebra" },
{ id: "ch-5", label: "Chapter 5: Geometry" },
{ id: "ch-6", label: "Chapter 6: Trigonometry" },
],
science: [
{ id: "ch-1", label: "Chapter 1: Nutrition in Plants" },
{ id: "ch-2", label: "Chapter 2: Photosynthesis" },
{ id: "ch-3", label: "Chapter 3: Human Physiology" },
{ id: "ch-4", label: "Chapter 4: Chemistry Basics" },
{ id: "ch-5", label: "Chapter 5: Motion & Force" },
{ id: "ch-6", label: "Chapter 6: Electricity" },
],
history: [
{ id: "ch-1", label: "Chapter 1: Early Civilizations" },
{ id: "ch-2", label: "Chapter 2: Ancient India" },
{ id: "ch-3", label: "Chapter 3: Medieval Period" },
{ id: "ch-4", label: "Chapter 4: Modern India" },
{ id: "ch-5", label: "Chapter 5: World Wars" },
{ id: "ch-6", label: "Chapter 6: Independence Movement" },
],
language: [
{ id: "ch-1", label: "Chapter 1: Grammar Basics" },
{ id: "ch-2", label: "Chapter 2: Comprehension" },
{ id: "ch-3", label: "Chapter 3: Creative Writing" },
{ id: "ch-4", label: "Chapter 4: Literature" },
{ id: "ch-5", label: "Chapter 5: Vocabulary" },
{ id: "ch-6", label: "Chapter 6: Composition" },
],
english: [
{ id: "ch-1", label: "Chapter 1: Reading Skills" },
{ id: "ch-2", label: "Chapter 2: Writing Skills" },
{ id: "ch-3", label: "Chapter 3: Grammar" },
{ id: "ch-4", label: "Chapter 4: Poetry" },
{ id: "ch-5", label: "Chapter 5: Prose" },
{ id: "ch-6", label: "Chapter 6: Drama" },
],
geography: [
{ id: "ch-1", label: "Chapter 1: Earth & Universe" },
{ id: "ch-2", label: "Chapter 2: Landforms" },
{ id: "ch-3", label: "Chapter 3: Climate & Weather" },
{ id: "ch-4", label: "Chapter 4: Natural Resources" },
{ id: "ch-5", label: "Chapter 5: Maps & Atlas" },
{ id: "ch-6", label: "Chapter 6: Human Geography" },
],
};
function App() { function App() {
const { isAuthenticated, user, signIn, signUp, signOut, getToken } = const { isAuthenticated, user, signIn, signUp, signOut } = useAuth();
useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const routeChatId = useMatch("/chat/:chatId")?.params.chatId ?? null; const routeChatId = useMatch("/chat/:chatId")?.params.chatId ?? null;
// Guards deep-link loading so it only fires once per route/authenticated change. const [showSignIn, setShowSignIn] = useState(false);
const chatLoadHandledRef = useRef(null); const requestSignIn = useCallback(() => setShowSignIn(true), []);
const [messages, setMessages] = useState([]);
const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
/* ─── Subject label resolution (ID → display name) ─── */ const chat = useChat({
const getSubjectLabel = (id) => { isAuthenticated,
if (!id || id === "choose-subject") return "Choose Subject"; routeChatId,
return subjectItems.find((s) => s.id === id)?.label || id; navigate,
}; onSignInRequired: requestSignIn,
/* ─── Chapter list resolution (subject ID → array) ─── */
const getChaptersForSubject = (subjectId) => {
return chapterData[subjectId] || [];
};
/* ─── Chapter label resolution (ID → display name) ─── */
const getChapterLabel = (chapterId) => {
if (!chapterId || chapterId === "choose-chapter") return "Choose Chapter";
return chapterId;
};
const [activeChat, setActiveChat] = useState(null);
const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade);
const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter);
const [isTyping, setIsTyping] = useState(false);
const [sessions, setSessions] = useState([]);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [selectorStep, setSelectorStep] = useState("grade"); // grade → subject → chapter → chat
const [showSignIn, setShowSignIn] = useState(false); // modal is on-demand now
const [anonRemaining, setAnonRemaining] = useState(5); // anonymous free-message budget
const abortRef = useRef(null);
const hasMountedRef = useRef(false); // track first mount to avoid resetting on initial load
const isAnonymous = !isAuthenticated;
// Reset all user-scoped state whenever authentication changes.
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
if (isAuthenticated) loadSessions();
return;
}
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
const next = resetAppState();
setMessages(next.messages);
setActiveChat(next.activeChat);
setActiveGrade(next.activeGrade);
setActiveSubject(next.activeSubject);
setActiveChapter(next.activeChapter);
setIsTyping(next.isTyping);
setSelectorStep(next.selectorStep);
setSessions(next.sessions);
setShowSignIn(false);
setAnonRemaining(5); // a fresh anonymous budget per auth transition (incl. sign-out)
if (isAuthenticated) loadSessions();
// After an auth transition, drop any chat URL we only reached in-app
// (e.g. an anonymous trial, or a session opened before sign-out). A cold
// deep link (chatLoadHandledRef.current === null) is left alone so the
// deep-link effect below can restore it once auth restore completes.
if (chatLoadHandledRef.current === routeChatId) {
chatLoadHandledRef.current = null;
navigate("/");
}
}, [isAuthenticated, navigate]);
// Deep link: landing on /chat/:chatId loads that conversation (signed-in
// users only — anonymous trials are in-memory, so a refreshed trial URL
// simply starts fresh, matching the "refresh loses the conversation" rule).
useEffect(() => {
if (!routeChatId || !isAuthenticated) return;
if (chatLoadHandledRef.current === routeChatId) return;
chatLoadHandledRef.current = routeChatId;
handleChatSelect(routeChatId);
}, [routeChatId, isAuthenticated]);
// ─── Auth handlers ───
const handleAuthError = (msg) => {
setMessages((prev) => [
...prev,
{ id: Date.now(), role: "assistant", text: `⚠️ ${msg}` },
]);
};
const loadSessions = async () => {
if (!isAuthenticated) return; // sessions require an account
setIsLoadingSessions(true);
try {
const token = await getToken();
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/sessions`, {
headers,
credentials: "include",
}); });
if (res.ok) {
const data = await res.json();
setSessions(data);
}
} catch (err) {
console.error("Failed to load sessions:", err);
} finally {
setIsLoadingSessions(false);
}
};
const handleNewChat = () => { const {
// Abort any in-flight request messages,
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
// Clear everything
setMessages([]);
setActiveChat(null);
setActiveGrade(DEFAULTS.grade);
setActiveSubject(DEFAULTS.subject);
setActiveChapter(DEFAULTS.chapter);
setIsTyping(false);
// Reset selector to grade step
setSelectorStep("grade");
// Refresh sessions list from backend
loadSessions();
// Back to the home URL
navigate("/");
chatLoadHandledRef.current = null;
};
const handleChatSelect = async (sessionId) => {
if (!isAuthenticated) return; // anonymous users have no persisted sessions
// Abort any in-flight request
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
setIsTyping(true);
setMessages([]);
try {
const token = await getToken();
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, {
headers,
credentials: "include",
});
if (!res.ok) {
throw new Error("Session not found");
}
const data = await res.json();
// Set the session's context (grade, subject, chapter)
if (data.session) {
setActiveGrade(data.session.grade || DEFAULTS.grade);
setActiveSubject(data.session.subject || DEFAULTS.subject);
setActiveChapter(data.session.chapter || DEFAULTS.chapter);
}
// Move to chat view
setSelectorStep("chat");
// Load the messages
if (data.messages) {
setMessages(data.messages.map((m) => ({ ...m, streaming: false })));
}
setActiveChat(sessionId);
// Mark as handled so the deep-link effect doesn't re-fetch it.
chatLoadHandledRef.current = sessionId;
navigate(`/chat/${sessionId}`, { replace: true });
} catch (err) {
console.error("Failed to load session:", err);
setMessages([
{
id: Date.now(),
role: "assistant",
text: `⚠️ Could not load that chat. It may have been deleted.`,
},
]);
} finally {
setIsTyping(false);
}
};
const handleDeleteChat = async (sessionId) => {
try {
const token = await getToken();
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, {
method: "DELETE",
headers,
credentials: "include",
});
if (res.ok) {
// If we were on the deleted chat, clear it
if (activeChat === sessionId) {
handleNewChat();
} else {
loadSessions();
}
}
} catch (err) {
console.error("Failed to delete session:", err);
}
};
const handleSend = useCallback(
async (text) => {
if (!text.trim() || isTyping) return;
// Hard client-side gate for anonymous users past the free budget.
if (isAnonymous && anonRemaining <= 0) {
setShowSignIn(true);
return;
}
// Abort any previous in-flight request
if (abortRef.current) {
abortRef.current.abort();
}
// Add user message immediately
const userMsg = { id: Date.now(), role: "user", text };
setMessages((prev) => [...prev, userMsg]);
setIsTyping(true);
const fetchChatId = activeChat;
// Build request body
const body = {
text: text.trim(),
chatId: fetchChatId,
grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade,
subject: activeSubject === DEFAULTS.subject ? undefined : activeSubject,
chapter: activeChapter === DEFAULTS.chapter ? undefined : activeChapter,
};
// Open SSE connection
const controller = new AbortController();
abortRef.current = controller;
const token = await getToken();
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`${API_BASE}/api/chat`, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify(body),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
// Anonymous budget exhausted server-side → show the sign-in gate.
if (response.status === 403) {
let data = null;
try {
data = await response.json();
} catch {
/* non-JSON body */
}
if (data?.code === "SIGNIN_REQUIRED") {
setAnonRemaining(0);
setIsTyping(false);
abortRef.current = null;
return;
}
}
throw new Error(`Server error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantText = "";
// Create placeholder assistant message
const assistantId = Date.now() + 1;
setMessages((prev) => [
...prev,
{ id: assistantId, role: "assistant", text: "", streaming: true },
]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const jsonStr = line.slice(6);
if (jsonStr === "[DONE]") continue;
try {
const data = JSON.parse(jsonStr);
if (data.type === "session") {
setActiveChat(data.id);
if (isAuthenticated) {
// Persist the deep-link without dispatching a router
// navigation. React Router navigation rerenders the route
// tree during the active stream; history.replaceState only
// updates the address bar, so chat state remains untouched.
chatLoadHandledRef.current = data.id;
window.history.replaceState(
window.history.state,
"",
`/chat/${encodeURIComponent(data.id)}`,
);
}
} else if (data.type === "limit") {
// Anonymous free-message budget remaining (server truth).
setAnonRemaining(data.remaining);
} else if (data.type === "chunk") {
assistantText += data.content;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, text: assistantText } : m,
),
);
} else if (data.type === "done") {
// Mark as non-streaming
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
);
} else if (data.type === "error") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
text: `⚠️ Error: ${data.message}`,
streaming: false,
}
: m,
),
);
}
} catch {
// Skip malformed SSE frames
}
}
}
setIsTyping(false);
abortRef.current = null;
// Refresh the sidebar without changing routes. The active session id
// already lives in state and is reused by the next message.
if (isAuthenticated) loadSessions();
})
.catch((err) => {
if (err.name === "AbortError") return; // intentional abort
setIsTyping(false);
setMessages((prev) => [
...prev,
{
id: Date.now() + 1,
role: "assistant",
text: `⚠️ Connection error: ${err.message}. Make sure the backend is running on port 3001.`,
},
]);
abortRef.current = null;
});
},
[
isTyping,
activeChat, activeChat,
activeGrade, activeGrade,
activeSubject, activeSubject,
activeChapter, activeChapter,
isAnonymous, selectorStep,
sessions,
isLoadingSessions,
isTyping,
anonRemaining, anonRemaining,
isAuthenticated, setActiveGrade,
], setActiveSubject,
); setActiveChapter,
setSelectorStep,
addError,
newChat,
selectChat,
deleteChat,
send,
} = chat;
const handlePromptClick = useCallback( const selectSubject = (id) => {
(promptText) => { setActiveSubject(id);
handleSend(promptText);
},
[handleSend],
);
/* ─── Selector step handlers ─── */
const handleGradeSelect = (item) => {
setActiveGrade(item.label);
setSelectorStep("subject");
};
const handleSubjectSelect = (item) => {
setActiveSubject(item.id);
setActiveChapter(DEFAULTS.chapter); setActiveChapter(DEFAULTS.chapter);
setSelectorStep("chapter"); if (selectorStep !== "chat") setSelectorStep("subject");
}; };
const handleChapterSelect = (item) => { const selectChapter = (chapter) => {
setActiveChapter(item.label); setActiveChapter(chapter);
setSelectorStep("chat"); if (selectorStep !== "chat") setSelectorStep("chapter");
}; };
/* ─── Selector step data ─── */
const selectorData = { const selectorData = {
grade: { grade: {
title: "Select your standard", title: "Select your standard",
subtitle: "Choose the grade you are studying in", subtitle: "Choose the grade you are studying in",
items: gradeItems, items: gradeItems,
selected: activeGrade, selectedValue: activeGrade,
onSelect: handleGradeSelect, compareField: "label",
onSelect: (item) => {
setActiveGrade(item.label);
setSelectorStep("subject");
},
}, },
subject: { subject: {
title: "Select your subject", title: "Select your subject",
subtitle: `Chose by grade – ${activeGrade}`, subtitle: `Chosen grade – ${activeGrade}`,
items: subjectItems, items: subjectItems,
selectedValue: activeSubject, selectedValue: activeSubject,
onSelect: handleSubjectSelect,
compareField: "id", compareField: "id",
onSelect: (item) => {
setActiveSubject(item.id);
setActiveChapter(DEFAULTS.chapter);
setSelectorStep("chapter");
},
}, },
chapter: { chapter: {
title: "Select a chapter", title: "Select a chapter",
subtitle: `Subject – ${getSubjectLabel(activeSubject)}`, subtitle: `Subject – ${getSubjectLabel(activeSubject)}`,
items: getChaptersForSubject(activeSubject), items: chapterData[activeSubject] || [],
selectedValue: activeChapter, selectedValue: activeChapter,
onSelect: handleChapterSelect,
compareField: "label", compareField: "label",
onSelect: (item) => {
setActiveChapter(item.label);
setSelectorStep("chat");
},
}, },
}; };
// ─── Render — the app is always accessible; sign-in is on-demand ─── const authenticate = async (action, email, password) => {
try {
await action(email, password);
setShowSignIn(false);
} catch (error) {
addError(error.message || "Authentication failed");
}
};
const isAnonymous = !isAuthenticated;
return ( return (
<> <>
{showSignIn && ( {showSignIn && (
<SignInModal <SignInModal
onClose={() => setShowSignIn(false)} onClose={() => setShowSignIn(false)}
onSignIn={async (email, password) => { onSignIn={(email, password) => authenticate(signIn, email, password)}
try { onSignUp={(email, password) => authenticate(signUp, email, password)}
await signIn(email, password); onError={addError}
setShowSignIn(false);
} catch (err) {
handleAuthError(err.message || "Sign in failed");
}
}}
onSignUp={async (email, password) => {
try {
await signUp(email, password);
setShowSignIn(false);
} catch (err) {
handleAuthError(err.message || "Sign up failed");
}
}}
onError={handleAuthError}
/> />
)} )}
<div className="page"> <div className="page">
<Sidebar <Sidebar
activeSubject={activeSubject} activeSubject={activeSubject}
onSubjectChange={(id) => { onSubjectChange={selectSubject}
setActiveSubject(id);
setActiveChapter(DEFAULTS.chapter);
if (selectorStep !== "chat") {
setSelectorStep("subject");
}
}}
activeChat={activeChat} activeChat={activeChat}
onChatSelect={handleChatSelect} onChatSelect={selectChat}
onNewChat={handleNewChat} onNewChat={newChat}
sessions={sessions} sessions={sessions}
onDeleteChat={handleDeleteChat} onDeleteChat={deleteChat}
isLoadingSessions={isLoadingSessions} isLoadingSessions={isLoadingSessions}
showUpgrade={false} showUpgrade={false}
isAnonymous={isAnonymous} isAnonymous={isAnonymous}
onSignInRequest={() => setShowSignIn(true)} onSignInRequest={requestSignIn}
user={user} user={user}
onSignOut={signOut} onSignOut={signOut}
/> />
@@ -598,26 +142,15 @@ function App() {
<div className="page__main"> <div className="page__main">
<TopNav <TopNav
activeGrade={activeGrade} activeGrade={activeGrade}
onGradeChange={(g) => setActiveGrade(g)} onGradeChange={setActiveGrade}
subjectItems={subjectItems} subjectItems={subjectItems}
chapterData={chapterData} chapterData={chapterData}
activeSubject={activeSubject} activeSubject={activeSubject}
activeChapter={activeChapter} activeChapter={activeChapter}
onChapterChange={(ch) => { onChapterChange={selectChapter}
setActiveChapter(ch); onSubjectChange={selectSubject}
if (selectorStep !== "chat") {
setSelectorStep("chapter");
}
}}
onSubjectChange={(id) => {
setActiveSubject(id);
setActiveChapter(DEFAULTS.chapter);
if (selectorStep !== "chat") {
setSelectorStep("subject");
}
}}
isAnonymous={isAnonymous} isAnonymous={isAnonymous}
onSignInRequest={() => setShowSignIn(true)} onSignInRequest={requestSignIn}
user={user} user={user}
onSignOut={signOut} onSignOut={signOut}
/> />
@@ -625,10 +158,7 @@ function App() {
<div className="main-content"> <div className="main-content">
<ChatHistory <ChatHistory
messages={messages} messages={messages}
activeSubject={activeSubject} onPromptClick={send}
activeGrade={activeGrade}
activeChapter={activeChapter}
onPromptClick={handlePromptClick}
isTyping={isTyping} isTyping={isTyping}
selectorStep={selectorStep} selectorStep={selectorStep}
selectorData={ selectorData={
@@ -638,11 +168,11 @@ function App() {
</div> </div>
<ChatInput <ChatInput
onSend={handleSend} onSend={send}
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`} placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
disabled={isTyping} disabled={isTyping}
exhausted={isAnonymous && anonRemaining <= 0} exhausted={isAnonymous && anonRemaining <= 0}
onSignInRequest={() => setShowSignIn(true)} onSignInRequest={requestSignIn}
/> />
</div> </div>
</div> </div>
-18
View File
@@ -1,18 +0,0 @@
export const DEFAULT_APP_STATE = Object.freeze({
messages: [],
activeChat: null,
activeGrade: "Choose standard",
activeSubject: "choose-subject",
activeChapter: "choose-chapter",
isTyping: false,
sessions: [],
selectorStep: "grade",
});
export function resetAppState() {
return {
...DEFAULT_APP_STATE,
messages: [],
sessions: [],
};
}
@@ -2,10 +2,14 @@
ChatHistory Component — padhle (Refined Workspace) ChatHistory Component — padhle (Refined Workspace)
======================================== */ ======================================== */
/* Main Chat History */ /* Main Chat History — the only scroll container in the app shell.
min-height: 0 lets it shrink below content height so it scrolls
internally instead of stretching the page. */
.chat-history { .chat-history {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
background-color: var(--color-background); background-color: var(--color-background);
} }
@@ -91,7 +95,10 @@
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
background-color: var(--color-surface-lowest); background-color: var(--color-surface-lowest);
cursor: pointer; cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast); transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 120px; min-height: 120px;
@@ -142,7 +149,9 @@
line-height: 1; line-height: 1;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
opacity: 0.5; opacity: 0.5;
transition: color var(--transition-fast), opacity var(--transition-fast); transition:
color var(--transition-fast),
opacity var(--transition-fast);
} }
.chat-history__bento-card:hover .chat-history__bento-arrow { .chat-history__bento-card:hover .chat-history__bento-arrow {
@@ -205,7 +214,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
padding: 24px 0; padding: 24px;
width: 100%; width: 100%;
max-width: var(--container-max); max-width: var(--container-max);
margin: 0 auto; margin: 0 auto;
@@ -1,94 +1,87 @@
/* ========================================
ChatHistory Component — padhle (Refined Workspace)
======================================== */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import Message from "../message/Message"; import Message from "../message/Message";
import SelectorFlow from "../selector-flow/SelectorFlow"; import SelectorFlow from "../selector-flow/SelectorFlow";
import "./ChatHistory.css"; import "./ChatHistory.css";
const suggestions = [
{
icon: "eco",
filled: true,
color: "--color-accent-green",
background: "--color-accent-green-bg",
before: "Explain how ",
highlight: "photosynthesis",
after: " works in simple terms",
},
{
icon: "calculate",
color: "--color-accent-blue",
background: "--color-accent-blue-bg",
before: "Help me understand ",
highlight: "derivatives",
after: " in calculus",
},
{
icon: "account_balance",
color: "--color-accent-orange",
background: "--color-accent-orange-bg",
before: "What caused the ",
highlight: "Industrial",
after: " Revolution?",
},
{
icon: "edit",
filled: true,
color: "--color-accent-purple",
background: "--color-accent-purple-bg",
before: "Help me analyze themes in ",
highlight: "Romeo and Juliet",
after: "",
},
];
const ChatHistory = ({ const ChatHistory = ({
messages, messages,
activeSubject,
activeGrade,
activeChapter,
onPromptClick, onPromptClick,
isTyping, isTyping,
selectorStep, selectorStep,
selectorData, selectorData,
}) => { }) => {
const hasMessages = messages.length > 0;
const isSelector = selectorStep !== "chat";
const suggestions = [
{
id: 1,
icon: "eco",
iconFilled: true,
color: "--color-accent-green",
colorBg: "--color-accent-green-bg",
keywords: ["photosynthesis"],
text: "Explain how ",
textEnd: " works in simple terms",
},
{
id: 2,
icon: "calculate",
iconFilled: false,
color: "--color-accent-blue",
colorBg: "--color-accent-blue-bg",
keywords: ["derivatives"],
text: "Help me understand ",
textEnd: " in calculus",
},
{
id: 3,
icon: "account_balance",
iconFilled: false,
color: "--color-accent-orange",
colorBg: "--color-accent-orange-bg",
keywords: ["Industrial"],
text: "What caused the ",
textEnd: " Revolution?",
},
{
id: 4,
icon: "edit",
iconFilled: true,
color: "--color-accent-purple",
colorBg: "--color-accent-purple-bg",
keywords: ["Romeo and Juliet"],
text: "Help me analyze themes in ",
textEnd: "",
},
];
const fullPromptTexts = suggestions.map(
(s) => s.text + s.keywords.join(" ") + s.textEnd,
);
const streamingMsg = messages.find((m) => m.streaming);
const historyRef = useRef(null); const historyRef = useRef(null);
const autoFollowRef = useRef(true);
const hasMessages = messages.length > 0;
const isStreaming = messages.some((message) => message.streaming);
const latestText = messages[messages.length - 1]?.text;
// Scroll only the chat pane, and only when a message is added. Streaming const scrollToBottom = () => {
// token updates must not move the page or restart a smooth-scroll animation.
useEffect(() => {
const history = historyRef.current; const history = historyRef.current;
if (history) history.scrollTop = history.scrollHeight; if (history) history.scrollTop = history.scrollHeight;
};
useEffect(() => {
autoFollowRef.current = true;
scrollToBottom();
}, [messages.length]); }, [messages.length]);
/* ─── Messages mode ─── */ useEffect(() => {
if (autoFollowRef.current) scrollToBottom();
}, [latestText]);
const handleScroll = () => {
const history = historyRef.current;
if (!history) return;
autoFollowRef.current =
history.scrollHeight - history.scrollTop - history.clientHeight < 80;
};
if (hasMessages) { if (hasMessages) {
return ( return (
<div className="chat-history" ref={historyRef}> <div className="chat-history" ref={historyRef} onScroll={handleScroll}>
<div className="chat-history__list"> <div className="chat-history__list">
{messages.map((message) => ( {messages.map((message) => (
<Message <Message key={message.id} message={message} />
key={message.id}
message={message}
/>
))} ))}
{isTyping && !streamingMsg && ( {isTyping && !isStreaming && (
<Message <Message
message={{ message={{
id: "typing", id: "typing",
@@ -103,34 +96,22 @@ const ChatHistory = ({
); );
} }
/* ─── Selector mode (grade → subject → chapter) ─── */ if (selectorStep !== "chat" && selectorData) {
if (isSelector && selectorData) {
return ( return (
<div className="chat-history"> <div className="chat-history">
<SelectorFlow <SelectorFlow {...selectorData} step={selectorStep} />
title={selectorData.title}
subtitle={selectorData.subtitle}
selected={selectorData.selected}
items={selectorData.items}
onSelect={selectorData.onSelect}
step={selectorStep}
/>
</div> </div>
); );
} }
/* ─── Welcome / suggestions mode ─── */
return ( return (
<div className="chat-history"> <div className="chat-history">
<div className="chat-history__focus"> <div className="chat-history__focus">
{/* Welcome Header */}
<div className="chat-history__welcome"> <div className="chat-history__welcome">
<div className="chat-history__welcome-icon"> <div className="chat-history__welcome-icon">
<span <span
className="material-symbols-outlined" className="material-symbols-outlined"
style={{ style={{ fontWeight: 200 }}
fontWeight: 200,
}}
> >
menu_book menu_book
</span> </span>
@@ -138,87 +119,49 @@ const ChatHistory = ({
auto_awesome auto_awesome
</span> </span>
</div> </div>
<h2 className="chat-history__welcome-title"> <h2 className="chat-history__welcome-title">Welcome to padhle</h2>
Welcome to padhle
</h2>
<p className="chat-history__welcome-subtitle"> <p className="chat-history__welcome-subtitle">
Your AI tutor for grades 6–12. Your AI tutor for grades 6–12. Ask anything!
Ask anything!
</p> </p>
</div> </div>
{/* Bento Grid Suggestions */}
<div className="chat-history__bento"> <div className="chat-history__bento">
{suggestions.map((suggestion) => { {suggestions.map((suggestion) => {
const colorVar = const color = `var(${suggestion.color})`;
suggestion.color.replace( const prompt =
"--", suggestion.before + suggestion.highlight + suggestion.after;
"var(",
);
const bgColorVar =
suggestion.colorBg.replace(
"--",
"var(",
);
return ( return (
<button <button
key={ key={suggestion.highlight}
suggestion.id
}
className="chat-history__bento-card" className="chat-history__bento-card"
onClick={() => { onClick={() => onPromptClick(prompt)}
const text =
fullPromptTexts[
suggestion.id -
1
];
onPromptClick(
text,
);
}}
> >
<div <div
className="chat-history__bento-icon-wrap" className="chat-history__bento-icon-wrap"
style={{ style={{ backgroundColor: `var(${suggestion.background})` }}
backgroundColor: `var(${bgColorVar})`,
}}
> >
<span <span
className="material-symbols-outlined" className="material-symbols-outlined"
style={{ style={{
color: `var(${colorVar})`, color,
fontWeight: suggestion.iconFilled fontWeight: suggestion.filled ? 700 : 200,
? 700
: 200,
}} }}
> >
{ {suggestion.icon}
suggestion.icon
}
</span> </span>
</div> </div>
<div className="chat-history__bento-text"> <div className="chat-history__bento-text">
<span className="chat-history__bento-text-main"> <span className="chat-history__bento-text-main">
{ {suggestion.before}
suggestion.text
}
</span> </span>
<span <span
className="chat-history__bento-text-highlight" className="chat-history__bento-text-highlight"
style={{ style={{ color, fontWeight: 600 }}
color: `var(${colorVar})`,
fontWeight: 600,
}}
> >
{ {suggestion.highlight}
suggestion
.keywords[0]
}
</span> </span>
{ {suggestion.after}
suggestion.textEnd
}
</div> </div>
<span className="material-symbols-outlined chat-history__bento-arrow"> <span className="material-symbols-outlined chat-history__bento-arrow">
arrow_forward arrow_forward
@@ -228,24 +171,18 @@ const ChatHistory = ({
})} })}
</div> </div>
{/* Info Panel */}
<div className="chat-history__info"> <div className="chat-history__info">
<div className="chat-history__info-icon-wrap"> <div className="chat-history__info-icon-wrap">
<span className="material-symbols-outlined"> <span className="material-symbols-outlined">lightbulb</span>
lightbulb
</span>
</div> </div>
<div className="chat-history__info-content"> <div className="chat-history__info-content">
<h4 className="chat-history__info-title"> <h4 className="chat-history__info-title">
Focused on your learning Focused on your learning
</h4> </h4>
<p className="chat-history__info-text"> <p className="chat-history__info-text">
I'll answer questions I'll answer questions related to your selected subject and chapter
related to your selected
subject and chapter
<br /> <br />
using your textbook using your textbook content.
content.
</p> </p>
</div> </div>
</div> </div>
@@ -32,7 +32,9 @@
border-radius: 32px; border-radius: 32px;
background-color: var(--color-surface-lowest); background-color: var(--color-surface-lowest);
box-shadow: var(--shadow-input); box-shadow: var(--shadow-input);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast); transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
} }
.chat-input__form:focus-within { .chat-input__form:focus-within {
@@ -52,7 +54,9 @@
background-color: var(--color-surface-lowest); background-color: var(--color-surface-lowest);
cursor: pointer; cursor: pointer;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
transition: background-color var(--transition-fast), color var(--transition-fast); transition:
background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -83,6 +87,10 @@
max-height: 128px; max-height: 128px;
} }
.chat-input__field:focus-visible {
outline: none;
}
.chat-input__field::placeholder { .chat-input__field::placeholder {
color: var(--color-outline); color: var(--color-outline);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
@@ -113,7 +121,9 @@
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
transition: background-color var(--transition-fast), color var(--transition-fast); transition:
background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -140,7 +150,10 @@
cursor: not-allowed; cursor: not-allowed;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
opacity: 0.3; opacity: 0.3;
transition: opacity var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast); transition:
opacity var(--transition-fast),
background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0; flex-shrink: 0;
} }
+34 -11
View File
@@ -81,6 +81,7 @@
font-size: 15px; font-size: 15px;
line-height: var(--line-height-relaxed); line-height: var(--line-height-relaxed);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
color: var(--color-on-surface); color: var(--color-on-surface);
} }
@@ -93,9 +94,15 @@
.message__text.markdown-body { .message__text.markdown-body {
white-space: normal; white-space: normal;
} }
.markdown-body > *:first-child { margin-top: 0; } .markdown-body > *:first-child {
.markdown-body > *:last-child { margin-bottom: 0; } margin-top: 0;
.markdown-body p { margin: 0 0 12px; } }
.markdown-body > *:last-child {
margin-bottom: 0;
}
.markdown-body p {
margin: 0 0 12px;
}
.markdown-body h1, .markdown-body h1,
.markdown-body h2, .markdown-body h2,
.markdown-body h3, .markdown-body h3,
@@ -105,16 +112,26 @@
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
} }
.markdown-body h1 { font-size: 22px; } .markdown-body h1 {
.markdown-body h2 { font-size: 19px; } font-size: 22px;
.markdown-body h3 { font-size: 16px; } }
.markdown-body h4 { font-size: 15px; } .markdown-body h2 {
font-size: 19px;
}
.markdown-body h3 {
font-size: 16px;
}
.markdown-body h4 {
font-size: 15px;
}
.markdown-body ul, .markdown-body ul,
.markdown-body ol { .markdown-body ol {
margin: 0 0 12px; margin: 0 0 12px;
padding-left: 22px; padding-left: 22px;
} }
.markdown-body li { margin-bottom: 4px; } .markdown-body li {
margin-bottom: 4px;
}
.markdown-body blockquote { .markdown-body blockquote {
margin: 0 0 12px; margin: 0 0 12px;
padding: 6px 14px; padding: 6px 14px;
@@ -169,7 +186,9 @@
text-decoration: underline; text-decoration: underline;
} }
/* KaTeX math inherits font size */ /* KaTeX math inherits font size */
.markdown-body .katex { font-size: 1.05em; } .markdown-body .katex {
font-size: 1.05em;
}
/* --- ACTION BUTTONS --- */ /* --- ACTION BUTTONS --- */
.message__actions { .message__actions {
@@ -191,7 +210,9 @@
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
cursor: pointer; cursor: pointer;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
transition: background-color var(--transition-fast), color var(--transition-fast); transition:
background-color var(--transition-fast),
color var(--transition-fast);
} }
.message__action:hover { .message__action:hover {
@@ -244,7 +265,9 @@
} }
@keyframes typingBounce { @keyframes typingBounce {
0%, 80%, 100% { 0%,
80%,
100% {
transform: scale(0.6); transform: scale(0.6);
opacity: 0.4; opacity: 0.4;
} }
+83
View File
@@ -0,0 +1,83 @@
export const DEFAULTS = {
grade: "Choose standard",
subject: "choose-subject",
chapter: "choose-chapter",
};
export const gradeItems = [
{ id: "grade-6", label: "Grade 6", icon: "backpack" },
{ id: "grade-7", label: "Grade 7", icon: "auto_stories" },
{ id: "grade-8", label: "Grade 8", icon: "menu_book" },
{ id: "grade-9", label: "Grade 9", icon: "science" },
{ id: "grade-10", label: "Grade 10", icon: "calculate" },
{ id: "grade-11", label: "Grade 11", icon: "psychology" },
{ id: "grade-12", label: "Grade 12", icon: "school" },
];
export const subjectItems = [
{ id: "math", label: "Mathematics" },
{ id: "science", label: "Science" },
{ id: "history", label: "History" },
{ id: "language", label: "Language Arts" },
{ id: "english", label: "English" },
{ id: "geography", label: "Geography" },
];
export const chapterData = {
math: [
{ id: "ch-1", label: "Chapter 1: Rational Numbers" },
{ id: "ch-2", label: "Chapter 2: Linear Equations" },
{ id: "ch-3", label: "Chapter 3: Coordinate Geometry" },
{ id: "ch-4", label: "Chapter 4: Algebra" },
{ id: "ch-5", label: "Chapter 5: Geometry" },
{ id: "ch-6", label: "Chapter 6: Trigonometry" },
],
science: [
{ id: "ch-1", label: "Chapter 1: Nutrition in Plants" },
{ id: "ch-2", label: "Chapter 2: Photosynthesis" },
{ id: "ch-3", label: "Chapter 3: Human Physiology" },
{ id: "ch-4", label: "Chapter 4: Chemistry Basics" },
{ id: "ch-5", label: "Chapter 5: Motion & Force" },
{ id: "ch-6", label: "Chapter 6: Electricity" },
],
history: [
{ id: "ch-1", label: "Chapter 1: Early Civilizations" },
{ id: "ch-2", label: "Chapter 2: Ancient India" },
{ id: "ch-3", label: "Chapter 3: Medieval Period" },
{ id: "ch-4", label: "Chapter 4: Modern India" },
{ id: "ch-5", label: "Chapter 5: World Wars" },
{ id: "ch-6", label: "Chapter 6: Independence Movement" },
],
language: [
{ id: "ch-1", label: "Chapter 1: Grammar Basics" },
{ id: "ch-2", label: "Chapter 2: Comprehension" },
{ id: "ch-3", label: "Chapter 3: Creative Writing" },
{ id: "ch-4", label: "Chapter 4: Literature" },
{ id: "ch-5", label: "Chapter 5: Vocabulary" },
{ id: "ch-6", label: "Chapter 6: Composition" },
],
english: [
{ id: "ch-1", label: "Chapter 1: Reading Skills" },
{ id: "ch-2", label: "Chapter 2: Writing Skills" },
{ id: "ch-3", label: "Chapter 3: Grammar" },
{ id: "ch-4", label: "Chapter 4: Poetry" },
{ id: "ch-5", label: "Chapter 5: Prose" },
{ id: "ch-6", label: "Chapter 6: Drama" },
],
geography: [
{ id: "ch-1", label: "Chapter 1: Earth & Universe" },
{ id: "ch-2", label: "Chapter 2: Landforms" },
{ id: "ch-3", label: "Chapter 3: Climate & Weather" },
{ id: "ch-4", label: "Chapter 4: Natural Resources" },
{ id: "ch-5", label: "Chapter 5: Maps & Atlas" },
{ id: "ch-6", label: "Chapter 6: Human Geography" },
],
};
export const getSubjectLabel = (id) =>
!id || id === DEFAULTS.subject
? "Choose Subject"
: subjectItems.find((subject) => subject.id === id)?.label || id;
export const getChapterLabel = (chapter) =>
!chapter || chapter === DEFAULTS.chapter ? "Choose Chapter" : chapter;
+15
View File
@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
chapterData,
DEFAULTS,
getChapterLabel,
getSubjectLabel,
} from "./curriculum.js";
test("resolves curriculum labels and defaults", () => {
assert.equal(getSubjectLabel("science"), "Science");
assert.equal(getSubjectLabel(DEFAULTS.subject), "Choose Subject");
assert.equal(getChapterLabel(DEFAULTS.chapter), "Choose Chapter");
assert.ok(chapterData.science.length > 0);
});
+301
View File
@@ -0,0 +1,301 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { chatApi } from "../lib/chatApi";
import { readSseData } from "../lib/readSseData";
import { DEFAULTS } from "../data/curriculum";
const errorMessage = (text) => ({
id: crypto.randomUUID(),
role: "assistant",
text: `⚠️ ${text}`,
});
export function useChat({
isAuthenticated,
routeChatId,
navigate,
onSignInRequired,
}) {
const [messages, setMessages] = useState([]);
const [activeChat, setActiveChat] = useState(null);
const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade);
const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter);
const [selectorStep, setSelectorStep] = useState("grade");
const [sessions, setSessions] = useState([]);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const [anonRemaining, setAnonRemaining] = useState(5);
const requestRef = useRef(null);
const handledRouteRef = useRef(null);
const mountedRef = useRef(false);
const abortRequest = useCallback(() => {
requestRef.current?.abort();
requestRef.current = null;
}, []);
const loadSessions = useCallback(async () => {
if (!isAuthenticated) return;
setIsLoadingSessions(true);
try {
const response = await chatApi.listSessions();
if (response.ok) setSessions(await response.json());
} catch (error) {
console.error("Failed to load sessions:", error);
} finally {
setIsLoadingSessions(false);
}
}, [isAuthenticated]);
const resetChat = useCallback(() => {
abortRequest();
setMessages([]);
setActiveChat(null);
setActiveGrade(DEFAULTS.grade);
setActiveSubject(DEFAULTS.subject);
setActiveChapter(DEFAULTS.chapter);
setSelectorStep("grade");
setIsTyping(false);
}, [abortRequest]);
const newChat = useCallback(() => {
resetChat();
handledRouteRef.current = null;
navigate("/");
loadSessions();
}, [loadSessions, navigate, resetChat]);
const selectChat = useCallback(
async (sessionId) => {
if (!isAuthenticated) return;
abortRequest();
const controller = new AbortController();
requestRef.current = controller;
setIsTyping(true);
setMessages([]);
try {
const response = await chatApi.getSession(sessionId, controller.signal);
if (!response.ok) throw new Error("Session not found");
const data = await response.json();
const session = data.session || {};
setActiveGrade(session.grade || DEFAULTS.grade);
setActiveSubject(session.subject || DEFAULTS.subject);
setActiveChapter(session.chapter || DEFAULTS.chapter);
setMessages(
(data.messages || []).map((message) => ({
...message,
streaming: false,
})),
);
setSelectorStep("chat");
setActiveChat(sessionId);
handledRouteRef.current = sessionId;
navigate(`/chat/${encodeURIComponent(sessionId)}`, { replace: true });
} catch (error) {
if (error.name !== "AbortError") {
console.error("Failed to load session:", error);
setMessages([
errorMessage("Could not load that chat. It may have been deleted."),
]);
}
} finally {
if (requestRef.current === controller) {
requestRef.current = null;
setIsTyping(false);
}
}
},
[abortRequest, isAuthenticated, navigate],
);
const deleteChat = useCallback(
async (sessionId) => {
try {
const response = await chatApi.deleteSession(sessionId);
if (!response.ok) return;
if (activeChat === sessionId) newChat();
else loadSessions();
} catch (error) {
console.error("Failed to delete session:", error);
}
},
[activeChat, loadSessions, newChat],
);
const send = useCallback(
async (value) => {
const text = value.trim();
if (!text || isTyping) return;
if (!isAuthenticated && anonRemaining <= 0) {
onSignInRequired();
return;
}
abortRequest();
const controller = new AbortController();
requestRef.current = controller;
const assistantId = crypto.randomUUID();
setMessages((current) => [
...current,
{ id: crypto.randomUUID(), role: "user", text },
{ id: assistantId, role: "assistant", text: "", streaming: true },
]);
setIsTyping(true);
try {
const response = await chatApi.send(
{
text,
chatId: activeChat,
grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade,
subject:
activeSubject === DEFAULTS.subject ? undefined : activeSubject,
chapter:
activeChapter === DEFAULTS.chapter ? undefined : activeChapter,
},
controller.signal,
);
if (!response.ok) {
const data = await response.json().catch(() => null);
if (response.status === 403 && data?.code === "SIGNIN_REQUIRED") {
setAnonRemaining(0);
onSignInRequired();
return;
}
throw new Error(`Server error: ${response.status}`);
}
let assistantText = "";
for await (const event of readSseData(response.body)) {
if (event.type === "session") {
setActiveChat(event.id);
if (isAuthenticated) {
handledRouteRef.current = event.id;
window.history.replaceState(
window.history.state,
"",
`/chat/${encodeURIComponent(event.id)}`,
);
}
} else if (event.type === "limit") {
setAnonRemaining(event.remaining);
} else if (event.type === "chunk") {
assistantText += event.content;
setMessages((current) =>
current.map((message) =>
message.id === assistantId
? { ...message, text: assistantText }
: message,
),
);
} else if (event.type === "error") {
assistantText = `⚠️ Error: ${event.message}`;
}
}
setMessages((current) =>
current.map((message) =>
message.id === assistantId
? { ...message, text: assistantText, streaming: false }
: message,
),
);
if (isAuthenticated) loadSessions();
} catch (error) {
if (error.name !== "AbortError") {
setMessages((current) =>
current.map((message) =>
message.id === assistantId
? {
...message,
text: `⚠️ Connection error: ${error.message}. Make sure the backend is running on port 3001.`,
streaming: false,
}
: message,
),
);
}
} finally {
if (requestRef.current === controller) {
requestRef.current = null;
setIsTyping(false);
}
}
},
[
abortRequest,
activeChat,
activeChapter,
activeGrade,
activeSubject,
anonRemaining,
isAuthenticated,
isTyping,
loadSessions,
onSignInRequired,
],
);
const addError = useCallback((message) => {
setMessages((current) => [...current, errorMessage(message)]);
}, []);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
loadSessions();
return;
}
resetChat();
setSessions([]);
setAnonRemaining(5);
loadSessions();
if (handledRouteRef.current) {
handledRouteRef.current = null;
navigate("/");
}
}, [isAuthenticated, loadSessions, navigate, resetChat]);
useEffect(() => {
if (
routeChatId &&
isAuthenticated &&
handledRouteRef.current !== routeChatId
) {
handledRouteRef.current = routeChatId;
selectChat(routeChatId);
}
}, [isAuthenticated, routeChatId, selectChat]);
useEffect(() => abortRequest, [abortRequest]);
return {
messages,
activeChat,
activeGrade,
activeSubject,
activeChapter,
selectorStep,
sessions,
isLoadingSessions,
isTyping,
anonRemaining,
setActiveGrade,
setActiveSubject,
setActiveChapter,
setSelectorStep,
addError,
newChat,
selectChat,
deleteChat,
send,
};
}
+1 -13
View File
@@ -36,21 +36,9 @@ export function AuthProvider({ children }) {
setUser(null); setUser(null);
}; };
const getToken = async () => {
// Token lives in httpOnly cookie — not accessible to JavaScript
return null;
};
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{ user, isAuthenticated: !!user, signIn, signUp, signOut }}
user,
isAuthenticated: !!user,
signIn,
signUp,
signOut,
getToken,
}}
> >
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
-9
View File
@@ -76,13 +76,4 @@ export const auth = {
} }
return data; return data;
}, },
/**
* Check if user is authenticated.
* Backend verifies the httpOnly cookie.
*/
async check() {
const { user } = await auth.me();
return !!user;
},
}; };
+17
View File
@@ -0,0 +1,17 @@
const request = (path, options) =>
fetch(path, { credentials: "include", ...options });
export const chatApi = {
listSessions: () => request("/api/sessions"),
getSession: (id, signal) =>
request(`/api/chat/${encodeURIComponent(id)}`, { signal }),
deleteSession: (id) =>
request(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }),
send: (body, signal) =>
request("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
}),
};
+29
View File
@@ -0,0 +1,29 @@
export async function* readSseData(body) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split("\n");
buffer = done ? "" : lines.pop() || "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line.startsWith("data: ")) continue;
const value = line.slice(6);
if (value === "[DONE]") continue;
try {
yield JSON.parse(value);
} catch {
// Ignore malformed frames; one bad event must not end the stream.
}
}
if (done) return;
}
}
+31
View File
@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readSseData } from "./readSseData.js";
const streamFrom = (chunks) =>
new ReadableStream({
start(controller) {
for (const chunk of chunks)
controller.enqueue(new TextEncoder().encode(chunk));
controller.close();
},
});
test("reads fragmented SSE data and ignores invalid frames", async () => {
const body = streamFrom([
'data: {"type":"sess',
'ion","id":"abc"}\n',
"event: ignored\n",
"data: not-json\n",
'data: {"type":"chunk","content":"Hi"}\n',
"data: [DONE]\n",
]);
const events = [];
for await (const event of readSseData(body)) events.push(event);
assert.deepEqual(events, [
{ type: "session", id: "abc" },
{ type: "chunk", content: "Hi" },
]);
});
+15 -3
View File
@@ -106,6 +106,15 @@
--shadow-input-focus: 0 2px 12px 0 rgba(0, 0, 0, 0.08); --shadow-input-focus: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
} }
/* App viewport — the shell owns scrolling, never the document.
A definite height here is what lets .chat-history become a real
scroll container instead of growing the page. */
html,
body,
#root {
height: 100%;
}
/* Body */ /* Body */
body { body {
font-family: var(--font-body); font-family: var(--font-body);
@@ -114,7 +123,7 @@ body {
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-on-surface); color: var(--color-on-surface);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
min-height: 100vh; overflow: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -122,7 +131,8 @@ body {
/* Page Frame — sidebar + main layout */ /* Page Frame — sidebar + main layout */
.page { .page {
display: flex; display: flex;
min-height: 100vh; height: 100%;
min-height: 0;
width: 100%; width: 100%;
} }
@@ -133,6 +143,7 @@ body {
flex-direction: column; flex-direction: column;
margin-left: var(--sidebar-width); margin-left: var(--sidebar-width);
min-width: 0; min-width: 0;
min-height: 0;
} }
/* Main Content Wrapper (scrollable chat area) */ /* Main Content Wrapper (scrollable chat area) */
@@ -140,7 +151,8 @@ body {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; min-height: 0;
overflow: hidden;
background-color: var(--color-background); background-color: var(--color-background);
} }
+1
View File
@@ -8,6 +8,7 @@
"dev:frontend": "cd frontend && vite", "dev:frontend": "cd frontend && vite",
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",
"build": "cd frontend && vite build", "build": "cd frontend && vite build",
"test": "npm --prefix frontend test",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {