From b07be5f82a645c5e6ddbafecc3ff2a406a66db39 Mon Sep 17 00:00:00 2001 From: Arsen Muk Date: Tue, 4 Aug 2026 08:07:38 +0100 Subject: [PATCH] Chat sidebar: lazy Archived + System groups, unbounded conversation feed The sidebar silently dropped active chats: GET /api/sessions capped non-starred sessions at 50, and always-updating cron sessions crowded conversations out of those slots. Archived sessions had no UI at all (and no unarchive path anywhere). - Main feed (GET /api/sessions) now returns ALL non-archived conversation sessions (no LIMIT); cron/hook sessions are excluded and served by GET /api/sessions/system instead. archived_count + system_count piggyback on the payload, so collapsed groups show a badge without fetching any rows. - New Archived and System sidebar groups: collapsed by default (not persisted), lazily fetched on first expand, hidden when empty. - Archived rows get a Star / Rename / Unarchive / Delete menu. New POST /api/sessions/{id}/unarchive restores to idle; starring an archived session unarchives + stars in one PATCH, so the star->project hook fires on a live session. - Date buckets simplified to Last hour / Last 3 hours / Today / This week / Other (relative first two, empty buckets never render). Telegram's session picker keeps the old bounded list_sessions. pytest tests/test_sessions.py 70/70; npm run build green. Co-Authored-By: Claude Fable 5 --- nerve/agent/sessions.py | 38 ++++ nerve/db/sessions.py | 56 ++++++ nerve/gateway/routes/sessions.py | 48 ++++- tests/test_sessions.py | 87 ++++++++ web/src/api/client.ts | 6 +- web/src/components/Chat/SessionSidebar.tsx | 223 ++++++++++++++------- web/src/stores/chatStore.ts | 93 ++++++++- web/src/utils/dateGroups.ts | 46 ++--- 8 files changed, 492 insertions(+), 105 deletions(-) diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index 2d1512d2..bb84b9a2 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -669,6 +669,44 @@ async def archive_session(self, session_id: str) -> None: await self.db.log_session_event(session_id, "archived", {}) logger.info("Archived session %s", session_id) + async def unarchive_session(self, session_id: str) -> None: + """Restore an archived session to ``idle`` so it's resumable again. + + Inverse of :meth:`archive_session`: clears ``archived_at`` and flips + the status back to idle. ``sdk_session_id`` stays cleared (archive + dropped it) — the next open resumes with fresh context, like any + idle session. + """ + session = await self.db.get_session(session_id) + if not session: + raise ValueError(f"Session {session_id} not found") + await self.db.update_session_fields(session_id, { + "status": SessionStatus.IDLE.value, + "archived_at": None, + }) + await self.db.log_session_event(session_id, "unarchived", {}) + logger.info("Unarchived session %s", session_id) + + async def list_archived_sessions(self, limit: int = 200) -> list[dict]: + """Archived sessions for the sidebar's lazily-loaded Archived group.""" + return await self.db.list_archived_sessions(limit=limit) + + async def count_archived_sessions(self) -> int: + """Number of archived sessions (cheap badge count).""" + return await self.db.count_archived_sessions() + + async def list_active_sessions(self) -> list[dict]: + """Main sidebar feed — non-archived, non-system sessions, unbounded.""" + return await self.db.list_active_sessions() + + async def list_system_sessions(self, limit: int = 200) -> list[dict]: + """System (cron/hook) sessions for the sidebar's lazy System group.""" + return await self.db.list_system_sessions(limit=limit) + + async def count_system_sessions(self) -> int: + """Number of non-archived system sessions (cheap badge count).""" + return await self.db.count_system_sessions() + async def run_cleanup( self, archive_after_days: int = DEFAULT_ARCHIVE_AFTER_DAYS, diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index 331e2a88..808d4a3d 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -85,6 +85,62 @@ async def count_sessions(self, include_archived: bool = False) -> int: row = await cursor.fetchone() return row[0] if row else 0 + async def list_archived_sessions(self, limit: int = 200) -> list[dict]: + """Archived sessions, most recently archived first. + + Separate from :meth:`list_sessions` (which always excludes archived): + the sidebar fetches this lazily, only when the Archived group is + expanded. + """ + sql = ( + "SELECT * FROM sessions WHERE status = 'archived' " + "ORDER BY archived_at DESC LIMIT ?" + ) + async with self.db.execute(sql, (limit,)) as cursor: + return [dict(row) async for row in cursor] + + async def count_archived_sessions(self) -> int: + """Count archived sessions (drives the collapsed sidebar badge).""" + async with self.db.execute( + "SELECT COUNT(*) FROM sessions WHERE status = 'archived'" + ) as cursor: + row = await cursor.fetchone() + return row[0] if row else 0 + + async def list_active_sessions(self) -> list[dict]: + """Main sidebar feed: every non-archived conversation session, newest + first, UNBOUNDED. Excludes system sources (cron/hook) — those now load + lazily via :meth:`list_system_sessions`, so cron traffic no longer + crowds the conversation list. No LIMIT: the client renders everything. + """ + query = ( + "SELECT * FROM sessions" + " WHERE status != 'archived' AND source NOT IN ('cron', 'hook')" + " ORDER BY updated_at DESC" + ) + async with self.db.execute(query) as cursor: + return [dict(row) async for row in cursor] + + async def list_system_sessions(self, limit: int = 200) -> list[dict]: + """System sessions (cron/hook), newest first — lazily fetched when the + sidebar System group is expanded.""" + query = ( + "SELECT * FROM sessions" + " WHERE status != 'archived' AND source IN ('cron', 'hook')" + " ORDER BY updated_at DESC LIMIT ?" + ) + async with self.db.execute(query, (limit,)) as cursor: + return [dict(row) async for row in cursor] + + async def count_system_sessions(self) -> int: + """Count non-archived system sessions (drives the collapsed System badge).""" + async with self.db.execute( + "SELECT COUNT(*) FROM sessions" + " WHERE status != 'archived' AND source IN ('cron', 'hook')" + ) as cursor: + row = await cursor.fetchone() + return row[0] if row else 0 + async def search_sessions(self, query: str, limit: int = 100) -> list[dict]: """Search sessions by title (LIKE match), across all non-archived sessions.""" sql = ( diff --git a/nerve/gateway/routes/sessions.py b/nerve/gateway/routes/sessions.py index bfaabb10..1f308e69 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -137,14 +137,16 @@ async def _attach_review_loops(deps, sessions: list[dict]) -> None: @router.get("/api/sessions") async def list_sessions(user: dict = Depends(require_auth)): deps = get_deps() - sessions = await deps.engine.sessions.list_sessions() + sessions = await deps.engine.sessions.list_active_sessions() running_ids = deps.engine.sessions.get_running_ids() awaiting_ids = get_awaiting_ids() for s in sessions: s["is_running"] = s["id"] in running_ids s["awaiting_input"] = s["id"] in awaiting_ids await _attach_review_loops(deps, sessions) - return {"sessions": sessions} + archived_count = await deps.engine.sessions.count_archived_sessions() + system_count = await deps.engine.sessions.count_system_sessions() + return {"sessions": sessions, "archived_count": archived_count, "system_count": system_count} @router.get("/api/sessions/search") @@ -163,6 +165,34 @@ async def search_sessions(q: str, user: dict = Depends(require_auth)): return {"sessions": sessions} +@router.get("/api/sessions/archived") +async def list_archived_sessions(user: dict = Depends(require_auth)): + """Archived sessions — lazily fetched when the sidebar Archived group is expanded.""" + deps = get_deps() + sessions = await deps.engine.sessions.list_archived_sessions() + running_ids = deps.engine.sessions.get_running_ids() + awaiting_ids = get_awaiting_ids() + for s in sessions: + s["is_running"] = s["id"] in running_ids + s["awaiting_input"] = s["id"] in awaiting_ids + await _attach_review_loops(deps, sessions) + return {"sessions": sessions} + + +@router.get("/api/sessions/system") +async def list_system_sessions(user: dict = Depends(require_auth)): + """System sessions (cron/hook) — lazily fetched when the sidebar System group is expanded.""" + deps = get_deps() + sessions = await deps.engine.sessions.list_system_sessions() + running_ids = deps.engine.sessions.get_running_ids() + awaiting_ids = get_awaiting_ids() + for s in sessions: + s["is_running"] = s["id"] in running_ids + s["awaiting_input"] = s["id"] in awaiting_ids + await _attach_review_loops(deps, sessions) + return {"sessions": sessions} + + @router.post("/api/sessions") async def create_session(req: SessionCreateRequest, user: dict = Depends(require_auth)): deps = get_deps() @@ -314,6 +344,12 @@ async def update_session(session_id: str, req: dict, user: dict = Depends(requir fields["title"] = req["title"] if "starred" in req: fields["starred"] = 1 if req["starred"] else 0 + # Starring an archived session restores it first, then stars — so the + # star->project hook below fires on a live (idle) session. "archived" + # is the persisted SessionStatus.ARCHIVED value. + if fields["starred"] == 1 and session.get("status") == "archived": + fields["status"] = "idle" + fields["archived_at"] = None if not fields: raise HTTPException(status_code=400, detail="No valid fields to update") old_starred = int(session.get("starred") or 0) @@ -443,6 +479,14 @@ async def archive_session(session_id: str, user: dict = Depends(require_auth)): return {"archived": True} +@router.post("/api/sessions/{session_id}/unarchive") +async def unarchive_session(session_id: str, user: dict = Depends(require_auth)): + """Restore an archived session (Archived group → Unarchive / Star).""" + deps = get_deps() + await deps.engine.sessions.unarchive_session(session_id) + return {"unarchived": True} + + @router.get("/api/sessions/{session_id}/events") async def get_session_events( session_id: str, limit: int = 50, user: dict = Depends(require_auth), diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 7fabc0dc..083b29d5 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -443,6 +443,93 @@ async def test_archive_session(self, sm: SessionManager, db: Database): assert session["status"] == "archived" assert session["archived_at"] is not None + async def test_unarchive_session(self, sm: SessionManager, db: Database): + await sm.get_or_create("unarch-1") + await sm.archive_session("unarch-1") + await sm.unarchive_session("unarch-1") + session = await db.get_session("unarch-1") + assert session["status"] == "idle" + assert session["archived_at"] is None + + async def test_unarchive_logs_event(self, sm: SessionManager, db: Database): + await sm.get_or_create("unarch-ev") + await sm.archive_session("unarch-ev") + await sm.unarchive_session("unarch-ev") + events = await db.get_session_events("unarch-ev") + assert any(e["event_type"] == "unarchived" for e in events) + + async def test_unarchive_missing_raises(self, sm: SessionManager): + with pytest.raises(ValueError): + await sm.unarchive_session("does-not-exist") + + async def test_list_archived_only_archived(self, sm: SessionManager, db: Database): + await sm.get_or_create("keep-live") + await db.update_session_fields("keep-live", {"status": "idle"}) + await sm.get_or_create("arch-listed") + await sm.archive_session("arch-listed") + archived_ids = {s["id"] for s in await sm.list_archived_sessions()} + assert "arch-listed" in archived_ids + assert "keep-live" not in archived_ids + # The default sidebar feed (list_sessions) must still exclude archived. + live_ids = {s["id"] for s in await sm.list_sessions()} + assert "arch-listed" not in live_ids + + async def test_count_archived_sessions(self, sm: SessionManager): + assert await sm.count_archived_sessions() == 0 + await sm.get_or_create("cnt-1") + await sm.archive_session("cnt-1") + await sm.get_or_create("cnt-2") + await sm.archive_session("cnt-2") + assert await sm.count_archived_sessions() == 2 + + async def test_star_archived_field_write_restores(self, sm: SessionManager, db: Database): + """The update_session route composites star+unarchive by writing these + fields together; verify that write restores the row to a live, starred + state (status idle, archived_at cleared).""" + await sm.get_or_create("star-arch") + await sm.archive_session("star-arch") + await db.update_session_fields( + "star-arch", {"starred": 1, "status": "idle", "archived_at": None}, + ) + session = await db.get_session("star-arch") + assert session["starred"] == 1 + assert session["status"] == "idle" + assert session["archived_at"] is None + + async def test_list_active_excludes_system_and_archived(self, sm: SessionManager): + await sm.get_or_create("feed-web", source="web") + await sm.get_or_create("feed-cron", source="cron") + await sm.get_or_create("feed-arch", source="web") + await sm.archive_session("feed-arch") + ids = {s["id"] for s in await sm.list_active_sessions()} + assert "feed-web" in ids + assert "feed-cron" not in ids # system source excluded from main feed + assert "feed-arch" not in ids # archived excluded + + async def test_list_active_is_unbounded(self, sm: SessionManager): + # Regression: the old sidebar feed capped non-starred sessions at 50. + for i in range(55): + await sm.get_or_create(f"many-{i}", source="web") + active = await sm.list_active_sessions() + assert len([s for s in active if s["id"].startswith("many-")]) == 55 + + async def test_list_system_only_system(self, sm: SessionManager): + await sm.get_or_create("sys-cron", source="cron") + await sm.get_or_create("sys-hook", source="hook") + await sm.get_or_create("sys-web", source="web") + ids = {s["id"] for s in await sm.list_system_sessions()} + assert {"sys-cron", "sys-hook"} <= ids + assert "sys-web" not in ids + + async def test_count_system_sessions(self, sm: SessionManager): + assert await sm.count_system_sessions() == 0 + await sm.get_or_create("c-cron", source="cron") + await sm.get_or_create("c-hook", source="hook") + await sm.get_or_create("c-web", source="web") + await sm.get_or_create("c-arch", source="cron") + await sm.archive_session("c-arch") + assert await sm.count_system_sessions() == 2 # archived cron excluded + async def test_archive_disconnects_client(self, sm: SessionManager): await sm.get_or_create("arch-2") # Simulate a client diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6789f0ae..3baa8cb9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -270,7 +270,9 @@ export const api = { }>('/models'), // Sessions - listSessions: () => request<{ sessions: any[] }>('/sessions'), + listSessions: () => request<{ sessions: any[]; archived_count: number; system_count: number }>('/sessions'), + listArchivedSessions: () => request<{ sessions: any[] }>('/sessions/archived'), + listSystemSessions: () => request<{ sessions: any[] }>('/sessions/system'), searchSessions: (q: string) => request<{ sessions: any[] }>(`/sessions/search?q=${encodeURIComponent(q)}`), getSession: (id: string) => request(`/sessions/${id}`), @@ -319,6 +321,8 @@ export const api = { request(`/sessions/${id}/resume`, { method: 'POST' }), archiveSession: (id: string) => request(`/sessions/${id}/archive`, { method: 'POST' }), + unarchiveSession: (id: string) => + request(`/sessions/${id}/unarchive`, { method: 'POST' }), getSessionStatus: (id: string) => request(`/sessions/${id}/status`), getSessionEvents: (id: string, limit = 50) => diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index 60da4210..0faee10a 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -1,6 +1,6 @@ -import { useState, useMemo, useRef, useEffect, useCallback, useLayoutEffect } from 'react'; +import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { Plus, X, MessageSquare, ChevronRight, ChevronDown, Bot, Loader2, Search, Hammer, MoreHorizontal, Star, Pencil, Trash2, Archive, Repeat } from 'lucide-react'; +import { Plus, X, MessageSquare, ChevronRight, ChevronDown, Bot, Loader2, Search, Hammer, MoreHorizontal, Star, Pencil, Trash2, Archive, ArchiveRestore, Repeat } from 'lucide-react'; import type { Session, AgentStatus } from '../../types/chat'; import { groupByDate, parseTimestamp } from '../../utils/dateGroups'; import { useChatStore } from '../../stores/chatStore'; @@ -63,6 +63,9 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, collapsed?: boolean; }) { const [systemExpanded, setSystemExpanded] = useState(false); + // Archived group: collapsed by default and NOT persisted (mirrors System), + // so every reload starts collapsed and fetches nothing until expanded. + const [archivedExpanded, setArchivedExpanded] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState>(loadCollapsedGroups); const [localQuery, setLocalQuery] = useState(''); const [searchHovered, setSearchHovered] = useState(false); @@ -76,7 +79,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, const debounceRef = useRef | null>(null); const inputRef = useRef(null); - const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar, archiveSession, virtualSession, discardVirtualSession, sidebarWidth, setSidebarWidth } = useChatStore(); + const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar, archiveSession, virtualSession, discardVirtualSession, sidebarWidth, setSidebarWidth, archivedSessions, archivedCount, archivedLoading, loadArchivedSessions, unarchiveSession, starArchivedSession, systemSessions, systemCount, systemLoading, loadSystemSessions } = useChatStore(); const searchFocusNonce = useChatStore(s => s.searchFocusNonce); // Drag-to-resize the session list. It is left-anchored against the nav rail, @@ -195,15 +198,16 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, return () => document.removeEventListener('keydown', handleKeyDown); }, [isSearching, clearSearch]); - const { conversations, systemSessions } = useMemo(() => { - // External = Codex/Claude-Code/Cursor satellite sessions (MCP server + - // Codex thread sync). Live alongside web/telegram conversations. - const convos = sessions.filter( + // Main feed = conversations only. System (cron/hook) sessions load lazily + // from the store and are no longer part of `sessions` (the server excludes + // them). External = Codex/Claude-Code/Cursor satellites. The source guard is + // a light safety net; the server already scopes the feed. + const conversations = useMemo( + () => sessions.filter( s => s.source === 'web' || s.source === 'telegram' || s.source === 'api' || s.source === 'external', - ); - const system = sessions.filter(s => s.source === 'cron' || s.source === 'hook'); - return { conversations: convos, systemSessions: system }; - }, [sessions]); + ), + [sessions], + ); const activeIsRunning = agentStatus.state !== 'idle'; @@ -265,18 +269,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, }); }, [activeSession, pinnedRunning, pinnedStarred, groupedConversations]); - // Count running system sessions for the badge - const runningSystemCount = useMemo( - () => systemSessions.filter(s => s.is_running).length, - [systemSessions], - ); - - // Auto-expand system section when something starts running - useLayoutEffect(() => { - if (runningSystemCount > 0 && !systemExpanded) { - setSystemExpanded(true); - } - }, [runningSystemCount]); // eslint-disable-line react-hooks/exhaustive-deps + // (System sessions load lazily now — no running-count badge / auto-expand.) return (
))} - {/* System sessions */} - {systemSessions.length > 0 && ( + {/* System sessions (cron/hook) — lazy: nothing fetched until + expanded; collapsed shows just the counter. */} + {systemCount > 0 && (
- {systemExpanded && systemSessions.map((s) => ( - - -
-
{cleanTitle(s)}
-
- - - ))} + {systemExpanded && ( + <> + {systemLoading && systemSessions === null && ( +
+ + Loading... +
+ )} + {systemSessions !== null && systemSessions.length === 0 && ( +
No system sessions
+ )} + {systemSessions !== null && systemSessions.map((s) => ( + + +
+
{cleanTitle(s)}
+
+ + + ))} + + )} +
+ )} + + {/* Archived sessions — lazy: nothing fetched until expanded. + Rendered after System, collapsed by default (not persisted). */} + {archivedCount > 0 && ( +
+ + + {archivedExpanded && ( + <> + {archivedLoading && archivedSessions === null && ( +
+ + Loading... +
+ )} + {archivedSessions !== null && archivedSessions.length === 0 && ( +
No archived sessions
+ )} + {archivedSessions !== null && archivedSessions.map((s) => ( + + ))} + + )}
)} @@ -662,7 +722,7 @@ function StatusIndicator({ session, isActive, isRunning }: { } -function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, showDate }: { +function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onUnarchive, onStarArchived, archived, showDate }: { session: Session; isActive: boolean; isRunning: boolean; @@ -670,6 +730,9 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl onRename: (id: string, title: string) => Promise; onToggleStar: (id: string) => Promise; onArchive: (id: string) => Promise; + onUnarchive?: (id: string) => Promise; + onStarArchived?: (id: string) => Promise; + archived?: boolean; showDate?: boolean; }) { const [menuOpen, setMenuOpen] = useState(false); @@ -786,13 +849,14 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl onClick={(e) => { e.preventDefault(); e.stopPropagation(); - onToggleStar(session.id); + if (archived) onStarArchived?.(session.id); + else onToggleStar(session.id); setMenuOpen(false); }} className="flex items-center gap-2.5 w-full px-3 py-1.5 text-[13px] text-text-secondary hover:bg-border-subtle cursor-pointer transition-colors" > - {session.starred ? 'Unstar' : 'Star'} + {archived ? 'Star' : session.starred ? 'Unstar' : 'Star'} - + {archived ? ( + + ) : ( + + )}