Replace inline mobile menu with slide-in drawer and paginate conversations

Rework mobile navigation into a proper left slide-in drawer with a dimmed
backdrop, sharing a new SidebarContent component with the desktop sidebar so
mobile users can reach Admin/Scheduler/Sign out. The sidebar now loads only the
10 most recent conversations; a dedicated /conversations page browses the full
list with server-side search and "Load more" pagination.

- Backend: limit/offset/search query params on GET /api/conversation/
- SidebarContent shared by desktop sidebar and mobile drawer
- useConversationBrowser hook powers the dedicated page
- Remove duplicated inline conversation lists from ChatScreen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:32:19 -04:00
parent 35a2e10bc8
commit 09dd983c4e
9 changed files with 421 additions and 104 deletions
+17 -2
View File
@@ -299,8 +299,23 @@ async def create_conversation():
async def get_all_conversations():
user_uuid = get_jwt_identity()
user = await blueprints.users.models.User.get(id=user_uuid)
conversations = Conversation.filter(user=user).order_by("-updated_at")
serialized_conversations = await PydListConversation.from_queryset(conversations)
query = Conversation.filter(user=user)
search = request.args.get("search", "").strip()
if search:
query = query.filter(name__icontains=search)
query = query.order_by("-updated_at")
# `limit` is optional: when omitted we return the full list (backward
# compatible with any non-frontend callers). `offset` only applies with it.
limit = request.args.get("limit", type=int)
if limit is not None:
offset = request.args.get("offset", default=0, type=int)
query = query.offset(offset).limit(limit)
serialized_conversations = await PydListConversation.from_queryset(query)
return jsonify(serialized_conversations.model_dump())
+8
View File
@@ -149,6 +149,14 @@ body {
animation: fadeSlideUp 0.3s ease-out forwards;
}
@keyframes drawer-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.animate-drawer-in {
animation: drawer-in 0.25s ease-out;
}
@keyframes catPulse {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); }
40% { opacity: 1; transform: scale(1); }
+2
View File
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import "./App.css";
import { AuthProvider } from "./contexts/AuthContext";
import { ChatScreen } from "./components/ChatScreen";
import { ConversationsPage } from "./components/ConversationsPage";
import { LoginScreen } from "./components/LoginScreen";
import { useAuthCheck } from "./hooks/useAuthCheck";
import catIcon from "./assets/cat.png";
@@ -38,6 +39,7 @@ const AppContainer = () => {
path="/c/:conversationId"
element={<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />}
/>
<Route path="/conversations" element={<ConversationsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
+12 -2
View File
@@ -131,9 +131,19 @@ class ConversationService {
}
}
async getAllConversations(): Promise<Conversation[]> {
async getAllConversations(params?: {
limit?: number;
offset?: number;
search?: string;
}): Promise<Conversation[]> {
const search = new URLSearchParams();
if (params?.limit != null) search.set("limit", String(params.limit));
if (params?.offset != null) search.set("offset", String(params.offset));
if (params?.search) search.set("search", params.search);
const qs = search.toString();
const response = await userService.fetchWithRefreshToken(
`${this.conversationBaseUrl}/`,
`${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`,
{
method: "GET",
},
+51 -99
View File
@@ -1,11 +1,11 @@
import { useCallback, useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { LogOut, Shield, Clock, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
import { PanelLeftOpen, Menu, X } from "lucide-react";
import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble";
import { MessageInput } from "./MessageInput";
import { ConversationList } from "./ConversationList";
import { SidebarContent } from "./SidebarContent";
import { AdminPanel } from "./AdminPanel";
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
import { cn } from "../lib/utils";
@@ -114,6 +114,16 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
setAuthenticated(false);
};
const sidebarProps = {
conversations,
onCreateNewConversation: handleCreateNewConversation,
onSelectConversation: handleSelectConversation,
onRenameConversation: renameConversation,
onDeleteConversation: handleDeleteConversation,
selectedId: selectedConversation?.id,
isAdmin,
};
return (
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
{/* Desktop Sidebar */}
@@ -139,67 +149,41 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
/>
</div>
) : (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
<div className="flex items-center gap-2.5">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
<h2
className="text-lg font-bold text-cream tracking-tight"
style={{ fontFamily: "var(--font-display)" }}
>
asksimba
</h2>
</div>
<button
onClick={() => setSidebarCollapsed(true)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<PanelLeftClose size={15} />
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-3">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
onRenameConversation={renameConversation}
onDeleteConversation={handleDeleteConversation}
selectedId={selectedConversation?.id}
/>
</div>
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
{isAdmin && (
<>
<button
onClick={() => setShowAdminPanel(true)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Shield size={14} />
<span>Admin</span>
</button>
<button
onClick={() => setShowScheduler(true)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Clock size={14} />
<span>Scheduler</span>
</button>
</>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<LogOut size={14} />
<span>Sign out</span>
</button>
</div>
</div>
<SidebarContent
{...sidebarProps}
onShowAdmin={() => setShowAdminPanel(true)}
onShowScheduler={() => setShowScheduler(true)}
onLogout={handleLogout}
onCollapse={() => setSidebarCollapsed(true)}
/>
)}
</aside>
{/* Mobile drawer */}
{showConversations && (
<div className="md:hidden fixed inset-0 z-50 flex">
<aside className="w-72 max-w-[80%] h-full bg-sidebar-bg shadow-xl animate-drawer-in">
<SidebarContent
{...sidebarProps}
onShowAdmin={() => {
setShowConversations(false);
setShowAdminPanel(true);
}}
onShowScheduler={() => {
setShowConversations(false);
setShowScheduler(true);
}}
onLogout={handleLogout}
onCloseDrawer={() => setShowConversations(false)}
/>
</aside>
<div
className="flex-1 bg-charcoal/40 backdrop-blur-sm"
onClick={() => setShowConversations(false)}
/>
</div>
)}
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
@@ -214,37 +198,17 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
asksimba
</h1>
</div>
<div className="flex items-center gap-2">
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={() => setShowConversations((v) => !v)}
>
{showConversations ? <X size={16} /> : <Menu size={16} />}
</button>
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={handleLogout}
>
<LogOut size={15} />
</button>
</div>
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={() => setShowConversations(true)}
aria-label="Open menu"
>
<Menu size={16} />
</button>
</header>
{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">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
onRenameConversation={renameConversation}
onDeleteConversation={handleDeleteConversation}
selectedId={selectedConversation?.id}
variant="light"
/>
</div>
)}
<div className="relative">
<div className="absolute -inset-6 bg-amber-soft/20 rounded-full blur-3xl" />
<img src={catIcon} alt="Simba" className="relative w-36 h-36" />
@@ -275,18 +239,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
<>
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-3xl mx-auto flex flex-col gap-6">
{showConversations && (
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
selectedId={selectedConversation?.id}
variant="light"
/>
</div>
)}
{messages.map((msg, index) => {
if (msg.speaker === "tool")
return <ToolBubble key={index} text={msg.text} />;
@@ -0,0 +1,94 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Search } from "lucide-react";
import { ConversationList } from "./ConversationList";
import { useConversationBrowser } from "../hooks/useConversationBrowser";
import type { Conversation } from "../hooks/useConversations";
import { Input } from "./ui/input";
import catIcon from "../assets/cat.png";
export const ConversationsPage = () => {
const navigate = useNavigate();
const {
items,
search,
setSearch,
hasMore,
loading,
loadMore,
renameConversation,
deleteConversation,
} = useConversationBrowser();
const handleSelect = useCallback(
(conversation: Conversation) => navigate(`/c/${conversation.id}`),
[navigate],
);
const handleCreateNew = useCallback(() => navigate("/"), [navigate]);
return (
<div className="h-screen h-[100dvh] flex flex-col bg-cream overflow-hidden">
<header className="flex items-center gap-3 px-4 py-3 bg-warm-white border-b border-sand-light/60">
<button
onClick={() => navigate(-1)}
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
aria-label="Back"
>
<ArrowLeft size={16} />
</button>
<img src={catIcon} alt="Simba" className="w-10 h-10" />
<h1
className="text-base font-bold text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
All conversations
</h1>
</header>
<div className="flex-1 overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 py-6 flex flex-col gap-4">
<div className="relative">
<Search
size={15}
className="absolute left-3 top-1/2 -translate-y-1/2 text-warm-gray/60"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search conversations..."
className="h-10 pl-9"
/>
</div>
<div className="bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
conversations={items}
onCreateNewConversation={handleCreateNew}
onSelectConversation={handleSelect}
onRenameConversation={renameConversation}
onDeleteConversation={deleteConversation}
variant="light"
/>
{items.length === 0 && !loading && (
<p className="text-center text-sm text-warm-gray/70 py-6">
{search ? "No conversations match your search." : "No conversations yet."}
</p>
)}
</div>
{hasMore && (
<button
onClick={loadMore}
disabled={loading}
className="self-center px-4 py-2 rounded-xl text-sm text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Loading..." : "Load more"}
</button>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,128 @@
import { Link } from "react-router-dom";
import {
Clock,
LogOut,
PanelLeftClose,
Shield,
ListTree,
X,
} from "lucide-react";
import { ConversationList } from "./ConversationList";
import type { Conversation } from "../hooks/useConversations";
import catIcon from "../assets/cat.png";
type SidebarContentProps = {
conversations: Conversation[];
onCreateNewConversation: () => void;
onSelectConversation: (conversation: Conversation) => void;
onRenameConversation: (id: string, title: string) => void;
onDeleteConversation: (id: string) => void;
selectedId?: string;
isAdmin: boolean;
onShowAdmin: () => void;
onShowScheduler: () => void;
onLogout: () => void;
/** Desktop: collapse the sidebar. Renders a PanelLeftClose button. */
onCollapse?: () => void;
/** Mobile: close the drawer. Renders an X button. */
onCloseDrawer?: () => void;
};
/**
* The dark sidebar body shared by the desktop sidebar and the mobile drawer.
* Shows the recent conversations, a link to the full list, and admin/account
* actions. Exactly one of `onCollapse`/`onCloseDrawer` is expected per usage.
*/
export const SidebarContent = ({
conversations,
onCreateNewConversation,
onSelectConversation,
onRenameConversation,
onDeleteConversation,
selectedId,
isAdmin,
onShowAdmin,
onShowScheduler,
onLogout,
onCollapse,
onCloseDrawer,
}: SidebarContentProps) => {
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
<div className="flex items-center gap-2.5">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
<h2
className="text-lg font-bold text-cream tracking-tight"
style={{ fontFamily: "var(--font-display)" }}
>
asksimba
</h2>
</div>
{onCollapse && (
<button
onClick={onCollapse}
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<PanelLeftClose size={15} />
</button>
)}
{onCloseDrawer && (
<button
onClick={onCloseDrawer}
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<X size={16} />
</button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 py-3">
<ConversationList
conversations={conversations}
onCreateNewConversation={onCreateNewConversation}
onSelectConversation={onSelectConversation}
onRenameConversation={onRenameConversation}
onDeleteConversation={onDeleteConversation}
selectedId={selectedId}
/>
</div>
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
<Link
to="/conversations"
onClick={onCloseDrawer}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<ListTree size={14} />
<span>See all conversations</span>
</Link>
{isAdmin && (
<>
<button
onClick={onShowAdmin}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Shield size={14} />
<span>Admin</span>
</button>
<button
onClick={onShowScheduler}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<Clock size={14} />
<span>Scheduler</span>
</button>
</>
)}
<button
onClick={onLogout}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<LogOut size={14} />
<span>Sign out</span>
</button>
</div>
</div>
);
};
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { conversationService } from "../api/conversationService";
import type { Conversation } from "./useConversations";
const PAGE_SIZE = 20;
/**
* Data source for the dedicated /conversations page: server-side search plus
* "Load more" pagination. Detects whether more rows exist by asking for one
* extra item beyond the page size.
*/
export function useConversationBrowser() {
const [items, setItems] = useState<Conversation[]>([]);
const [search, setSearchValue] = useState("");
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
// Guards against a slow earlier request overwriting a newer one.
const requestIdRef = useRef(0);
const fetchPage = useCallback(
async (offset: number, searchTerm: string) => {
const requestId = ++requestIdRef.current;
setLoading(true);
try {
const fetched = await conversationService.getAllConversations({
limit: PAGE_SIZE + 1,
offset,
search: searchTerm || undefined,
});
if (requestId !== requestIdRef.current) return;
const page = fetched
.slice(0, PAGE_SIZE)
.map((c) => ({ id: c.id, title: c.name }));
setHasMore(fetched.length > PAGE_SIZE);
setItems((prev) => (offset === 0 ? page : [...prev, ...page]));
} catch (err) {
if (requestId === requestIdRef.current) {
console.error("Failed to load conversations:", err);
}
} finally {
if (requestId === requestIdRef.current) setLoading(false);
}
},
[],
);
// Debounce search changes; reset to the first page on each new term.
useEffect(() => {
const handle = setTimeout(() => fetchPage(0, search), 250);
return () => clearTimeout(handle);
}, [search, fetchPage]);
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
fetchPage(items.length, search);
}, [loading, hasMore, items.length, search, fetchPage]);
const renameConversation = useCallback(
async (id: string, title: string) => {
const trimmed = title.trim();
if (!trimmed) return;
const previous = items;
setItems((prev) =>
prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
);
try {
await conversationService.renameConversation(id, trimmed);
} catch (err) {
console.error("Failed to rename conversation:", err);
setItems(previous);
}
},
[items],
);
const deleteConversation = useCallback(
async (id: string) => {
const previous = items;
setItems((prev) => prev.filter((c) => c.id !== id));
try {
await conversationService.deleteConversation(id);
} catch (err) {
console.error("Failed to delete conversation:", err);
setItems(previous);
}
},
[items],
);
return {
items,
search,
setSearch: setSearchValue,
hasMore,
loading,
loadMore,
renameConversation,
deleteConversation,
};
}
+7 -1
View File
@@ -7,6 +7,10 @@ export type Conversation = {
id: string;
};
// The sidebar/drawer only shows the most recent conversations; the full list
// lives on the dedicated /conversations page.
const SIDEBAR_LIMIT = 10;
export function useConversations() {
const navigate = useNavigate();
const { conversationId } = useParams();
@@ -14,7 +18,9 @@ export function useConversations() {
const refreshConversations = useCallback(async () => {
try {
const fetched = await conversationService.getAllConversations();
const fetched = await conversationService.getAllConversations({
limit: SIDEBAR_LIMIT,
});
setConversations(fetched.map((c) => ({ id: c.id, title: c.name })));
} catch (err) {
console.error("Failed to load conversations:", err);