diff --git a/frontend/server.ts b/frontend/server.ts index d0e2b00..65e20c0 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -382,7 +382,10 @@ app.all("/api/copilotkit/*", async (c) => { headers: outbound.headers, body: JSON.stringify({ ...body, messages: paired }), }); - } catch { + } catch (err) { + // Skipping the repair is survivable; doing it silently is not — + // orphan tool calls then fail downstream with no trace of why. + console.error("[copilotkit] orphan-tool-call repair skipped:", err); return; } }, diff --git a/frontend/src/AuthContext.tsx b/frontend/src/AuthContext.tsx index f82b110..a32c4df 100644 --- a/frontend/src/AuthContext.tsx +++ b/frontend/src/AuthContext.tsx @@ -20,12 +20,27 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); useEffect(() => { - // Probe auth state by hitting a protected endpoint. - // If the ws_token cookie is valid, the server returns 200; else 401. - fetch("/api/v1/auth/me", { credentials: "include" }) - .then((r) => setAuthenticated(r.ok)) - .catch(() => setAuthenticated(false)) - .finally(() => setIsLoading(false)); + // Probe auth state by hitting a protected endpoint. An HTTP status is an + // authoritative answer (200 = in, 401 = out); a thrown fetch is a NETWORK + // failure and must not log the user out — retry once before giving up. + let cancelled = false; + const probe = () => fetch("/api/v1/auth/me", { credentials: "include" }); + probe() + .catch(() => new Promise((r) => setTimeout(r, 1500)).then(probe)) + .then((r) => { + if (!cancelled) setAuthenticated((r as Response).ok); + }) + .catch(() => { + // Network still down: leave unauthenticated but log the real cause. + console.warn("Auth probe failed twice (network) — backend unreachable"); + if (!cancelled) setAuthenticated(false); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; }, []); const logout = async () => { diff --git a/frontend/src/contexts/privacy-context.tsx b/frontend/src/contexts/privacy-context.tsx index 260ae39..fd58c66 100644 --- a/frontend/src/contexts/privacy-context.tsx +++ b/frontend/src/contexts/privacy-context.tsx @@ -8,11 +8,10 @@ const PrivacyContext = createContext<{ }>({ privacyMode: false, togglePrivacy: () => {} }); export function PrivacyProvider({ children }: { children: ReactNode }) { - const [privacyMode, setPrivacyMode] = useState(false); - - useEffect(() => { - setPrivacyMode(localStorage.getItem("privacyMode") === "true"); - }, []); + // Lazy initializer: sensitive values must never flash unmasked on reload. + const [privacyMode, setPrivacyMode] = useState( + () => localStorage.getItem("privacyMode") === "true", + ); useEffect(() => { document.documentElement.classList.toggle("privacy", privacyMode); diff --git a/frontend/src/contexts/theme-context.tsx b/frontend/src/contexts/theme-context.tsx index f0ab679..b6ba1aa 100644 --- a/frontend/src/contexts/theme-context.tsx +++ b/frontend/src/contexts/theme-context.tsx @@ -10,18 +10,15 @@ const ThemeContext = createContext<{ }>({ theme: "dark", toggleTheme: () => {} }); export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setTheme] = useState("dark"); - - // Initialize once on mount (localStorage + prefers-color-scheme). - useEffect(() => { + // Lazy initializer: resolving the theme before first paint avoids the + // flash of wrong theme an effect-based init caused. + const [theme, setTheme] = useState(() => { const saved = localStorage.getItem("theme") as Theme | null; - const initial: Theme = saved - ? saved - : window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; - setTheme(initial); - }, []); + if (saved) return saved; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + }); useEffect(() => { document.documentElement.classList.toggle("dark", theme === "dark");