diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index a8e20ee..272900d 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -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()) diff --git a/raggr-frontend/src/App.css b/raggr-frontend/src/App.css index 770c127..9670d2e 100644 --- a/raggr-frontend/src/App.css +++ b/raggr-frontend/src/App.css @@ -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); } diff --git a/raggr-frontend/src/App.tsx b/raggr-frontend/src/App.tsx index e5c4046..c8cc5b2 100644 --- a/raggr-frontend/src/App.tsx +++ b/raggr-frontend/src/App.tsx @@ -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={} /> + } /> } /> ); diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 31394bc..ae205ba 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -131,9 +131,19 @@ class ConversationService { } } - async getAllConversations(): Promise { + async getAllConversations(params?: { + limit?: number; + offset?: number; + search?: string; + }): Promise { + 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", }, diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index dd92f5e..9fb3b7c 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -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 (
{/* Desktop Sidebar */} @@ -139,67 +149,41 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { />
) : ( -
-
-
- Simba -

- asksimba -

-
- -
- -
- -
- -
- {isAdmin && ( - <> - - - - )} - -
-
+ setShowAdminPanel(true)} + onShowScheduler={() => setShowScheduler(true)} + onLogout={handleLogout} + onCollapse={() => setSidebarCollapsed(true)} + /> )} + {/* Mobile drawer */} + {showConversations && ( +
+ +
setShowConversations(false)} + /> +
+ )} + {showAdminPanel && setShowAdminPanel(false)} />} {showScheduler && setShowScheduler(false)} />} @@ -214,37 +198,17 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { asksimba
-
- - -
+ {messages.length === 0 && !messagesLoading ? (
- {showConversations && ( -
- -
- )}
Simba @@ -275,18 +239,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { <>
- {showConversations && ( -
- -
- )} - {messages.map((msg, index) => { if (msg.speaker === "tool") return ; diff --git a/raggr-frontend/src/components/ConversationsPage.tsx b/raggr-frontend/src/components/ConversationsPage.tsx new file mode 100644 index 0000000..ac200ba --- /dev/null +++ b/raggr-frontend/src/components/ConversationsPage.tsx @@ -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 ( +
+
+ + Simba +

+ All conversations +

+
+ +
+
+
+ + setSearch(e.target.value)} + placeholder="Search conversations..." + className="h-10 pl-9" + /> +
+ +
+ + + {items.length === 0 && !loading && ( +

+ {search ? "No conversations match your search." : "No conversations yet."} +

+ )} +
+ + {hasMore && ( + + )} +
+
+
+ ); +}; diff --git a/raggr-frontend/src/components/SidebarContent.tsx b/raggr-frontend/src/components/SidebarContent.tsx new file mode 100644 index 0000000..6db33cb --- /dev/null +++ b/raggr-frontend/src/components/SidebarContent.tsx @@ -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 ( +
+
+
+ Simba +

+ asksimba +

+
+ {onCollapse && ( + + )} + {onCloseDrawer && ( + + )} +
+ +
+ +
+ +
+ + + See all conversations + + {isAdmin && ( + <> + + + + )} + +
+
+ ); +}; diff --git a/raggr-frontend/src/hooks/useConversationBrowser.ts b/raggr-frontend/src/hooks/useConversationBrowser.ts new file mode 100644 index 0000000..2b253d2 --- /dev/null +++ b/raggr-frontend/src/hooks/useConversationBrowser.ts @@ -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([]); + 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, + }; +} diff --git a/raggr-frontend/src/hooks/useConversations.ts b/raggr-frontend/src/hooks/useConversations.ts index 78a2f0f..99b2027 100644 --- a/raggr-frontend/src/hooks/useConversations.ts +++ b/raggr-frontend/src/hooks/useConversations.ts @@ -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);