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": {
"dev": "vite",
"build": "vite build",
"test": "node --test src/**/*.test.js",
"preview": "vite preview"
},
"dependencies": {
+90 -560
View File
@@ -1,596 +1,140 @@
/* ========================================
App Component — padhle (Backend-Connected)
======================================== */
import { useState, useRef, useCallback, useEffect } from "react";
import { useCallback, useState } from "react";
import { useMatch, useNavigate } from "react-router-dom";
import Sidebar from "./components/sidebar/Sidebar";
import TopNav from "./components/top-nav/TopNav";
import ChatHistory from "./components/chat-history/ChatHistory";
import ChatInput from "./components/chat-input/ChatInput";
import { useAuth } from "./lib/auth/SupabaseAuth";
import SignInModal from "./lib/auth/SignIn";
import { resetAppState } from "./appState";
const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev
// Default selectors
const DEFAULTS = {
grade: "Choose standard",
subject: "choose-subject",
chapter: "choose-chapter",
};
/* ─── 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" },
],
};
import { useAuth } from "./lib/auth/SupabaseAuth";
import { useChat } from "./hooks/useChat";
import {
chapterData,
DEFAULTS,
getChapterLabel,
getSubjectLabel,
gradeItems,
subjectItems,
} from "./data/curriculum";
function App() {
const { isAuthenticated, user, signIn, signUp, signOut, getToken } =
useAuth();
const { isAuthenticated, user, signIn, signUp, signOut } = useAuth();
const navigate = useNavigate();
const routeChatId = useMatch("/chat/:chatId")?.params.chatId ?? null;
// Guards deep-link loading so it only fires once per route/authenticated change.
const chatLoadHandledRef = useRef(null);
const [messages, setMessages] = useState([]);
const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
const [showSignIn, setShowSignIn] = useState(false);
const requestSignIn = useCallback(() => setShowSignIn(true), []);
/* ─── Subject label resolution (ID → display name) ─── */
const getSubjectLabel = (id) => {
if (!id || id === "choose-subject") return "Choose Subject";
return subjectItems.find((s) => s.id === id)?.label || id;
};
const chat = useChat({
isAuthenticated,
routeChatId,
navigate,
onSignInRequired: requestSignIn,
});
/* ─── Chapter list resolution (subject ID → array) ─── */
const getChaptersForSubject = (subjectId) => {
return chapterData[subjectId] || [];
};
const {
messages,
activeChat,
activeGrade,
activeSubject,
activeChapter,
selectorStep,
sessions,
isLoadingSessions,
isTyping,
anonRemaining,
setActiveGrade,
setActiveSubject,
setActiveChapter,
setSelectorStep,
addError,
newChat,
selectChat,
deleteChat,
send,
} = chat;
/* ─── 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 = () => {
// Abort any in-flight request
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
// Clear everything
setMessages([]);
setActiveChat(null);
setActiveGrade(DEFAULTS.grade);
setActiveSubject(DEFAULTS.subject);
const selectSubject = (id) => {
setActiveSubject(id);
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;
if (selectorStep !== "chat") setSelectorStep("subject");
};
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 selectChapter = (chapter) => {
setActiveChapter(chapter);
if (selectorStep !== "chat") setSelectorStep("chapter");
};
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,
activeGrade,
activeSubject,
activeChapter,
isAnonymous,
anonRemaining,
isAuthenticated,
],
);
const handlePromptClick = useCallback(
(promptText) => {
handleSend(promptText);
},
[handleSend],
);
/* ─── Selector step handlers ─── */
const handleGradeSelect = (item) => {
setActiveGrade(item.label);
setSelectorStep("subject");
};
const handleSubjectSelect = (item) => {
setActiveSubject(item.id);
setActiveChapter(DEFAULTS.chapter);
setSelectorStep("chapter");
};
const handleChapterSelect = (item) => {
setActiveChapter(item.label);
setSelectorStep("chat");
};
/* ─── Selector step data ─── */
const selectorData = {
grade: {
title: "Select your standard",
subtitle: "Choose the grade you are studying in",
items: gradeItems,
selected: activeGrade,
onSelect: handleGradeSelect,
selectedValue: activeGrade,
compareField: "label",
onSelect: (item) => {
setActiveGrade(item.label);
setSelectorStep("subject");
},
},
subject: {
title: "Select your subject",
subtitle: `Chose by grade – ${activeGrade}`,
subtitle: `Chosen grade – ${activeGrade}`,
items: subjectItems,
selectedValue: activeSubject,
onSelect: handleSubjectSelect,
compareField: "id",
onSelect: (item) => {
setActiveSubject(item.id);
setActiveChapter(DEFAULTS.chapter);
setSelectorStep("chapter");
},
},
chapter: {
title: "Select a chapter",
subtitle: `Subject – ${getSubjectLabel(activeSubject)}`,
items: getChaptersForSubject(activeSubject),
items: chapterData[activeSubject] || [],
selectedValue: activeChapter,
onSelect: handleChapterSelect,
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 (
<>
{showSignIn && (
<SignInModal
onClose={() => setShowSignIn(false)}
onSignIn={async (email, password) => {
try {
await signIn(email, password);
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}
onSignIn={(email, password) => authenticate(signIn, email, password)}
onSignUp={(email, password) => authenticate(signUp, email, password)}
onError={addError}
/>
)}
<div className="page">
<Sidebar
activeSubject={activeSubject}
onSubjectChange={(id) => {
setActiveSubject(id);
setActiveChapter(DEFAULTS.chapter);
if (selectorStep !== "chat") {
setSelectorStep("subject");
}
}}
onSubjectChange={selectSubject}
activeChat={activeChat}
onChatSelect={handleChatSelect}
onNewChat={handleNewChat}
onChatSelect={selectChat}
onNewChat={newChat}
sessions={sessions}
onDeleteChat={handleDeleteChat}
onDeleteChat={deleteChat}
isLoadingSessions={isLoadingSessions}
showUpgrade={false}
isAnonymous={isAnonymous}
onSignInRequest={() => setShowSignIn(true)}
onSignInRequest={requestSignIn}
user={user}
onSignOut={signOut}
/>
@@ -598,26 +142,15 @@ function App() {
<div className="page__main">
<TopNav
activeGrade={activeGrade}
onGradeChange={(g) => setActiveGrade(g)}
onGradeChange={setActiveGrade}
subjectItems={subjectItems}
chapterData={chapterData}
activeSubject={activeSubject}
activeChapter={activeChapter}
onChapterChange={(ch) => {
setActiveChapter(ch);
if (selectorStep !== "chat") {
setSelectorStep("chapter");
}
}}
onSubjectChange={(id) => {
setActiveSubject(id);
setActiveChapter(DEFAULTS.chapter);
if (selectorStep !== "chat") {
setSelectorStep("subject");
}
}}
onChapterChange={selectChapter}
onSubjectChange={selectSubject}
isAnonymous={isAnonymous}
onSignInRequest={() => setShowSignIn(true)}
onSignInRequest={requestSignIn}
user={user}
onSignOut={signOut}
/>
@@ -625,10 +158,7 @@ function App() {
<div className="main-content">
<ChatHistory
messages={messages}
activeSubject={activeSubject}
activeGrade={activeGrade}
activeChapter={activeChapter}
onPromptClick={handlePromptClick}
onPromptClick={send}
isTyping={isTyping}
selectorStep={selectorStep}
selectorData={
@@ -638,11 +168,11 @@ function App() {
</div>
<ChatInput
onSend={handleSend}
onSend={send}
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
disabled={isTyping}
exhausted={isAnonymous && anonRemaining <= 0}
onSignInRequest={() => setShowSignIn(true)}
onSignInRequest={requestSignIn}
/>
</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,211 +2,220 @@
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 {
flex: 1;
overflow-y: auto;
background-color: var(--color-background);
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: var(--color-background);
}
/* Focus Column — centered content area */
.chat-history__focus {
max-width: var(--container-max);
margin: 0 auto;
padding: 48px 24px 128px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100%;
max-width: var(--container-max);
margin: 0 auto;
padding: 48px 24px 128px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100%;
}
/* Welcome Header */
.chat-history__welcome {
text-align: center;
margin-bottom: 48px;
text-align: center;
margin-bottom: 48px;
}
.chat-history__welcome-icon {
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-bottom: 16px;
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-bottom: 16px;
color: var(--color-primary);
}
.chat-history__welcome-icon .material-symbols-outlined {
font-size: 48px;
line-height: 1;
font-size: 48px;
line-height: 1;
}
.chat-history__welcome-sparkle {
position: absolute;
top: -4px;
right: -16px;
font-size: 20px;
color: var(--color-primary);
font-weight: 700;
position: absolute;
top: -4px;
right: -16px;
font-size: 20px;
color: var(--color-primary);
font-weight: 700;
}
.chat-history__welcome-title {
font-family: var(--font-heading);
font-size: var(--font-size-display-lg);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
letter-spacing: -0.02em;
line-height: var(--line-height-tight);
margin-bottom: 12px;
font-family: var(--font-heading);
font-size: var(--font-size-display-lg);
font-weight: var(--font-weight-bold);
color: var(--color-primary);
letter-spacing: -0.02em;
line-height: var(--line-height-tight);
margin-bottom: 12px;
}
.chat-history__welcome-subtitle {
font-size: var(--font-size-body-md);
color: var(--color-on-surface-variant);
max-width: 440px;
margin: 0 auto;
line-height: var(--line-height-relaxed);
font-size: var(--font-size-body-md);
color: var(--color-on-surface-variant);
max-width: 440px;
margin: 0 auto;
line-height: var(--line-height-relaxed);
}
/* Bento Grid */
.chat-history__bento {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
width: 100%;
margin-bottom: 24px;
display: grid;
grid-template-columns: 1fr;
gap: 16px;
width: 100%;
margin-bottom: 24px;
}
@media (min-width: 768px) {
.chat-history__bento {
grid-template-columns: repeat(4, 1fr);
}
.chat-history__bento {
grid-template-columns: repeat(4, 1fr);
}
}
/* Bento Card */
.chat-history__bento-card {
position: relative;
text-align: left;
padding: 20px;
border: var(--border-thin);
border-radius: var(--radius-xl);
background-color: var(--color-surface-lowest);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
display: flex;
flex-direction: column;
min-height: 120px;
overflow: hidden;
position: relative;
text-align: left;
padding: 20px;
border: var(--border-thin);
border-radius: var(--radius-xl);
background-color: var(--color-surface-lowest);
cursor: pointer;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
display: flex;
flex-direction: column;
min-height: 120px;
overflow: hidden;
}
.chat-history__bento-card:hover {
border-color: var(--color-outline);
box-shadow: var(--shadow-card-hover);
border-color: var(--color-outline);
box-shadow: var(--shadow-card-hover);
}
.chat-history__bento-icon-wrap {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.chat-history__bento-icon-wrap .material-symbols-outlined {
font-size: 20px;
line-height: 1;
font-size: 20px;
line-height: 1;
}
.chat-history__bento-text {
flex: 1;
font-size: 14px;
line-height: 1.625;
color: var(--color-on-surface);
margin-bottom: 16px;
flex: 1;
font-size: 14px;
line-height: 1.625;
color: var(--color-on-surface);
margin-bottom: 16px;
}
.chat-history__bento-text-main {
color: var(--color-on-surface);
color: var(--color-on-surface);
}
.chat-history__bento-text-highlight {
font-weight: 600;
font-weight: 600;
}
.chat-history__bento-arrow {
position: absolute;
bottom: 20px;
right: 20px;
font-size: 20px;
line-height: 1;
color: var(--color-on-surface-variant);
opacity: 0.5;
transition: color var(--transition-fast), opacity var(--transition-fast);
position: absolute;
bottom: 20px;
right: 20px;
font-size: 20px;
line-height: 1;
color: var(--color-on-surface-variant);
opacity: 0.5;
transition:
color var(--transition-fast),
opacity var(--transition-fast);
}
.chat-history__bento-card:hover .chat-history__bento-arrow {
color: var(--color-primary);
opacity: 1;
color: var(--color-primary);
opacity: 1;
}
/* Info Panel */
.chat-history__info {
width: 100%;
border: var(--border-thin);
border-radius: var(--radius-xl);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
background-color: var(--color-surface-lowest);
position: relative;
overflow: hidden;
width: 100%;
border: var(--border-thin);
border-radius: var(--radius-xl);
padding: 24px;
display: flex;
align-items: flex-start;
gap: 16px;
background-color: var(--color-surface-lowest);
position: relative;
overflow: hidden;
}
.chat-history__info-icon-wrap {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: var(--color-surface-low);
display: flex;
align-items: center;
justify-content: center;
border: var(--border-thin);
border-color: rgba(0, 0, 0, 0.05);
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
background-color: var(--color-surface-low);
display: flex;
align-items: center;
justify-content: center;
border: var(--border-thin);
border-color: rgba(0, 0, 0, 0.05);
flex-shrink: 0;
}
.chat-history__info-icon-wrap .material-symbols-outlined {
font-size: 22px;
line-height: 1;
color: var(--color-on-surface);
font-size: 22px;
line-height: 1;
color: var(--color-on-surface);
}
.chat-history__info-content {
flex: 1;
flex: 1;
}
.chat-history__info-title {
font-size: 15px;
font-weight: var(--font-weight-bold);
color: var(--color-primary);
margin-bottom: 4px;
font-size: 15px;
font-weight: var(--font-weight-bold);
color: var(--color-primary);
margin-bottom: 4px;
}
.chat-history__info-text {
font-size: 14px;
line-height: 1.4;
color: var(--color-on-surface-variant);
font-size: 14px;
line-height: 1.4;
color: var(--color-on-surface-variant);
}
/* Message List */
.chat-history__list {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px 0;
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
}
@@ -1,257 +1,194 @@
/* ========================================
ChatHistory Component — padhle (Refined Workspace)
======================================== */
import { useEffect, useRef } from "react";
import Message from "../message/Message";
import SelectorFlow from "../selector-flow/SelectorFlow";
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 = ({
messages,
activeSubject,
activeGrade,
activeChapter,
onPromptClick,
isTyping,
selectorStep,
selectorData,
messages,
onPromptClick,
isTyping,
selectorStep,
selectorData,
}) => {
const hasMessages = messages.length > 0;
const isSelector = selectorStep !== "chat";
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;
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 scrollToBottom = () => {
const history = historyRef.current;
if (history) history.scrollTop = history.scrollHeight;
};
const fullPromptTexts = suggestions.map(
(s) => s.text + s.keywords.join(" ") + s.textEnd,
);
useEffect(() => {
autoFollowRef.current = true;
scrollToBottom();
}, [messages.length]);
const streamingMsg = messages.find((m) => m.streaming);
const historyRef = useRef(null);
useEffect(() => {
if (autoFollowRef.current) scrollToBottom();
}, [latestText]);
// Scroll only the chat pane, and only when a message is added. Streaming
// token updates must not move the page or restart a smooth-scroll animation.
useEffect(() => {
const history = historyRef.current;
if (history) history.scrollTop = history.scrollHeight;
}, [messages.length]);
const handleScroll = () => {
const history = historyRef.current;
if (!history) return;
autoFollowRef.current =
history.scrollHeight - history.scrollTop - history.clientHeight < 80;
};
/* ─── Messages mode ─── */
if (hasMessages) {
return (
<div className="chat-history" ref={historyRef}>
<div className="chat-history__list">
{messages.map((message) => (
<Message
key={message.id}
message={message}
/>
))}
{isTyping && !streamingMsg && (
<Message
message={{
id: "typing",
role: "assistant",
text: "",
streaming: true,
}}
/>
)}
</div>
</div>
);
}
if (hasMessages) {
return (
<div className="chat-history" ref={historyRef} onScroll={handleScroll}>
<div className="chat-history__list">
{messages.map((message) => (
<Message key={message.id} message={message} />
))}
{isTyping && !isStreaming && (
<Message
message={{
id: "typing",
role: "assistant",
text: "",
streaming: true,
}}
/>
)}
</div>
</div>
);
}
/* ─── Selector mode (grade → subject → chapter) ─── */
if (isSelector && selectorData) {
return (
<div className="chat-history">
<SelectorFlow
title={selectorData.title}
subtitle={selectorData.subtitle}
selected={selectorData.selected}
items={selectorData.items}
onSelect={selectorData.onSelect}
step={selectorStep}
/>
</div>
);
}
if (selectorStep !== "chat" && selectorData) {
return (
<div className="chat-history">
<SelectorFlow {...selectorData} step={selectorStep} />
</div>
);
}
/* ─── Welcome / suggestions mode ─── */
return (
<div className="chat-history">
<div className="chat-history__focus">
{/* Welcome Header */}
<div className="chat-history__welcome">
<div className="chat-history__welcome-icon">
<span
className="material-symbols-outlined"
style={{
fontWeight: 200,
}}
>
menu_book
</span>
<span className="material-symbols-outlined chat-history__welcome-sparkle">
auto_awesome
</span>
</div>
<h2 className="chat-history__welcome-title">
Welcome to padhle
</h2>
<p className="chat-history__welcome-subtitle">
Your AI tutor for grades 6–12.
Ask anything!
</p>
</div>
return (
<div className="chat-history">
<div className="chat-history__focus">
<div className="chat-history__welcome">
<div className="chat-history__welcome-icon">
<span
className="material-symbols-outlined"
style={{ fontWeight: 200 }}
>
menu_book
</span>
<span className="material-symbols-outlined chat-history__welcome-sparkle">
auto_awesome
</span>
</div>
<h2 className="chat-history__welcome-title">Welcome to padhle</h2>
<p className="chat-history__welcome-subtitle">
Your AI tutor for grades 6–12. Ask anything!
</p>
</div>
{/* Bento Grid Suggestions */}
<div className="chat-history__bento">
{suggestions.map((suggestion) => {
const colorVar =
suggestion.color.replace(
"--",
"var(",
);
const bgColorVar =
suggestion.colorBg.replace(
"--",
"var(",
);
<div className="chat-history__bento">
{suggestions.map((suggestion) => {
const color = `var(${suggestion.color})`;
const prompt =
suggestion.before + suggestion.highlight + suggestion.after;
return (
<button
key={
suggestion.id
}
className="chat-history__bento-card"
onClick={() => {
const text =
fullPromptTexts[
suggestion.id -
1
];
onPromptClick(
text,
);
}}
>
<div
className="chat-history__bento-icon-wrap"
style={{
backgroundColor: `var(${bgColorVar})`,
}}
>
<span
className="material-symbols-outlined"
style={{
color: `var(${colorVar})`,
fontWeight: suggestion.iconFilled
? 700
: 200,
}}
>
{
suggestion.icon
}
</span>
</div>
<div className="chat-history__bento-text">
<span className="chat-history__bento-text-main">
{
suggestion.text
}
</span>
<span
className="chat-history__bento-text-highlight"
style={{
color: `var(${colorVar})`,
fontWeight: 600,
}}
>
{
suggestion
.keywords[0]
}
</span>
{
suggestion.textEnd
}
</div>
<span className="material-symbols-outlined chat-history__bento-arrow">
arrow_forward
</span>
</button>
);
})}
</div>
{/* Info Panel */}
<div className="chat-history__info">
<div className="chat-history__info-icon-wrap">
<span className="material-symbols-outlined">
lightbulb
</span>
</div>
<div className="chat-history__info-content">
<h4 className="chat-history__info-title">
Focused on your learning
</h4>
<p className="chat-history__info-text">
I'll answer questions
related to your selected
subject and chapter
<br />
using your textbook
content.
</p>
</div>
</div>
</div>
return (
<button
key={suggestion.highlight}
className="chat-history__bento-card"
onClick={() => onPromptClick(prompt)}
>
<div
className="chat-history__bento-icon-wrap"
style={{ backgroundColor: `var(${suggestion.background})` }}
>
<span
className="material-symbols-outlined"
style={{
color,
fontWeight: suggestion.filled ? 700 : 200,
}}
>
{suggestion.icon}
</span>
</div>
);
<div className="chat-history__bento-text">
<span className="chat-history__bento-text-main">
{suggestion.before}
</span>
<span
className="chat-history__bento-text-highlight"
style={{ color, fontWeight: 600 }}
>
{suggestion.highlight}
</span>
{suggestion.after}
</div>
<span className="material-symbols-outlined chat-history__bento-arrow">
arrow_forward
</span>
</button>
);
})}
</div>
<div className="chat-history__info">
<div className="chat-history__info-icon-wrap">
<span className="material-symbols-outlined">lightbulb</span>
</div>
<div className="chat-history__info-content">
<h4 className="chat-history__info-title">
Focused on your learning
</h4>
<p className="chat-history__info-text">
I'll answer questions related to your selected subject and chapter
<br />
using your textbook content.
</p>
</div>
</div>
</div>
</div>
);
};
export default ChatHistory;
+148 -135
View File
@@ -4,223 +4,236 @@
/* Main Input Bar */
.chat-input {
flex-shrink: 0;
width: 100%;
/* Gradient fade from background */
background: linear-gradient(to top, var(--color-background) 50%, transparent);
padding-top: 24px;
padding-bottom: 32px;
padding-left: 24px;
padding-right: 24px;
display: flex;
justify-content: center;
flex-shrink: 0;
width: 100%;
/* Gradient fade from background */
background: linear-gradient(to top, var(--color-background) 50%, transparent);
padding-top: 24px;
padding-bottom: 32px;
padding-left: 24px;
padding-right: 24px;
display: flex;
justify-content: center;
}
/* Container — centered content */
.chat-input__container {
width: 100%;
max-width: 800px;
width: 100%;
max-width: 800px;
}
/* Form */
.chat-input__form {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px;
border: var(--border-thin);
border-radius: 32px;
background-color: var(--color-surface-lowest);
box-shadow: var(--shadow-input);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px;
border: var(--border-thin);
border-radius: 32px;
background-color: var(--color-surface-lowest);
box-shadow: var(--shadow-input);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.chat-input__form:focus-within {
border-color: var(--color-primary);
box-shadow: var(--shadow-input-focus);
border-color: var(--color-primary);
box-shadow: var(--shadow-input-focus);
}
/* Attachment Button */
.chat-input__attach-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: var(--border-thin);
border-radius: 50%;
background-color: var(--color-surface-lowest);
cursor: pointer;
color: var(--color-on-surface-variant);
transition: background-color var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: var(--border-thin);
border-radius: 50%;
background-color: var(--color-surface-lowest);
cursor: pointer;
color: var(--color-on-surface-variant);
transition:
background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
}
.chat-input__attach-btn:hover {
background-color: var(--color-surface-low);
color: var(--color-primary);
background-color: var(--color-surface-low);
color: var(--color-primary);
}
.chat-input__attach-btn .material-symbols-outlined {
font-size: 20px;
line-height: 1;
font-size: 20px;
line-height: 1;
}
/* Textarea */
.chat-input__field {
flex: 1;
font-family: var(--font-body);
font-size: 15px;
font-weight: var(--font-weight-regular);
line-height: 1.5;
color: var(--color-primary);
background-color: transparent;
border: none;
padding: 8px 4px;
resize: none;
outline: none;
min-height: 44px;
max-height: 128px;
flex: 1;
font-family: var(--font-body);
font-size: 15px;
font-weight: var(--font-weight-regular);
line-height: 1.5;
color: var(--color-primary);
background-color: transparent;
border: none;
padding: 8px 4px;
resize: none;
outline: none;
min-height: 44px;
max-height: 128px;
}
.chat-input__field:focus-visible {
outline: none;
}
.chat-input__field::placeholder {
color: var(--color-outline);
font-weight: var(--font-weight-regular);
color: var(--color-outline);
font-weight: var(--font-weight-regular);
}
.chat-input__field:disabled {
opacity: 0.5;
cursor: not-allowed;
opacity: 0.5;
cursor: not-allowed;
}
/* Actions Container */
.chat-input__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Mic Button */
.chat-input__mic-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
color: var(--color-on-surface-variant);
transition: background-color var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
color: var(--color-on-surface-variant);
transition:
background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
}
.chat-input__mic-btn:hover {
background-color: var(--color-surface-low);
color: var(--color-primary);
background-color: var(--color-surface-low);
color: var(--color-primary);
}
.chat-input__mic-btn .material-symbols-outlined {
font-size: 20px;
line-height: 1;
font-size: 20px;
line-height: 1;
}
/* Send Button */
.chat-input__send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
cursor: not-allowed;
color: var(--color-on-surface-variant);
opacity: 0.3;
transition: opacity var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
cursor: not-allowed;
color: var(--color-on-surface-variant);
opacity: 0.3;
transition:
opacity var(--transition-fast),
background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
}
.chat-input__send-btn--active {
background-color: var(--color-primary);
color: var(--color-on-primary);
cursor: pointer;
opacity: 1;
background-color: var(--color-primary);
color: var(--color-on-primary);
cursor: pointer;
opacity: 1;
}
.chat-input__send-btn--active:hover {
opacity: 0.9;
opacity: 0.9;
}
.chat-input__send-btn:disabled {
cursor: not-allowed;
opacity: 0.3;
cursor: not-allowed;
opacity: 0.3;
}
.chat-input__send-btn .material-symbols-outlined {
font-size: 20px;
line-height: 1;
font-size: 20px;
line-height: 1;
}
/* Disclaimer */
.chat-input__disclaimer {
font-size: var(--font-size-label-sm);
font-weight: var(--font-weight-medium);
color: var(--color-on-surface-variant);
text-align: center;
margin-top: 12px;
font-size: var(--font-size-label-sm);
font-weight: var(--font-weight-medium);
color: var(--color-on-surface-variant);
text-align: center;
margin-top: 12px;
}
/* Exhausted (anonymous free messages used) — sign-in gate */
.chat-input__exhausted {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 4px;
padding: 28px 24px;
border: var(--border-thin);
border-radius: 20px;
background-color: var(--color-surface-lowest);
box-shadow: var(--shadow-input);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 4px;
padding: 28px 24px;
border: var(--border-thin);
border-radius: 20px;
background-color: var(--color-surface-lowest);
box-shadow: var(--shadow-input);
}
.chat-input__exhausted-icon {
font-size: 28px;
color: var(--color-primary);
margin-bottom: 6px;
font-size: 28px;
color: var(--color-primary);
margin-bottom: 6px;
}
.chat-input__exhausted-title {
margin: 0;
font-family: var(--font-heading);
font-size: 1.05rem;
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface);
margin: 0;
font-family: var(--font-heading);
font-size: 1.05rem;
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface);
}
.chat-input__exhausted-sub {
margin: 0 0 14px;
font-size: var(--font-size-label-md);
color: var(--color-on-surface-variant);
margin: 0 0 14px;
font-size: var(--font-size-label-md);
color: var(--color-on-surface-variant);
}
.chat-input__exhausted-btn {
padding: 10px 22px;
border: none;
border-radius: 999px;
background-color: var(--color-primary);
color: var(--color-on-primary);
font-family: var(--font-body);
font-size: var(--font-size-label-md);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: opacity var(--transition-fast);
padding: 10px 22px;
border: none;
border-radius: 999px;
background-color: var(--color-primary);
color: var(--color-on-primary);
font-family: var(--font-body);
font-size: var(--font-size-label-md);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: opacity var(--transition-fast);
}
.chat-input__exhausted-btn:hover {
opacity: 0.9;
opacity: 0.9;
}
+34 -11
View File
@@ -81,6 +81,7 @@
font-size: 15px;
line-height: var(--line-height-relaxed);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-weight: var(--font-weight-regular);
color: var(--color-on-surface);
}
@@ -93,9 +94,15 @@
.message__text.markdown-body {
white-space: normal;
}
.markdown-body > *:first-child { margin-top: 0; }
.markdown-body > *:last-child { margin-bottom: 0; }
.markdown-body p { margin: 0 0 12px; }
.markdown-body > *:first-child {
margin-top: 0;
}
.markdown-body > *:last-child {
margin-bottom: 0;
}
.markdown-body p {
margin: 0 0 12px;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
@@ -105,16 +112,26 @@
font-weight: 700;
line-height: 1.3;
}
.markdown-body h1 { font-size: 22px; }
.markdown-body h2 { font-size: 19px; }
.markdown-body h3 { font-size: 16px; }
.markdown-body h4 { font-size: 15px; }
.markdown-body h1 {
font-size: 22px;
}
.markdown-body h2 {
font-size: 19px;
}
.markdown-body h3 {
font-size: 16px;
}
.markdown-body h4 {
font-size: 15px;
}
.markdown-body ul,
.markdown-body ol {
margin: 0 0 12px;
padding-left: 22px;
}
.markdown-body li { margin-bottom: 4px; }
.markdown-body li {
margin-bottom: 4px;
}
.markdown-body blockquote {
margin: 0 0 12px;
padding: 6px 14px;
@@ -169,7 +186,9 @@
text-decoration: underline;
}
/* KaTeX math inherits font size */
.markdown-body .katex { font-size: 1.05em; }
.markdown-body .katex {
font-size: 1.05em;
}
/* --- ACTION BUTTONS --- */
.message__actions {
@@ -191,7 +210,9 @@
background-color: var(--color-surface-low);
cursor: pointer;
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 {
@@ -244,7 +265,9 @@
}
@keyframes typingBounce {
0%, 80%, 100% {
0%,
80%,
100% {
transform: scale(0.6);
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);
};
const getToken = async () => {
// Token lives in httpOnly cookie — not accessible to JavaScript
return null;
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
signIn,
signUp,
signOut,
getToken,
}}
value={{ user, isAuthenticated: !!user, signIn, signUp, signOut }}
>
{children}
</AuthContext.Provider>
-9
View File
@@ -76,13 +76,4 @@ export const auth = {
}
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" },
]);
});
+127 -115
View File
@@ -6,177 +6,189 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Root Variables — Material Design 3 tokens from EduAI */
:root {
/* Surface Colors */
--color-background: #fcf8fa;
--color-surface: #ffffff;
--color-surface-lowest: #ffffff;
--color-surface-low: #f9fafb;
--color-surface-container: #f3f4f6;
--color-surface-high: #e5e7eb;
/* Surface Colors */
--color-background: #fcf8fa;
--color-surface: #ffffff;
--color-surface-lowest: #ffffff;
--color-surface-low: #f9fafb;
--color-surface-container: #f3f4f6;
--color-surface-high: #e5e7eb;
/* Outline Colors */
--color-outline: #d1d5db;
--color-outline-variant: #e5e5e5;
/* Outline Colors */
--color-outline: #d1d5db;
--color-outline-variant: #e5e5e5;
/* Text Colors */
--color-on-surface: #111827;
--color-on-surface-variant: #6b7280;
--color-primary: #000000;
--color-on-primary: #ffffff;
--color-primary-fixed: #dce2f7;
--color-primary-container: #141b2b;
/* Text Colors */
--color-on-surface: #111827;
--color-on-surface-variant: #6b7280;
--color-primary: #000000;
--color-on-primary: #ffffff;
--color-primary-fixed: #dce2f7;
--color-primary-container: #141b2b;
/* Accent Colors for Suggestion Cards */
--color-accent-green: #2e7d32;
--color-accent-green-bg: #e8f5e9;
--color-accent-blue: #1565c0;
--color-accent-blue-bg: #e3f2fd;
--color-accent-orange: #e65100;
--color-accent-orange-bg: #fff3e0;
--color-accent-purple: #6a1b9a;
--color-accent-purple-bg: #f3e5f5;
/* Accent Colors for Suggestion Cards */
--color-accent-green: #2e7d32;
--color-accent-green-bg: #e8f5e9;
--color-accent-blue: #1565c0;
--color-accent-blue-bg: #e3f2fd;
--color-accent-orange: #e65100;
--color-accent-orange-bg: #fff3e0;
--color-accent-purple: #6a1b9a;
--color-accent-purple-bg: #f3e5f5;
/* Fonts */
--font-heading: "Montserrat", sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
/* Fonts */
--font-heading: "Montserrat", sans-serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
/* Font Sizes */
--font-size-body-md: 16px;
--font-size-body-lg: 18px;
--font-size-headline-md: 24px;
--font-size-headline-sm: 20px;
--font-size-label-md: 14px;
--font-size-label-sm: 12px;
--font-size-label-xs: 11px;
--font-size-display-lg: 40px;
/* Font Sizes */
--font-size-body-md: 16px;
--font-size-body-lg: 18px;
--font-size-headline-md: 24px;
--font-size-headline-sm: 20px;
--font-size-label-md: 14px;
--font-size-label-sm: 12px;
--font-size-label-xs: 11px;
--font-size-display-lg: 40px;
/* Font Weights */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Font Weights */
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Line Heights */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
/* Line Heights */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.625;
/* Border */
--border-thin: 1px solid var(--color-outline);
--border-regular: 1.5px solid var(--color-outline);
--border-thick: 2px solid var(--color-outline);
/* Border */
--border-thin: 1px solid var(--color-outline);
--border-regular: 1.5px solid var(--color-outline);
--border-thick: 2px solid var(--color-outline);
/* Border Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-pill: 9999px;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-pill: 9999px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Transition */
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Transition */
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Layout */
--sidebar-width: 280px;
--container-max: 800px;
--content-padding: 24px;
/* Layout */
--sidebar-width: 280px;
--container-max: 800px;
--content-padding: 24px;
/* Shadows */
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
--shadow-card-hover: 0 2px 8px 0 rgba(0, 0, 0, 0.08);
--shadow-input: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
--shadow-input-focus: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
/* Shadows */
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
--shadow-card-hover: 0 2px 8px 0 rgba(0, 0, 0, 0.08);
--shadow-input: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
--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 {
font-family: var(--font-body);
font-weight: var(--font-weight-regular);
font-size: var(--font-size-body-md);
background-color: var(--color-background);
color: var(--color-on-surface);
line-height: var(--line-height-normal);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: var(--font-body);
font-weight: var(--font-weight-regular);
font-size: var(--font-size-body-md);
background-color: var(--color-background);
color: var(--color-on-surface);
line-height: var(--line-height-normal);
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Page Frame — sidebar + main layout */
.page {
display: flex;
min-height: 100vh;
width: 100%;
display: flex;
height: 100%;
min-height: 0;
width: 100%;
}
/* Main Content Area */
.page__main {
flex: 1;
display: flex;
flex-direction: column;
margin-left: var(--sidebar-width);
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
margin-left: var(--sidebar-width);
min-width: 0;
min-height: 0;
}
/* Main Content Wrapper (scrollable chat area) */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
background-color: var(--color-background);
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
background-color: var(--color-background);
}
/* Container — centered content */
.container {
max-width: var(--container-max);
margin: 0 auto;
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
width: 100%;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-outline-variant);
border-radius: 3px;
background-color: var(--color-outline-variant);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-outline);
background-color: var(--color-outline);
}
/* Focus outline */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Selection */
::selection {
background-color: var(--color-primary-fixed);
color: var(--color-primary-container);
background-color: var(--color-primary-fixed);
color: var(--color-primary-container);
}
+1
View File
@@ -8,6 +8,7 @@
"dev:frontend": "cd frontend && vite",
"dev:backend": "cd backend && npm run dev",
"build": "cd frontend && vite build",
"test": "npm --prefix frontend test",
"preview": "vite preview"
},
"dependencies": {