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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:10:47 -04:00
parent 47db945393
commit 6a62db2e85
7 changed files with 148 additions and 77 deletions
+1
View File
@@ -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"
+16 -7
View File
@@ -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 <LoginScreen setAuthenticated={setAuthenticated} />;
}
return (
<>
{isAuthenticated ? (
<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />
) : (
<LoginScreen setAuthenticated={setAuthenticated} />
)}
</>
<Routes>
<Route
path="/"
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
/>
<Route
path="/c/:conversationId"
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
};
+14 -18
View File
@@ -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) => {
</div>
</header>
{messages.length === 0 ? (
{messages.length === 0 && !messagesLoading ? (
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
{showConversations && (
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
@@ -299,7 +295,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
return <QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />;
})}
{isLoading && <AnswerBubble text="" loading={true} />}
{(isLoading || messagesLoading) && <AnswerBubble text="" loading={true} />}
{error && (
<div className="flex justify-center message-enter">
+65 -10
View File
@@ -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<string, string> = {
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
type UseChatOptions = {
selectedConversation: Conversation | null;
createConversation: () => Promise<Conversation>;
refreshConversations: () => Promise<void>;
onSessionExpired: () => void;
@@ -37,19 +37,23 @@ type UseChatOptions = {
};
export function useChat({
selectedConversation,
createConversation,
refreshConversations,
onSessionExpired,
scrollToBottom,
}: UseChatOptions) {
const { conversationId } = useParams();
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const abortControllerRef = useRef<AbortController | null>(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<string | null>(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,
+23 -41
View File
@@ -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<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null);
const refreshConversations = useCallback(async () => {
try {
@@ -30,46 +25,36 @@ export function useConversations() {
refreshConversations();
}, [refreshConversations]);
const selectConversation = useCallback(
async (conversation: Conversation): Promise<Message[]> => {
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<Conversation | null>(() => {
if (!conversationId) return null;
return (
conversations.find((c) => c.id === conversationId) ?? {
id: conversationId,
title: "",
}
},
[],
);
);
}, [conversationId, conversations]);
const createConversation = useCallback(async (): Promise<Conversation> => {
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<void> => {
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<boolean> => {
async (id: string): Promise<void> => {
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,
+4 -1
View File
@@ -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(
<React.StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
}
+25
View File
@@ -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"