From 6a62db2e852515e97fb8375a8d0a4f64475d8513 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Wed, 9 Sep 2026 22:10:47 -0400 Subject: [PATCH] Add per-conversation URLs with client-side routing Give each conversation a deep-linkable URL (/c/:conversationId) so threads survive refresh, support browser back/forward, and are shareable within a session. The app was already an SPA but had no router. - Add react-router-dom; wrap the app in BrowserRouter and define routes ("/" for the empty/new state, "/c/:conversationId" for a thread) - Make the URL the source of truth for selection: selectedConversation is derived from the :conversationId param; create/delete navigate instead of mutating local selection state - Load a thread's messages in a URL-keyed effect that clears immediately for an instant switch, with a stale-response guard - Preserve the optimistic message + in-flight stream when the first message lazily creates a conversation and navigates into it (skip-ref keyed on the new id, guarded against the null/home-route collision) No backend changes: app.py already serves index.html for unmatched paths. Co-Authored-By: Claude Opus 4.8 --- raggr-frontend/package.json | 1 + raggr-frontend/src/App.tsx | 23 ++++-- raggr-frontend/src/components/ChatScreen.tsx | 32 ++++----- raggr-frontend/src/hooks/useChat.ts | 75 +++++++++++++++++--- raggr-frontend/src/hooks/useConversations.ts | 64 ++++++----------- raggr-frontend/src/index.tsx | 5 +- raggr-frontend/yarn.lock | 25 +++++++ 7 files changed, 148 insertions(+), 77 deletions(-) diff --git a/raggr-frontend/package.json b/raggr-frontend/package.json index 00bf0de..1550216 100644 --- a/raggr-frontend/package.json +++ b/raggr-frontend/package.json @@ -20,6 +20,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.3", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.5.0", "watch": "^1.0.2" diff --git a/raggr-frontend/src/App.tsx b/raggr-frontend/src/App.tsx index 4e12037..e5c4046 100644 --- a/raggr-frontend/src/App.tsx +++ b/raggr-frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { Navigate, Route, Routes } from "react-router-dom"; import "./App.css"; import { AuthProvider } from "./contexts/AuthContext"; import { ChatScreen } from "./components/ChatScreen"; @@ -23,14 +24,22 @@ const AppContainer = () => { ); } + if (!isAuthenticated) { + return ; + } + return ( - <> - {isAuthenticated ? ( - - ) : ( - - )} - + + } + /> + } + /> + } /> + ); }; diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 361e860..6e8e832 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -1,4 +1,5 @@ import { useCallback, useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { LogOut, Shield, Clock, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react"; import { QuestionBubble } from "./QuestionBubble"; import { AnswerBubble } from "./AnswerBubble"; @@ -18,6 +19,7 @@ type ChatScreenProps = { }; export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { + const navigate = useNavigate(); const [query, setQuery] = useState(""); const [simbaMode, setSimbaMode] = useState(false); const [showConversations, setShowConversations] = useState(false); @@ -39,7 +41,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { const { conversations, selectedConversation, - selectConversation, createConversation, renameConversation, deleteConversation, @@ -50,8 +51,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { const { messages, - setMessages, isLoading, + messagesLoading, pendingImage, setPendingImage, sendMessage, @@ -59,7 +60,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { error, clearError, } = useChat({ - selectedConversation, createConversation, refreshConversations, onSessionExpired, @@ -70,18 +70,17 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { isLoadingRef.current = isLoading; const handleSelectConversation = useCallback( - async (conversation: { title: string; id: string }) => { + (conversation: { title: string; id: string }) => { setShowConversations(false); - const loaded = await selectConversation(conversation); - setMessages(loaded); + navigate(`/c/${conversation.id}`); }, - [selectConversation, setMessages], + [navigate], ); - const handleCreateNewConversation = useCallback(async () => { - await createConversation(); - setMessages([]); - }, [createConversation, setMessages]); + const handleCreateNewConversation = useCallback(() => { + setShowConversations(false); + navigate("/"); + }, [navigate]); const handleQuestionSubmit = useCallback(() => { sendMessage(query, simbaMode); @@ -102,11 +101,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { const handleToggleSimbaMode = useCallback(() => setSimbaMode((v) => !v), []); const handleDeleteConversation = useCallback( - async (id: string) => { - const wasSelected = await deleteConversation(id); - if (wasSelected) setMessages([]); - }, - [deleteConversation, setMessages], + (id: string) => deleteConversation(id), + [deleteConversation], ); const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]); @@ -234,7 +230,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { - {messages.length === 0 ? ( + {messages.length === 0 && !messagesLoading ? (
{showConversations && (
@@ -299,7 +295,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { return ; })} - {isLoading && } + {(isLoading || messagesLoading) && } {error && (
diff --git a/raggr-frontend/src/hooks/useChat.ts b/raggr-frontend/src/hooks/useChat.ts index f433a48..13730d3 100644 --- a/raggr-frontend/src/hooks/useChat.ts +++ b/raggr-frontend/src/hooks/useChat.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useRef } from "react"; +import { useParams } from "react-router-dom"; import { conversationService } from "../api/conversationService"; import type { Conversation } from "./useConversations"; @@ -29,7 +30,6 @@ const TOOL_MESSAGES: Record = { const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; type UseChatOptions = { - selectedConversation: Conversation | null; createConversation: () => Promise; refreshConversations: () => Promise; onSessionExpired: () => void; @@ -37,19 +37,23 @@ type UseChatOptions = { }; export function useChat({ - selectedConversation, createConversation, refreshConversations, onSessionExpired, scrollToBottom, }: UseChatOptions) { + const { conversationId } = useParams(); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [messagesLoading, setMessagesLoading] = useState(false); const [pendingImage, setPendingImage] = useState(null); const [error, setError] = useState(null); const isMountedRef = useRef(true); const abortControllerRef = useRef(null); + // Set to a conversation id we just created locally, so the route-change + // loader below doesn't wipe the optimistic message + in-flight stream. + const skipLoadForIdRef = useRef(null); useEffect(() => { isMountedRef.current = true; @@ -67,15 +71,65 @@ export function useChat({ [scrollToBottom], ); + // Load messages whenever the active conversation (URL) changes. Clearing + // immediately makes the switch feel instant instead of lingering on the old + // thread until the fetch resolves. + useEffect(() => { + // Skip only for a conversation we just created locally (its optimistic + // message + stream are already in state). Guard against the null sentinel + // colliding with the "no conversation" (home) route. + if ( + skipLoadForIdRef.current !== null && + skipLoadForIdRef.current === conversationId + ) { + skipLoadForIdRef.current = null; + return; + } + if (!conversationId) { + setMessages([]); + setMessagesLoading(false); + return; + } + let cancelled = false; + setMessages([]); + setMessagesLoading(true); + (async () => { + try { + const fetched = await conversationService.getConversation(conversationId); + if (cancelled) return; + setMessages( + (fetched.messages ?? []).map((m) => ({ + text: m.text, + speaker: m.speaker, + image_key: m.image_key, + })), + ); + scrollToBottom(); + } catch (err) { + if (!cancelled) { + console.error("Failed to load messages:", err); + setMessages([]); + } + } finally { + if (!cancelled) setMessagesLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [conversationId, scrollToBottom]); + const sendMessage = useCallback( async (query: string, simbaMode: boolean) => { if ((!query.trim() && !pendingImage) || isLoading) return; - let activeConversation = selectedConversation; - let createdNew = false; - if (!activeConversation) { - activeConversation = await createConversation(); - createdNew = true; + let activeId = conversationId; + const createdNew = !activeId; + if (!activeId) { + const created = await createConversation(); + // Prevent the route-change loader from clearing what we're about to add. + skipLoadForIdRef.current = created.id; + activeId = created.id; } const imageFile = pendingImage; @@ -103,7 +157,7 @@ export function useChat({ if (imageFile) { const uploadResult = await conversationService.uploadImage( imageFile, - activeConversation.id, + activeId, ); imageKey = uploadResult.image_key; @@ -121,7 +175,7 @@ export function useChat({ await conversationService.streamQuery( query, - activeConversation.id, + activeId, (event) => { if (!isMountedRef.current) return; if (event.type === "tool_start") { @@ -171,7 +225,7 @@ export function useChat({ [ pendingImage, isLoading, - selectedConversation, + conversationId, createConversation, refreshConversations, onSessionExpired, @@ -190,6 +244,7 @@ export function useChat({ messages, setMessages: updateMessages, isLoading, + messagesLoading, pendingImage, setPendingImage, sendMessage, diff --git a/raggr-frontend/src/hooks/useConversations.ts b/raggr-frontend/src/hooks/useConversations.ts index 666b2da..78a2f0f 100644 --- a/raggr-frontend/src/hooks/useConversations.ts +++ b/raggr-frontend/src/hooks/useConversations.ts @@ -1,4 +1,5 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import { conversationService } from "../api/conversationService"; export type Conversation = { @@ -6,16 +7,10 @@ export type Conversation = { id: string; }; -type Message = { - text: string; - speaker: "simba" | "user" | "tool"; - image_key?: string | null; -}; - export function useConversations() { + const navigate = useNavigate(); + const { conversationId } = useParams(); const [conversations, setConversations] = useState([]); - const [selectedConversation, setSelectedConversation] = - useState(null); const refreshConversations = useCallback(async () => { try { @@ -30,46 +25,36 @@ export function useConversations() { refreshConversations(); }, [refreshConversations]); - const selectConversation = useCallback( - async (conversation: Conversation): Promise => { - setSelectedConversation(conversation); - try { - const fetched = await conversationService.getConversation( - conversation.id, - ); - return (fetched.messages ?? []).map((m) => ({ - text: m.text, - speaker: m.speaker, - image_key: m.image_key, - })); - } catch (err) { - console.error("Failed to load messages:", err); - return []; + // The active conversation is derived from the URL. Fall back to a minimal + // object while the list is still loading (e.g. on a deep-link refresh). + const selectedConversation = useMemo(() => { + if (!conversationId) return null; + return ( + conversations.find((c) => c.id === conversationId) ?? { + id: conversationId, + title: "", } - }, - [], - ); + ); + }, [conversationId, conversations]); const createConversation = useCallback(async (): Promise => { const newConv = await conversationService.createConversation(); const conversation = { title: newConv.name, id: newConv.id }; setConversations((prev) => [conversation, ...prev]); - setSelectedConversation(conversation); + navigate(`/c/${conversation.id}`); return conversation; - }, []); + }, [navigate]); const renameConversation = useCallback( async (id: string, title: string): Promise => { const trimmed = title.trim(); if (!trimmed) return; - // Optimistic update, roll back on failure + // Optimistic update, roll back on failure. selectedConversation is + // derived from `conversations`, so it updates automatically. const previous = conversations; setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)), ); - setSelectedConversation((prev) => - prev?.id === id ? { ...prev, title: trimmed } : prev, - ); try { await conversationService.renameConversation(id, trimmed); } catch (err) { @@ -81,28 +66,25 @@ export function useConversations() { ); const deleteConversation = useCallback( - async (id: string): Promise => { + async (id: string): Promise => { const previous = conversations; - const wasSelected = selectedConversation?.id === id; + const wasActive = conversationId === id; setConversations((prev) => prev.filter((c) => c.id !== id)); - if (wasSelected) setSelectedConversation(null); + // Navigating home clears the message view via the route change. + if (wasActive) navigate("/"); try { await conversationService.deleteConversation(id); - return wasSelected; } catch (err) { console.error("Failed to delete conversation:", err); setConversations(previous); - return false; } }, - [conversations, selectedConversation], + [conversations, conversationId, navigate], ); return { conversations, selectedConversation, - setSelectedConversation, - selectConversation, createConversation, renameConversation, deleteConversation, diff --git a/raggr-frontend/src/index.tsx b/raggr-frontend/src/index.tsx index f36a44d..ae34e40 100644 --- a/raggr-frontend/src/index.tsx +++ b/raggr-frontend/src/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; import App from './App'; const rootEl = document.getElementById('root'); @@ -7,7 +8,9 @@ if (rootEl) { const root = ReactDOM.createRoot(rootEl); root.render( - + + + , ); } diff --git a/raggr-frontend/yarn.lock b/raggr-frontend/yarn.lock index 9916378..8626032 100644 --- a/raggr-frontend/yarn.lock +++ b/raggr-frontend/yarn.lock @@ -598,6 +598,11 @@ comma-separated-tokens@^2.0.0: resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== +cookie@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" + integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== + core-js@~3.47.0: version "3.47.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17" @@ -1628,6 +1633,21 @@ react-refresh@^0.18.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062" integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw== +react-router-dom@^7.18.3: + version "7.18.3" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.3.tgz#0688c91b4b376c8b0dcaf0a0064975d52a32e0f7" + integrity sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg== + dependencies: + react-router "7.18.3" + +react-router@7.18.3: + version "7.18.3" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.3.tgz#2a3257aa7c5edd5a71f878063e4c7f3fcfc4b76a" + integrity sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA== + dependencies: + cookie "^1.0.1" + set-cookie-parser "^2.6.0" + react@^19.1.1: version "19.2.4" resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" @@ -1706,6 +1726,11 @@ semver@^7.5.3: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +set-cookie-parser@^2.6.0: + version "2.7.2" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68" + integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + simple-update-notifier@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb"