From 616282cb35bda79576293899236530598d767e9d Mon Sep 17 00:00:00 2001 From: chait Date: Sat, 19 Sep 2026 05:42:26 -0400 Subject: [PATCH] fixed page state while generating a message --- .gitignore | 2 + backend/src/app.js | 21 +- frontend/src/App.jsx | 82 ++-- .../components/chat-history/ChatHistory.jsx | 381 +++++++++++------- frontend/src/components/message/Message.jsx | 80 ++-- frontend/src/main.jsx | 8 +- supabasev2.md | 37 ++ vercel.json | 28 ++ 8 files changed, 411 insertions(+), 228 deletions(-) create mode 100644 supabasev2.md create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index 9275b8b..c5dccb8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ logs/ *.njsproj *.sln *.sw? +.vercel +.env* diff --git a/backend/src/app.js b/backend/src/app.js index fa4d3bd..faca497 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,23 +17,26 @@ import supabaseAuth, { optionalAuth } from "./middleware/supabaseAuth.js"; dotenv.config(); const app = express(); -const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; +const CORS_ORIGINS = (process.env.CORS_ORIGIN || "http://localhost:5173") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); // Trust proxy hop count so req.ip (and therefore rate limiting) sees the real // client IP behind a reverse proxy. Off by default; set TRUST_PROXY=1 in prod. if (process.env.TRUST_PROXY) { - app.set("trust proxy", process.env.TRUST_PROXY); + app.set("trust proxy", process.env.TRUST_PROXY); } // Middleware app.use(securityHeaders); app.use(cookieParser()); app.use(express.json()); -app.use(cors({ origin: CORS_ORIGIN, credentials: true })); +app.use(cors({ origin: CORS_ORIGINS, credentials: true })); // Health check app.get("/health", (_req, res) => { - res.json({ status: "ok", provider: process.env.AI_PROVIDER }); + res.json({ status: "ok", provider: process.env.AI_PROVIDER }); }); // Auth routes — public endpoints (signup, signin, signout, me) @@ -47,16 +50,16 @@ app.use("/api/sessions", sessionsLimiter, supabaseAuth, sessionRoutes); // 404 handler app.use((_req, res) => { - res.status(404).json({ error: "Endpoint not found" }); + res.status(404).json({ error: "Endpoint not found" }); }); // Global error handler — never leak stack traces or internals to the client // (Express's default handler emits HTML stack dumps in non-production). // eslint-disable-next-line no-unused-vars app.use((err, _req, res, _next) => { - console.error("Unhandled error:", err?.message); - if (res.headersSent) return; - res.status(500).json({ error: "Internal server error" }); + console.error("Unhandled error:", err?.message); + if (res.headersSent) return; + res.status(500).json({ error: "Internal server error" }); }); -export default app; \ No newline at end of file +export default app; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f8ddcc4..d094666 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,12 +3,11 @@ ======================================== */ import { useState, useRef, useCallback, useEffect } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +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 SelectorFlow from "./components/selector-flow/SelectorFlow"; import { useAuth } from "./lib/auth/SupabaseAuth"; import SignInModal from "./lib/auth/SignIn"; import { resetAppState } from "./appState"; @@ -94,9 +93,10 @@ const chapterData = { }; function App() { - const { isAuthenticated, user, signIn, signUp, signOut, getToken } = useAuth(); + const { isAuthenticated, user, signIn, signUp, signOut, getToken } = + useAuth(); const navigate = useNavigate(); - const { chatId: routeChatId } = useParams(); + 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([]); @@ -166,7 +166,7 @@ function App() { chatLoadHandledRef.current = null; navigate("/"); } - }, [isAuthenticated, navigate, routeChatId]); + }, [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 @@ -178,8 +178,6 @@ function App() { handleChatSelect(routeChatId); }, [routeChatId, isAuthenticated]); - - // ─── Auth handlers ─── const handleAuthError = (msg) => { setMessages((prev) => [ @@ -196,7 +194,10 @@ function App() { const headers = {}; if (token) headers["Authorization"] = `Bearer ${token}`; - const res = await fetch(`${API_BASE}/api/sessions`, { headers, credentials: "include" }); + const res = await fetch(`${API_BASE}/api/sessions`, { + headers, + credentials: "include", + }); if (res.ok) { const data = await res.json(); setSessions(data); @@ -250,7 +251,10 @@ function App() { const headers = {}; if (token) headers["Authorization"] = `Bearer ${token}`; - const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { headers, credentials: "include" }); + const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { + headers, + credentials: "include", + }); if (!res.ok) { throw new Error("Session not found"); } @@ -338,9 +342,9 @@ function App() { const body = { text: text.trim(), chatId: fetchChatId, - grade: activeGrade !== DEFAULTS.grade ? activeGrade : undefined, - subject: activeSubject !== DEFAULTS.subject ? activeSubject : undefined, - chapter: activeChapter !== DEFAULTS.chapter ? activeChapter : undefined, + grade: activeGrade === DEFAULTS.grade ? undefined : activeGrade, + subject: activeSubject === DEFAULTS.subject ? undefined : activeSubject, + chapter: activeChapter === DEFAULTS.chapter ? undefined : activeChapter, }; // Open SSE connection @@ -406,17 +410,19 @@ function App() { try { const data = JSON.parse(jsonStr); if (data.type === "session") { - // Remember which session this conversation belongs to so - // follow-up messages reuse it (history stays together). - // For anonymous users this is the in-memory trial id. setActiveChat(data.id); - // Only reflect signed-in sessions in the URL. An anonymous - // trial id is a capability (no server ownership check), so - // baking it into the URL would leak it via history/logs/ - // referer — keep it in in-memory state and the POST body only. + if (isAuthenticated) { - navigate(`/chat/${data.id}`, { replace: true }); + // 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). @@ -425,19 +431,15 @@ function App() { assistantText += data.content; setMessages((prev) => prev.map((m) => - m.id === assistantId - ? { ...m, text: assistantText } - : 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 - ) + m.id === assistantId ? { ...m, streaming: false } : m, + ), ); } else if (data.type === "error") { setMessages((prev) => @@ -448,8 +450,8 @@ function App() { text: `⚠️ Error: ${data.message}`, streaming: false, } - : m - ) + : m, + ), ); } } catch { @@ -461,7 +463,8 @@ function App() { setIsTyping(false); abortRef.current = null; - // Refresh sessions to update timestamps (signed-in users only) + // 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) => { @@ -478,14 +481,23 @@ function App() { abortRef.current = null; }); }, - [isTyping, activeChat, activeGrade, activeSubject, activeChapter, isAnonymous, anonRemaining, isAuthenticated] + [ + isTyping, + activeChat, + activeGrade, + activeSubject, + activeChapter, + isAnonymous, + anonRemaining, + isAuthenticated, + ], ); const handlePromptClick = useCallback( (promptText) => { handleSend(promptText); }, - [handleSend] + [handleSend], ); /* ─── Selector step handlers ─── */ @@ -619,7 +631,9 @@ function App() { onPromptClick={handlePromptClick} isTyping={isTyping} selectorStep={selectorStep} - selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null} + selectorData={ + selectorStep === "chat" ? null : selectorData[selectorStep] + } /> diff --git a/frontend/src/components/chat-history/ChatHistory.jsx b/frontend/src/components/chat-history/ChatHistory.jsx index 85334f8..937f082 100644 --- a/frontend/src/components/chat-history/ChatHistory.jsx +++ b/frontend/src/components/chat-history/ChatHistory.jsx @@ -2,167 +2,256 @@ 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 ChatHistory = ({ - messages, - activeSubject, - activeGrade, - activeChapter, - onPromptClick, - isTyping, - selectorStep, - selectorData, + messages, + activeSubject, + activeGrade, + activeChapter, + onPromptClick, + isTyping, + selectorStep, + selectorData, }) => { - const hasMessages = messages.length > 0; - const isSelector = selectorStep !== "chat"; + const hasMessages = messages.length > 0; + const isSelector = selectorStep !== "chat"; - const suggestions = [ - { - id: 1, - icon: "eco", - iconFilled: true, - color: "--color-accent-green", - colorBg: "--color-accent-green-bg", - keywords: ["photosynthesis"], - text: "Explain how ", - textEnd: " works in simple terms", - }, - { - id: 2, - icon: "calculate", - iconFilled: false, - color: "--color-accent-blue", - colorBg: "--color-accent-blue-bg", - keywords: ["derivatives"], - text: "Help me understand ", - textEnd: " in calculus", - }, - { - id: 3, - icon: "account_balance", - iconFilled: false, - color: "--color-accent-orange", - colorBg: "--color-accent-orange-bg", - keywords: ["Industrial"], - text: "What caused the ", - textEnd: " Revolution?", - }, - { - id: 4, - icon: "edit", - iconFilled: true, - color: "--color-accent-purple", - colorBg: "--color-accent-purple-bg", - keywords: ["Romeo and Juliet"], - text: "Help me analyze themes in ", - textEnd: "", - }, - ]; + const suggestions = [ + { + id: 1, + icon: "eco", + iconFilled: true, + color: "--color-accent-green", + colorBg: "--color-accent-green-bg", + keywords: ["photosynthesis"], + text: "Explain how ", + textEnd: " works in simple terms", + }, + { + id: 2, + icon: "calculate", + iconFilled: false, + color: "--color-accent-blue", + colorBg: "--color-accent-blue-bg", + keywords: ["derivatives"], + text: "Help me understand ", + textEnd: " in calculus", + }, + { + id: 3, + icon: "account_balance", + iconFilled: false, + color: "--color-accent-orange", + colorBg: "--color-accent-orange-bg", + keywords: ["Industrial"], + text: "What caused the ", + textEnd: " Revolution?", + }, + { + id: 4, + icon: "edit", + iconFilled: true, + color: "--color-accent-purple", + colorBg: "--color-accent-purple-bg", + keywords: ["Romeo and Juliet"], + text: "Help me analyze themes in ", + textEnd: "", + }, + ]; - const fullPromptTexts = suggestions.map((s) => s.text + s.keywords.join(" ") + s.textEnd); + const fullPromptTexts = suggestions.map( + (s) => s.text + s.keywords.join(" ") + s.textEnd, + ); - const streamingMsg = messages.find((m) => m.streaming); + const streamingMsg = messages.find((m) => m.streaming); + const historyRef = useRef(null); - /* ─── Messages mode ─── */ - if (hasMessages) { - return ( -
-
- {messages.map((message) => ( - - ))} - {isTyping && !streamingMsg && ( - - )} -
el && el.scrollIntoView({ behavior: "smooth", block: "end" })} /> -
-
- ); - } + // 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]); - /* ─── Selector mode (grade → subject → chapter) ─── */ - if (isSelector && selectorData) { - return ( -
- -
- ); - } + /* ─── Messages mode ─── */ + if (hasMessages) { + return ( +
+
+ {messages.map((message) => ( + + ))} + {isTyping && !streamingMsg && ( + + )} +
+
+ ); + } - /* ─── Welcome / suggestions mode ─── */ - return ( -
-
- {/* Welcome Header */} -
-
- menu_book - auto_awesome -
-

Welcome to padhle

-

Your AI tutor for grades 6–12. Ask anything!

-
+ /* ─── Selector mode (grade → subject → chapter) ─── */ + if (isSelector && selectorData) { + return ( +
+ +
+ ); + } - {/* Bento Grid Suggestions */} -
- {suggestions.map((suggestion) => { - const colorVar = suggestion.color.replace("--", "var("); - const bgColorVar = suggestion.colorBg.replace("--", "var("); + /* ─── Welcome / suggestions mode ─── */ + return ( +
+
+ {/* Welcome Header */} +
+
+ + menu_book + + + auto_awesome + +
+

+ Welcome to padhle +

+

+ Your AI tutor for grades 6–12. + Ask anything! +

+
- return ( - + ); + })} +
+ + {/* Info Panel */} +
+
+ + lightbulb + +
+
+

+ Focused on your learning +

+

+ I'll answer questions + related to your selected + subject and chapter +
+ using your textbook + content. +

+
+
+
-
- {suggestion.text} - - {suggestion.keywords[0]} - - {suggestion.textEnd} -
- arrow_forward - - ); - })} -
- - {/* Info Panel */} -
-
- lightbulb -
-
-

Focused on your learning

-

- I'll answer questions related to your selected subject and chapter -
- using your textbook content. -

-
-
-
-
- ); + ); }; export default ChatHistory; diff --git a/frontend/src/components/message/Message.jsx b/frontend/src/components/message/Message.jsx index 41adbdc..8476653 100644 --- a/frontend/src/components/message/Message.jsx +++ b/frontend/src/components/message/Message.jsx @@ -2,7 +2,6 @@ Message Component — padhle (Refined Workspace) ======================================== */ -import { useEffect, useRef } from "react"; import ReactMarkdown from "react-markdown"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; @@ -12,61 +11,76 @@ import "./Message.css"; const Message = ({ message }) => { const { role, text, streaming } = message; const isUser = role === "user"; - const endRef = useRef(null); - - // Auto-scroll to bottom when text updates - useEffect(() => { - if (endRef.current) { - endRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); - } - }, [text, streaming]); return ( -
+
{!isUser && (
- school + + school +
)}
-
- {isUser ? ( - text - ) : ( - text && ( - - {text} - - ) +
+ {isUser + ? text + : text && ( + + {text} + + )} + {streaming && ( + + + + + )} - {streaming && - - - - }
{/* Action buttons for assistant messages */} {!isUser && text && (
- - -
)} -
); }; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 650578a..8b5da13 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -14,14 +14,10 @@ ReactDOM.createRoot(document.getElementById("root")).render( - {/* Both paths render the same App shell; the chat id is read via - useParams() to support deep links like /chat/:chatId. A catch-all - keeps unknown paths on the app (Vite SPA fallback serves index.html). */} - } /> - } /> + {/* One route owns the app shell so URL changes never remount it. */} } /> - + , ); diff --git a/supabasev2.md b/supabasev2.md new file mode 100644 index 0000000..c8c7b64 --- /dev/null +++ b/supabasev2.md @@ -0,0 +1,37 @@ +│ 🔧 Development Tools │ +├─────────┬────────────────────────────┤ +│ Studio │ http://http://192.168.50.153/:54323 │ +│ Mailpit │ http://http://192.168.50.153/:54324 │ +│ MCP │ http://http://192.168.50.153/:54321/mcp │ +╰─────────┴────────────────────────────╯ + +╭──────────────────────────────────────────────────────╮ +│ 🌐 APIs │ +├────────────────┬─────────────────────────────────────┤ +│ Project URL │ http://http://192.168.50.153/:54321 │ +│ REST │ http://http://192.168.50.153/:54321/rest/v1 │ +│ GraphQL │ http://http://192.168.50.153/:54321/graphql/v1 │ +│ Edge Functions │ http://http://192.168.50.153/:54321/functions/v1 │ +╰────────────────┴─────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────╮ +│ ⛁ Database │ +├─────┬─────────────────────────────────────────────────────────┤ +│ URL │ postgresql://postgres:postgres@http://192.168.50.153/:54322/postgres │ +╰─────┴─────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────╮ +│ 🔑 Authentication Keys │ +├─────────────┬────────────────────────────────────────────────┤ +│ Publishable │ sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH │ +│ Secret │ sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz │ +╰─────────────┴────────────────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────────────────────╮ +│ 📦 Storage (S3) │ +├────────────┬──────────────────────────────────────────────────────────────────┤ +│ URL │ http://http://192.168.50.153/:54321/storage/v1/s3 │ +│ Access Key │ 625729a08b95bf1b7ff351a663f3a23c │ +│ Secret Key │ 850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907 │ +│ Region │ local │ +╰────────────┴────────── diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..405ce1e --- /dev/null +++ b/vercel.json @@ -0,0 +1,28 @@ +{ + "services": { + "frontend": { + "root": ".", + "framework": "vite" + }, + "backend": { + "root": "backend", + "entrypoint": "src/app.js" + } + }, + "rewrites": [ + { + "source": "/api(/.*)?", + "destination": { + "type": "service", + "service": "backend" + } + }, + { + "source": "/(.*)", + "destination": { + "type": "service", + "service": "frontend" + } + } + ] +}