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": {
+90 -560
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 {
const getChaptersForSubject = (subjectId) => { messages,
return chapterData[subjectId] || []; 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 selectSubject = (id) => {
const getChapterLabel = (chapterId) => { setActiveSubject(id);
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);
setActiveChapter(DEFAULTS.chapter); setActiveChapter(DEFAULTS.chapter);
setIsTyping(false); if (selectorStep !== "chat") setSelectorStep("subject");
// 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) => { const selectChapter = (chapter) => {
if (!isAuthenticated) return; // anonymous users have no persisted sessions setActiveChapter(chapter);
// Abort any in-flight request if (selectorStep !== "chat") setSelectorStep("chapter");
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,
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 = { 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,211 +2,220 @@
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;
overflow-y: auto; min-height: 0;
background-color: var(--color-background); overflow-y: auto;
overflow-x: hidden;
background-color: var(--color-background);
} }
/* Focus Column — centered content area */ /* Focus Column — centered content area */
.chat-history__focus { .chat-history__focus {
max-width: var(--container-max); max-width: var(--container-max);
margin: 0 auto; margin: 0 auto;
padding: 48px 24px 128px; padding: 48px 24px 128px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
min-height: 100%; min-height: 100%;
} }
/* Welcome Header */ /* Welcome Header */
.chat-history__welcome { .chat-history__welcome {
text-align: center; text-align: center;
margin-bottom: 48px; margin-bottom: 48px;
} }
.chat-history__welcome-icon { .chat-history__welcome-icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
margin-bottom: 16px; margin-bottom: 16px;
color: var(--color-primary); color: var(--color-primary);
} }
.chat-history__welcome-icon .material-symbols-outlined { .chat-history__welcome-icon .material-symbols-outlined {
font-size: 48px; font-size: 48px;
line-height: 1; line-height: 1;
} }
.chat-history__welcome-sparkle { .chat-history__welcome-sparkle {
position: absolute; position: absolute;
top: -4px; top: -4px;
right: -16px; right: -16px;
font-size: 20px; font-size: 20px;
color: var(--color-primary); color: var(--color-primary);
font-weight: 700; font-weight: 700;
} }
.chat-history__welcome-title { .chat-history__welcome-title {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: var(--font-size-display-lg); font-size: var(--font-size-display-lg);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--color-primary); color: var(--color-primary);
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
margin-bottom: 12px; margin-bottom: 12px;
} }
.chat-history__welcome-subtitle { .chat-history__welcome-subtitle {
font-size: var(--font-size-body-md); font-size: var(--font-size-body-md);
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
max-width: 440px; max-width: 440px;
margin: 0 auto; margin: 0 auto;
line-height: var(--line-height-relaxed); line-height: var(--line-height-relaxed);
} }
/* Bento Grid */ /* Bento Grid */
.chat-history__bento { .chat-history__bento {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 16px;
width: 100%; width: 100%;
margin-bottom: 24px; margin-bottom: 24px;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.chat-history__bento { .chat-history__bento {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
} }
} }
/* Bento Card */ /* Bento Card */
.chat-history__bento-card { .chat-history__bento-card {
position: relative; position: relative;
text-align: left; text-align: left;
padding: 20px; padding: 20px;
border: var(--border-thin); border: var(--border-thin);
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:
display: flex; border-color var(--transition-fast),
flex-direction: column; box-shadow var(--transition-fast),
min-height: 120px; transform var(--transition-fast);
overflow: hidden; display: flex;
flex-direction: column;
min-height: 120px;
overflow: hidden;
} }
.chat-history__bento-card:hover { .chat-history__bento-card:hover {
border-color: var(--color-outline); border-color: var(--color-outline);
box-shadow: var(--shadow-card-hover); box-shadow: var(--shadow-card-hover);
} }
.chat-history__bento-icon-wrap { .chat-history__bento-icon-wrap {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 16px; margin-bottom: 16px;
} }
.chat-history__bento-icon-wrap .material-symbols-outlined { .chat-history__bento-icon-wrap .material-symbols-outlined {
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
} }
.chat-history__bento-text { .chat-history__bento-text {
flex: 1; flex: 1;
font-size: 14px; font-size: 14px;
line-height: 1.625; line-height: 1.625;
color: var(--color-on-surface); color: var(--color-on-surface);
margin-bottom: 16px; margin-bottom: 16px;
} }
.chat-history__bento-text-main { .chat-history__bento-text-main {
color: var(--color-on-surface); color: var(--color-on-surface);
} }
.chat-history__bento-text-highlight { .chat-history__bento-text-highlight {
font-weight: 600; font-weight: 600;
} }
.chat-history__bento-arrow { .chat-history__bento-arrow {
position: absolute; position: absolute;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
font-size: 20px; font-size: 20px;
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 {
color: var(--color-primary); color: var(--color-primary);
opacity: 1; opacity: 1;
} }
/* Info Panel */ /* Info Panel */
.chat-history__info { .chat-history__info {
width: 100%; width: 100%;
border: var(--border-thin); border: var(--border-thin);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
padding: 24px; padding: 24px;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 16px;
background-color: var(--color-surface-lowest); background-color: var(--color-surface-lowest);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.chat-history__info-icon-wrap { .chat-history__info-icon-wrap {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 50%; border-radius: 50%;
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: var(--border-thin); border: var(--border-thin);
border-color: rgba(0, 0, 0, 0.05); border-color: rgba(0, 0, 0, 0.05);
flex-shrink: 0; flex-shrink: 0;
} }
.chat-history__info-icon-wrap .material-symbols-outlined { .chat-history__info-icon-wrap .material-symbols-outlined {
font-size: 22px; font-size: 22px;
line-height: 1; line-height: 1;
color: var(--color-on-surface); color: var(--color-on-surface);
} }
.chat-history__info-content { .chat-history__info-content {
flex: 1; flex: 1;
} }
.chat-history__info-title { .chat-history__info-title {
font-size: 15px; font-size: 15px;
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--color-primary); color: var(--color-primary);
margin-bottom: 4px; margin-bottom: 4px;
} }
.chat-history__info-text { .chat-history__info-text {
font-size: 14px; font-size: 14px;
line-height: 1.4; line-height: 1.4;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
/* Message List */ /* Message List */
.chat-history__list { .chat-history__list {
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,257 +1,194 @@
/* ========================================
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, onPromptClick,
activeGrade, isTyping,
activeChapter, selectorStep,
onPromptClick, selectorData,
isTyping,
selectorStep,
selectorData,
}) => { }) => {
const hasMessages = messages.length > 0; const historyRef = useRef(null);
const isSelector = selectorStep !== "chat"; 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 = [ const scrollToBottom = () => {
{ const history = historyRef.current;
id: 1, if (history) history.scrollTop = history.scrollHeight;
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( useEffect(() => {
(s) => s.text + s.keywords.join(" ") + s.textEnd, autoFollowRef.current = true;
); scrollToBottom();
}, [messages.length]);
const streamingMsg = messages.find((m) => m.streaming); useEffect(() => {
const historyRef = useRef(null); if (autoFollowRef.current) scrollToBottom();
}, [latestText]);
// Scroll only the chat pane, and only when a message is added. Streaming const handleScroll = () => {
// token updates must not move the page or restart a smooth-scroll animation. const history = historyRef.current;
useEffect(() => { if (!history) return;
const history = historyRef.current; autoFollowRef.current =
if (history) history.scrollTop = history.scrollHeight; history.scrollHeight - history.scrollTop - history.clientHeight < 80;
}, [messages.length]); };
/* ─── Messages mode ─── */ if (hasMessages) {
if (hasMessages) { return (
return ( <div className="chat-history" ref={historyRef} onScroll={handleScroll}>
<div className="chat-history" ref={historyRef}> <div className="chat-history__list">
<div className="chat-history__list"> {messages.map((message) => (
{messages.map((message) => ( <Message key={message.id} message={message} />
<Message ))}
key={message.id} {isTyping && !isStreaming && (
message={message} <Message
/> message={{
))} id: "typing",
{isTyping && !streamingMsg && ( role: "assistant",
<Message text: "",
message={{ streaming: true,
id: "typing", }}
role: "assistant", />
text: "", )}
streaming: true, </div>
}} </div>
/> );
)} }
</div>
</div>
);
}
/* ─── Selector mode (grade → subject → chapter) ─── */ if (selectorStep !== "chat" && selectorData) {
if (isSelector && selectorData) { return (
return ( <div className="chat-history">
<div className="chat-history"> <SelectorFlow {...selectorData} step={selectorStep} />
<SelectorFlow </div>
title={selectorData.title} );
subtitle={selectorData.subtitle} }
selected={selectorData.selected}
items={selectorData.items}
onSelect={selectorData.onSelect}
step={selectorStep}
/>
</div>
);
}
/* ─── Welcome / suggestions mode ─── */ return (
return ( <div className="chat-history">
<div className="chat-history"> <div className="chat-history__focus">
<div className="chat-history__focus"> <div className="chat-history__welcome">
{/* Welcome Header */} <div className="chat-history__welcome-icon">
<div className="chat-history__welcome"> <span
<div className="chat-history__welcome-icon"> className="material-symbols-outlined"
<span style={{ fontWeight: 200 }}
className="material-symbols-outlined" >
style={{ menu_book
fontWeight: 200, </span>
}} <span className="material-symbols-outlined chat-history__welcome-sparkle">
> auto_awesome
menu_book </span>
</span> </div>
<span className="material-symbols-outlined chat-history__welcome-sparkle"> <h2 className="chat-history__welcome-title">Welcome to padhle</h2>
auto_awesome <p className="chat-history__welcome-subtitle">
</span> Your AI tutor for grades 6–12. Ask anything!
</div> </p>
<h2 className="chat-history__welcome-title"> </div>
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">
<div className="chat-history__bento"> {suggestions.map((suggestion) => {
{suggestions.map((suggestion) => { const color = `var(${suggestion.color})`;
const colorVar = const prompt =
suggestion.color.replace( 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"
} onClick={() => onPromptClick(prompt)}
className="chat-history__bento-card" >
onClick={() => { <div
const text = className="chat-history__bento-icon-wrap"
fullPromptTexts[ style={{ backgroundColor: `var(${suggestion.background})` }}
suggestion.id - >
1 <span
]; className="material-symbols-outlined"
onPromptClick( style={{
text, color,
); fontWeight: suggestion.filled ? 700 : 200,
}} }}
> >
<div {suggestion.icon}
className="chat-history__bento-icon-wrap" </span>
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>
</div> </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; export default ChatHistory;
+148 -135
View File
@@ -4,223 +4,236 @@
/* Main Input Bar */ /* Main Input Bar */
.chat-input { .chat-input {
flex-shrink: 0; flex-shrink: 0;
width: 100%; width: 100%;
/* Gradient fade from background */ /* Gradient fade from background */
background: linear-gradient(to top, var(--color-background) 50%, transparent); background: linear-gradient(to top, var(--color-background) 50%, transparent);
padding-top: 24px; padding-top: 24px;
padding-bottom: 32px; padding-bottom: 32px;
padding-left: 24px; padding-left: 24px;
padding-right: 24px; padding-right: 24px;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
/* Container — centered content */ /* Container — centered content */
.chat-input__container { .chat-input__container {
width: 100%; width: 100%;
max-width: 800px; max-width: 800px;
} }
/* Form */ /* Form */
.chat-input__form { .chat-input__form {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 8px; gap: 8px;
padding: 8px; padding: 8px;
border: var(--border-thin); border: var(--border-thin);
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 {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: var(--shadow-input-focus); box-shadow: var(--shadow-input-focus);
} }
/* Attachment Button */ /* Attachment Button */
.chat-input__attach-btn { .chat-input__attach-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; width: 44px;
height: 44px; height: 44px;
border: var(--border-thin); border: var(--border-thin);
border-radius: 50%; border-radius: 50%;
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:
flex-shrink: 0; background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
} }
.chat-input__attach-btn:hover { .chat-input__attach-btn:hover {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
color: var(--color-primary); color: var(--color-primary);
} }
.chat-input__attach-btn .material-symbols-outlined { .chat-input__attach-btn .material-symbols-outlined {
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
} }
/* Textarea */ /* Textarea */
.chat-input__field { .chat-input__field {
flex: 1; flex: 1;
font-family: var(--font-body); font-family: var(--font-body);
font-size: 15px; font-size: 15px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: 1.5; line-height: 1.5;
color: var(--color-primary); color: var(--color-primary);
background-color: transparent; background-color: transparent;
border: none; border: none;
padding: 8px 4px; padding: 8px 4px;
resize: none; resize: none;
outline: none; outline: none;
min-height: 44px; min-height: 44px;
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);
} }
.chat-input__field:disabled { .chat-input__field:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Actions Container */ /* Actions Container */
.chat-input__actions { .chat-input__actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-shrink: 0; flex-shrink: 0;
} }
/* Mic Button */ /* Mic Button */
.chat-input__mic-btn { .chat-input__mic-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; width: 36px;
height: 36px; height: 36px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
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:
flex-shrink: 0; background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
} }
.chat-input__mic-btn:hover { .chat-input__mic-btn:hover {
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
color: var(--color-primary); color: var(--color-primary);
} }
.chat-input__mic-btn .material-symbols-outlined { .chat-input__mic-btn .material-symbols-outlined {
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
} }
/* Send Button */ /* Send Button */
.chat-input__send-btn { .chat-input__send-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 40px;
height: 40px; height: 40px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: transparent; background: transparent;
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:
flex-shrink: 0; opacity var(--transition-fast),
background-color var(--transition-fast),
color var(--transition-fast);
flex-shrink: 0;
} }
.chat-input__send-btn--active { .chat-input__send-btn--active {
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-on-primary); color: var(--color-on-primary);
cursor: pointer; cursor: pointer;
opacity: 1; opacity: 1;
} }
.chat-input__send-btn--active:hover { .chat-input__send-btn--active:hover {
opacity: 0.9; opacity: 0.9;
} }
.chat-input__send-btn:disabled { .chat-input__send-btn:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.3; opacity: 0.3;
} }
.chat-input__send-btn .material-symbols-outlined { .chat-input__send-btn .material-symbols-outlined {
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
} }
/* Disclaimer */ /* Disclaimer */
.chat-input__disclaimer { .chat-input__disclaimer {
font-size: var(--font-size-label-sm); font-size: var(--font-size-label-sm);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
text-align: center; text-align: center;
margin-top: 12px; margin-top: 12px;
} }
/* Exhausted (anonymous free messages used) — sign-in gate */ /* Exhausted (anonymous free messages used) — sign-in gate */
.chat-input__exhausted { .chat-input__exhausted {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
gap: 4px; gap: 4px;
padding: 28px 24px; padding: 28px 24px;
border: var(--border-thin); border: var(--border-thin);
border-radius: 20px; border-radius: 20px;
background-color: var(--color-surface-lowest); background-color: var(--color-surface-lowest);
box-shadow: var(--shadow-input); box-shadow: var(--shadow-input);
} }
.chat-input__exhausted-icon { .chat-input__exhausted-icon {
font-size: 28px; font-size: 28px;
color: var(--color-primary); color: var(--color-primary);
margin-bottom: 6px; margin-bottom: 6px;
} }
.chat-input__exhausted-title { .chat-input__exhausted-title {
margin: 0; margin: 0;
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--color-on-surface); color: var(--color-on-surface);
} }
.chat-input__exhausted-sub { .chat-input__exhausted-sub {
margin: 0 0 14px; margin: 0 0 14px;
font-size: var(--font-size-label-md); font-size: var(--font-size-label-md);
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
.chat-input__exhausted-btn { .chat-input__exhausted-btn {
padding: 10px 22px; padding: 10px 22px;
border: none; border: none;
border-radius: 999px; border-radius: 999px;
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-on-primary); color: var(--color-on-primary);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--font-size-label-md); font-size: var(--font-size-label-md);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
cursor: pointer; cursor: pointer;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
} }
.chat-input__exhausted-btn:hover { .chat-input__exhausted-btn:hover {
opacity: 0.9; opacity: 0.9;
} }
+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" },
]);
});
+127 -115
View File
@@ -6,177 +6,189 @@
*, *,
*::before, *::before,
*::after { *::after {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
/* Root Variables — Material Design 3 tokens from EduAI */ /* Root Variables — Material Design 3 tokens from EduAI */
:root { :root {
/* Surface Colors */ /* Surface Colors */
--color-background: #fcf8fa; --color-background: #fcf8fa;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-lowest: #ffffff; --color-surface-lowest: #ffffff;
--color-surface-low: #f9fafb; --color-surface-low: #f9fafb;
--color-surface-container: #f3f4f6; --color-surface-container: #f3f4f6;
--color-surface-high: #e5e7eb; --color-surface-high: #e5e7eb;
/* Outline Colors */ /* Outline Colors */
--color-outline: #d1d5db; --color-outline: #d1d5db;
--color-outline-variant: #e5e5e5; --color-outline-variant: #e5e5e5;
/* Text Colors */ /* Text Colors */
--color-on-surface: #111827; --color-on-surface: #111827;
--color-on-surface-variant: #6b7280; --color-on-surface-variant: #6b7280;
--color-primary: #000000; --color-primary: #000000;
--color-on-primary: #ffffff; --color-on-primary: #ffffff;
--color-primary-fixed: #dce2f7; --color-primary-fixed: #dce2f7;
--color-primary-container: #141b2b; --color-primary-container: #141b2b;
/* Accent Colors for Suggestion Cards */ /* Accent Colors for Suggestion Cards */
--color-accent-green: #2e7d32; --color-accent-green: #2e7d32;
--color-accent-green-bg: #e8f5e9; --color-accent-green-bg: #e8f5e9;
--color-accent-blue: #1565c0; --color-accent-blue: #1565c0;
--color-accent-blue-bg: #e3f2fd; --color-accent-blue-bg: #e3f2fd;
--color-accent-orange: #e65100; --color-accent-orange: #e65100;
--color-accent-orange-bg: #fff3e0; --color-accent-orange-bg: #fff3e0;
--color-accent-purple: #6a1b9a; --color-accent-purple: #6a1b9a;
--color-accent-purple-bg: #f3e5f5; --color-accent-purple-bg: #f3e5f5;
/* Fonts */ /* Fonts */
--font-heading: "Montserrat", sans-serif; --font-heading: "Montserrat", sans-serif;
--font-body: "Inter", system-ui, sans-serif; --font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace; --font-mono: "JetBrains Mono", "Fira Code", monospace;
/* Font Sizes */ /* Font Sizes */
--font-size-body-md: 16px; --font-size-body-md: 16px;
--font-size-body-lg: 18px; --font-size-body-lg: 18px;
--font-size-headline-md: 24px; --font-size-headline-md: 24px;
--font-size-headline-sm: 20px; --font-size-headline-sm: 20px;
--font-size-label-md: 14px; --font-size-label-md: 14px;
--font-size-label-sm: 12px; --font-size-label-sm: 12px;
--font-size-label-xs: 11px; --font-size-label-xs: 11px;
--font-size-display-lg: 40px; --font-size-display-lg: 40px;
/* Font Weights */ /* Font Weights */
--font-weight-regular: 400; --font-weight-regular: 400;
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
/* Line Heights */ /* Line Heights */
--line-height-tight: 1.25; --line-height-tight: 1.25;
--line-height-normal: 1.5; --line-height-normal: 1.5;
--line-height-relaxed: 1.625; --line-height-relaxed: 1.625;
/* Border */ /* Border */
--border-thin: 1px solid var(--color-outline); --border-thin: 1px solid var(--color-outline);
--border-regular: 1.5px solid var(--color-outline); --border-regular: 1.5px solid var(--color-outline);
--border-thick: 2px solid var(--color-outline); --border-thick: 2px solid var(--color-outline);
/* Border Radius */ /* Border Radius */
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 10px; --radius-md: 10px;
--radius-lg: 14px; --radius-lg: 14px;
--radius-xl: 20px; --radius-xl: 20px;
--radius-pill: 9999px; --radius-pill: 9999px;
/* Spacing */ /* Spacing */
--spacing-xs: 4px; --spacing-xs: 4px;
--spacing-sm: 8px; --spacing-sm: 8px;
--spacing-md: 16px; --spacing-md: 16px;
--spacing-lg: 24px; --spacing-lg: 24px;
--spacing-xl: 32px; --spacing-xl: 32px;
--spacing-2xl: 48px; --spacing-2xl: 48px;
/* Transition */ /* Transition */
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
--transition-normal: 200ms ease; --transition-normal: 200ms ease;
--transition-slow: 300ms ease; --transition-slow: 300ms ease;
/* Layout */ /* Layout */
--sidebar-width: 280px; --sidebar-width: 280px;
--container-max: 800px; --container-max: 800px;
--content-padding: 24px; --content-padding: 24px;
/* Shadows */ /* Shadows */
--shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.05); --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-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: 0 2px 8px 0 rgba(0, 0, 0, 0.04);
--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);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
font-size: var(--font-size-body-md); font-size: var(--font-size-body-md);
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;
} }
/* Page Frame — sidebar + main layout */ /* Page Frame — sidebar + main layout */
.page { .page {
display: flex; display: flex;
min-height: 100vh; height: 100%;
width: 100%; min-height: 0;
width: 100%;
} }
/* Main Content Area */ /* Main Content Area */
.page__main { .page__main {
flex: 1; flex: 1;
display: flex; display: flex;
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) */
.main-content { .main-content {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; min-height: 0;
background-color: var(--color-background); overflow: hidden;
background-color: var(--color-background);
} }
/* Container — centered content */ /* Container — centered content */
.container { .container {
max-width: var(--container-max); max-width: var(--container-max);
margin: 0 auto; margin: 0 auto;
width: 100%; width: 100%;
} }
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: var(--color-outline-variant); background-color: var(--color-outline-variant);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background-color: var(--color-outline); background-color: var(--color-outline);
} }
/* Focus outline */ /* Focus outline */
:focus-visible { :focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 2px; outline-offset: 2px;
} }
/* Selection */ /* Selection */
::selection { ::selection {
background-color: var(--color-primary-fixed); background-color: var(--color-primary-fixed);
color: var(--color-primary-container); color: var(--color-primary-container);
} }
+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": {