fixed page state while generating a message

This commit is contained in:
2026-09-19 05:42:26 -04:00
parent b2d8109019
commit 616282cb35
8 changed files with 411 additions and 228 deletions
+48 -34
View File
@@ -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]
}
/>
</div>