From 72c726318b28770fa2dff5a8cfc39734622f0ce5 Mon Sep 17 00:00:00 2001 From: Arsen Muk Date: Tue, 11 Aug 2026 09:37:31 +0100 Subject: [PATCH] Chat sidebar: configurable page size, lazy Archived + System groups The sidebar feed used one LIMIT 50 window over every session row, cron included, so ~96 cron runs a day pushed conversations out of the pane within hours. Rework it into three server-scoped lists with one shared page-size knob: - sessions.sidebar_page_size (default 50, 0 = unlimited) caps the feed and sizes one Archived/System page. - The window applies only to non-archived, non-system rows, so cron traffic can never displace conversations. - Starred sessions are off-budget: always returned in full, whatever the page size, and pinned even when their source is cron/hook. - Archived and System load lazily (nothing fetched until expanded) and page via ?offset=, with has_more/next_offset driving a '...' row. Collapsing a group drops its rows, so reopening repeats the same cold request. The feed gets the same '...' rather than truncating silently. - Sources split by exclusion (system = cron/hook): the client no longer whitelists, so workflow/external sessions stop rendering nowhere. Co-Authored-By: Claude Opus 5 --- nerve/agent/sessions.py | 52 ++++ nerve/config.py | 5 + nerve/db/sessions.py | 94 ++++++++ nerve/gateway/routes/sessions.py | 78 +++++- tests/test_sessions.py | 158 ++++++++++++ tests/test_sidebar_pagination_api.py | 144 +++++++++++ web/src/api/client.ts | 18 +- web/src/components/Chat/SessionSidebar.tsx | 267 +++++++++++++-------- web/src/stores/chatStore.ts | 156 +++++++++++- web/src/utils/dateGroups.test.ts | 113 +++++++++ web/src/utils/dateGroups.ts | 94 ++++++-- 11 files changed, 1049 insertions(+), 130 deletions(-) create mode 100644 tests/test_sidebar_pagination_api.py create mode 100644 web/src/utils/dateGroups.test.ts diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index 2d1512d2..89afb571 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -669,6 +669,58 @@ 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_starred_sessions(self) -> list[dict]: + """Starred, non-archived sessions — always returned, never truncated.""" + return await self.db.list_starred_sessions() + + async def list_conversation_sessions( + self, limit: int | None = None, offset: int = 0, + ) -> list[dict]: + """One page of the sidebar feed — non-archived, non-system, non-starred.""" + return await self.db.list_conversation_sessions(limit=limit, offset=offset) + + async def count_conversation_sessions(self) -> int: + """Number of pageable conversations (drives the feed's has_more).""" + return await self.db.count_conversation_sessions() + + async def list_archived_sessions( + self, limit: int | None = None, offset: int = 0, + ) -> list[dict]: + """One page of archived sessions for the sidebar's lazy Archived group.""" + return await self.db.list_archived_sessions(limit=limit, offset=offset) + + async def count_archived_sessions(self) -> int: + """Number of archived sessions (cheap badge count).""" + return await self.db.count_archived_sessions() + + async def list_system_sessions( + self, limit: int | None = None, offset: int = 0, + ) -> list[dict]: + """One page of system (cron/hook) sessions for the lazy System group.""" + return await self.db.list_system_sessions(limit=limit, offset=offset) + + async def count_system_sessions(self) -> int: + """Number of pageable 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/config.py b/nerve/config.py index 72bb3068..6cda0dab 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1856,6 +1856,10 @@ class SessionsConfig: sticky_period_minutes: int = 120 # Reuse session if active within this window client_idle_timeout_minutes: int = 60 # Auto-disconnect clients idle longer than this (0 = disabled) star_project_hook: bool = False # opt-in; fire an internal agent turn on star/unstar transition + # Rows per sidebar request: caps the conversation feed and sizes one lazy + # Archived/System page. 0 = unlimited (a group loads in a single request). + # Starred sessions are exempt and always returned in full. + sidebar_page_size: int = 50 @classmethod @_coerced @@ -1869,6 +1873,7 @@ def from_dict(cls, d: dict) -> SessionsConfig: sticky_period_minutes=d.get("sticky_period_minutes", 120), client_idle_timeout_minutes=d.get("client_idle_timeout_minutes", 60), star_project_hook=d.get("star_project_hook", False), + sidebar_page_size=max(0, _lenient_int(d.get("sidebar_page_size"), 50)), ) diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index 331e2a88..ae1f2a65 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -5,6 +5,14 @@ import json from datetime import datetime, timezone +# Sources the sidebar treats as "system": machine-driven runs that must never +# compete with human conversations for the feed's page window. Every other +# source (web, telegram, api, external, workflow, …) is a conversation — the +# split is by exclusion, so a new source shows up in the feed by default +# instead of silently rendering nowhere. +SYSTEM_SOURCES = ("cron", "hook") +_SYSTEM_SQL = "('" + "', '".join(SYSTEM_SOURCES) + "')" + class SessionStore: """Mixin providing session CRUD and lifecycle operations.""" @@ -85,6 +93,92 @@ async def count_sessions(self, include_archived: bool = False) -> int: row = await cursor.fetchone() return row[0] if row else 0 + async def _page(self, sql: str, params: tuple, limit: int | None, offset: int) -> list[dict]: + """Run a sidebar list query with an optional page window. + + ``limit=None`` means unbounded — the LIMIT/OFFSET clause is omitted + entirely rather than passing a sentinel, so an unlimited sidebar is a + plain full scan of the (already narrow) predicate. + """ + if limit is None: + async with self.db.execute(sql, params) as cursor: + return [dict(row) async for row in cursor] + async with self.db.execute( + f"{sql} LIMIT ? OFFSET ?", (*params, limit, max(0, offset)), + ) as cursor: + return [dict(row) async for row in cursor] + + async def _count(self, where: str) -> int: + async with self.db.execute(f"SELECT COUNT(*) FROM sessions WHERE {where}") as cursor: + row = await cursor.fetchone() + return row[0] if row else 0 + + async def list_starred_sessions(self) -> list[dict]: + """Every non-archived starred session, newest first — NEVER truncated. + + Starred rows are off-budget for the sidebar page size (a star is a + durable pin), and they are returned regardless of source, so a starred + cron session is pinned in the feed instead of hiding in System. + """ + return await self._page( + "SELECT * FROM sessions WHERE starred = 1 AND status != 'archived'" + " ORDER BY updated_at DESC", (), None, 0, + ) + + async def list_conversation_sessions( + self, limit: int | None = None, offset: int = 0, + ) -> list[dict]: + """Main sidebar feed page: non-archived, non-system, non-starred. + + The page window applies *after* system sources are excluded, so cron + traffic can never crowd conversations out of the feed. Sources are + filtered by exclusion, not by a whitelist: anything that is not + cron/hook (web, telegram, api, external, workflow, …) is a conversation. + """ + return await self._page( + "SELECT * FROM sessions" + f" WHERE status != 'archived' AND starred = 0 AND source NOT IN {_SYSTEM_SQL}" + " ORDER BY updated_at DESC", (), limit, offset, + ) + + async def count_conversation_sessions(self) -> int: + """Pageable conversations (drives the feed's has_more).""" + return await self._count( + f"status != 'archived' AND starred = 0 AND source NOT IN {_SYSTEM_SQL}", + ) + + async def list_archived_sessions( + self, limit: int | None = None, offset: int = 0, + ) -> list[dict]: + """Archived sessions page, most recently archived first — lazily + fetched when the sidebar Archived group is expanded.""" + return await self._page( + "SELECT * FROM sessions WHERE status = 'archived'" + " ORDER BY archived_at DESC", (), limit, offset, + ) + + async def count_archived_sessions(self) -> int: + """Count archived sessions (drives the collapsed badge + has_more).""" + return await self._count("status = 'archived'") + + async def list_system_sessions( + self, limit: int | None = None, offset: int = 0, + ) -> list[dict]: + """System sessions page (cron/hook), newest first — lazily fetched when + the sidebar System group is expanded. Starred rows are excluded: they + are already pinned in the feed, so every session shows exactly once.""" + return await self._page( + "SELECT * FROM sessions" + f" WHERE status != 'archived' AND starred = 0 AND source IN {_SYSTEM_SQL}" + " ORDER BY updated_at DESC", (), limit, offset, + ) + + async def count_system_sessions(self) -> int: + """Count pageable system sessions (drives the badge + has_more).""" + return await self._count( + f"status != 'archived' AND starred = 0 AND source IN {_SYSTEM_SQL}", + ) + 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 5df33fb9..3645becf 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -134,17 +134,49 @@ async def _attach_review_loops(deps, sessions: list[dict]) -> None: s["review_loop"] = _loop_summary(lp) -@router.get("/api/sessions") -async def list_sessions(user: dict = Depends(require_auth)): - deps = get_deps() - sessions = await deps.engine.sessions.list_sessions() +def _page_size() -> int | None: + """Sidebar page size from config; ``None`` when configured unlimited.""" + size = get_config().sessions.sidebar_page_size + return size if size and size > 0 else None + + +async def _decorate(deps, sessions: list[dict]) -> list[dict]: + """Attach the live per-row bits every sidebar list needs.""" 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} + return sessions + + +def _page_meta(page: list[dict], offset: int, total: int, limit: int | None) -> dict: + """``has_more``/``next_offset`` for the client's '...' control.""" + seen = offset + len(page) + return {"has_more": limit is not None and seen < total, "next_offset": seen} + + +@router.get("/api/sessions") +async def list_sessions(offset: int = 0, user: dict = Depends(require_auth)): + """Sidebar feed: one page of conversations, plus every starred session. + + The page window covers only non-archived, non-system, non-starred rows, so + cron traffic can never displace conversations. Starred rows ride along + in full on the first page (``offset=0``) and are never truncated. + """ + deps = get_deps() + limit = _page_size() + page = await deps.engine.sessions.list_conversation_sessions(limit=limit, offset=offset) + total = await deps.engine.sessions.count_conversation_sessions() + sessions = page if offset else await deps.engine.sessions.list_starred_sessions() + page + await _decorate(deps, sessions) + return { + "sessions": sessions, + "archived_count": await deps.engine.sessions.count_archived_sessions(), + "system_count": await deps.engine.sessions.count_system_sessions(), + **_page_meta(page, offset, total, limit), + } @router.get("/api/sessions/search") @@ -163,6 +195,28 @@ async def search_sessions(q: str, user: dict = Depends(require_auth)): return {"sessions": sessions} +@router.get("/api/sessions/archived") +async def list_archived_sessions(offset: int = 0, user: dict = Depends(require_auth)): + """One page of archived sessions — fetched only when the group is expanded.""" + deps = get_deps() + limit = _page_size() + page = await deps.engine.sessions.list_archived_sessions(limit=limit, offset=offset) + total = await deps.engine.sessions.count_archived_sessions() + await _decorate(deps, page) + return {"sessions": page, **_page_meta(page, offset, total, limit)} + + +@router.get("/api/sessions/system") +async def list_system_sessions(offset: int = 0, user: dict = Depends(require_auth)): + """One page of system (cron/hook) sessions — fetched only when expanded.""" + deps = get_deps() + limit = _page_size() + page = await deps.engine.sessions.list_system_sessions(limit=limit, offset=offset) + total = await deps.engine.sessions.count_system_sessions() + await _decorate(deps, page) + return {"sessions": page, **_page_meta(page, offset, total, limit)} + + @router.post("/api/sessions") async def create_session(req: SessionCreateRequest, user: dict = Depends(require_auth)): deps = get_deps() @@ -319,6 +373,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 "model" in req: requested_model = str(req["model"] or "").strip() if not requested_model: @@ -466,6 +526,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..327f6f20 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -443,6 +443,164 @@ 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_feed_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_conversation_sessions()} + assert "feed-web" in ids + assert "feed-cron" not in ids # system source excluded from the feed + assert "feed-arch" not in ids # archived excluded + + async def test_feed_keeps_unknown_sources(self, sm: SessionManager): + """Sources split by exclusion: anything that is not cron/hook is a + conversation, so a new source can never render nowhere.""" + await sm.get_or_create("feed-workflow", source="workflow") + await sm.get_or_create("feed-external", source="external") + ids = {s["id"] for s in await sm.list_conversation_sessions()} + assert {"feed-workflow", "feed-external"} <= ids + + async def test_feed_is_unbounded_by_default(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") + feed = await sm.list_conversation_sessions() + assert len([s for s in feed if s["id"].startswith("many-")]) == 55 + + async def test_feed_page_window_ignores_system(self, sm: SessionManager): + """The window applies AFTER system rows are excluded, so cron churn can + never displace conversations — the bug this rework fixes.""" + for i in range(6): + await sm.get_or_create(f"chat-{i}", source="web") + for i in range(30): # cron churn arrives afterwards + await sm.get_or_create(f"cronrun-{i}", source="cron") + await sm.get_or_create("late-chat", source="web") + page = await sm.list_conversation_sessions(limit=5) + assert len(page) == 5 # 5 conversations, not 5 rows of cron + assert all(s["source"] == "web" for s in page) + assert "late-chat" in {s["id"] for s in page} + + async def test_feed_pages_do_not_overlap(self, sm: SessionManager): + for i in range(12): + await sm.get_or_create(f"page-{i:02d}", source="web") + first = await sm.list_conversation_sessions(limit=5, offset=0) + second = await sm.list_conversation_sessions(limit=5, offset=5) + rest = await sm.list_conversation_sessions(limit=5, offset=10) + assert len(first) == len(second) == 5 + assert len(rest) == 2 + ids = [s["id"] for s in first + second + rest] + assert len(set(ids)) == 12 # no overlap, no gaps + assert await sm.count_conversation_sessions() == 12 + + async def test_starred_never_truncated(self, sm: SessionManager, db: Database): + """Starred rows are off-budget: excluded from the page window and + returned in full however small the page size is.""" + for i in range(8): + await sm.get_or_create(f"star-{i}", source="web") + await db.update_session_fields(f"star-{i}", {"starred": 1}) + for i in range(4): + await sm.get_or_create(f"plain-{i}", source="web") + assert len(await sm.list_starred_sessions()) == 8 + page = await sm.list_conversation_sessions(limit=2) + assert len(page) == 2 + assert all(s["starred"] == 0 for s in page) # starred don't eat the window + assert await sm.count_conversation_sessions() == 4 + + async def test_starred_system_session_is_pinned_not_hidden( + self, sm: SessionManager, db: Database, + ): + """Starring a cron session pins it in the feed and drops it from the + System page, so every session shows in exactly one place.""" + await sm.get_or_create("star-cron", source="cron") + await db.update_session_fields("star-cron", {"starred": 1}) + assert "star-cron" in {s["id"] for s in await sm.list_starred_sessions()} + assert "star-cron" not in {s["id"] for s in await sm.list_system_sessions()} + assert await sm.count_system_sessions() == 0 + + async def test_system_and_archived_paginate(self, sm: SessionManager): + for i in range(7): + await sm.get_or_create(f"psys-{i}", source="cron") + for i in range(6): + await sm.get_or_create(f"parch-{i}", source="web") + await sm.archive_session(f"parch-{i}") + assert len(await sm.list_system_sessions(limit=3)) == 3 + assert len(await sm.list_system_sessions(limit=3, offset=6)) == 1 + assert await sm.count_system_sessions() == 7 + assert len(await sm.list_archived_sessions(limit=4)) == 4 + assert len(await sm.list_archived_sessions(limit=4, offset=4)) == 2 + assert await sm.count_archived_sessions() == 6 + + 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/tests/test_sidebar_pagination_api.py b/tests/test_sidebar_pagination_api.py new file mode 100644 index 00000000..170ce804 --- /dev/null +++ b/tests/test_sidebar_pagination_api.py @@ -0,0 +1,144 @@ +"""HTTP tests for the sidebar's three lists (``gateway/routes/sessions.py``). + +The store layer is covered in test_sessions.py; what's verified here is the +contract the frontend actually reads — that the page window comes from +``sessions.sidebar_page_size``, that ``has_more``/``next_offset`` drive the +'...' control, and that starred rows ride along whole regardless of the page +size. The shipped default (50) is exercised as a default, not as a value the +test passes in, so a regression that hard-codes or drops the knob fails here. + +Harness follows TestTaskBoardRoutes in test_task_board_api.py: a minimal +FastAPI app with the real router, auth disabled via an empty jwt_secret. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import pytest_asyncio + +from nerve.agent.sessions import SessionManager +from nerve.db import Database + + +@pytest.mark.asyncio +class TestSidebarListRoutes: + @pytest_asyncio.fixture + async def setup(self, db: Database): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + import nerve.config as cfg_mod + from nerve.config import NerveConfig + from nerve.gateway.routes._deps import init_deps + from nerve.gateway.routes.sessions import router as sessions_router + + cfg = NerveConfig() + cfg.auth.jwt_secret = "" # require_auth becomes a no-op + cfg_mod._config = cfg + + sm = SessionManager(db) + engine = SimpleNamespace(config=cfg, sessions=sm) + init_deps(engine=engine, db=db) # type: ignore[arg-type] + + app = FastAPI() + app.include_router(sessions_router) + yield SimpleNamespace(client=TestClient(app), db=db, sm=sm, cfg=cfg) + + cfg_mod._config = None + + async def _seed(self, sm: SessionManager, db: Database, *, chats=0, crons=0, + archived=0, starred=0) -> None: + for i in range(chats): + await sm.get_or_create(f"chat-{i:03d}", source="web") + for i in range(crons): + await sm.get_or_create(f"cron-{i:03d}", source="cron") + for i in range(archived): + await sm.get_or_create(f"arch-{i:03d}", source="web") + await sm.archive_session(f"arch-{i:03d}") + for i in range(starred): + await sm.get_or_create(f"star-{i:03d}", source="web") + await db.update_session_fields(f"star-{i:03d}", {"starred": 1}) + + # ── Default page size ──────────────────────────────────────────────── + + async def test_default_page_size_is_fifty_and_paginates(self, setup): + """Out of the box (no config), the feed pages at 50 and says so.""" + assert setup.cfg.sessions.sidebar_page_size == 50 + await self._seed(setup.sm, setup.db, chats=60) + + body = setup.client.get("/api/sessions").json() + assert len(body["sessions"]) == 50 + assert body["has_more"] is True + assert body["next_offset"] == 50 + + rest = setup.client.get(f"/api/sessions?offset={body['next_offset']}").json() + assert len(rest["sessions"]) == 10 + assert rest["has_more"] is False + first_ids = {s["id"] for s in body["sessions"]} + assert first_ids.isdisjoint({s["id"] for s in rest["sessions"]}) + assert len(first_ids | {s["id"] for s in rest["sessions"]}) == 60 + + async def test_unlimited_only_when_configured(self, setup): + """0 is opt-in: it returns everything and never offers another page.""" + await self._seed(setup.sm, setup.db, chats=60) + setup.cfg.sessions.sidebar_page_size = 0 + + body = setup.client.get("/api/sessions").json() + assert len(body["sessions"]) == 60 + assert body["has_more"] is False + + async def test_cron_never_consumes_the_feed_window(self, setup): + """The regression this rework fixes: cron rows are counted and paged + separately, so they cannot displace conversations.""" + await self._seed(setup.sm, setup.db, chats=10, crons=200) + + body = setup.client.get("/api/sessions").json() + assert len(body["sessions"]) == 10 + assert body["has_more"] is False + assert all(s["source"] == "web" for s in body["sessions"]) + assert body["system_count"] == 200 + + async def test_starred_ride_along_whole_on_page_one(self, setup): + """Starred rows are off-budget: all of them, plus a full page.""" + setup.cfg.sessions.sidebar_page_size = 5 + await self._seed(setup.sm, setup.db, chats=12, starred=7) + + body = setup.client.get("/api/sessions").json() + assert len([s for s in body["sessions"] if s["starred"]]) == 7 + assert len([s for s in body["sessions"] if not s["starred"]]) == 5 + assert body["has_more"] is True + + # Later pages are conversations only — starred are not resent. + page2 = setup.client.get("/api/sessions?offset=5").json() + assert all(not s["starred"] for s in page2["sessions"]) + + # ── Lazy groups ────────────────────────────────────────────────────── + + async def test_counts_ride_on_the_feed_so_collapsed_groups_cost_nothing(self, setup): + await self._seed(setup.sm, setup.db, chats=2, crons=3, archived=4) + + body = setup.client.get("/api/sessions").json() + assert body["system_count"] == 3 + assert body["archived_count"] == 4 + # …and neither group's rows are in the feed. + assert {s["id"] for s in body["sessions"]} == {"chat-000", "chat-001"} + + @pytest.mark.parametrize("group,total", [("system", 12), ("archived", 12)]) + async def test_group_pages_are_disjoint_and_terminate(self, setup, group, total): + setup.cfg.sessions.sidebar_page_size = 5 + kwargs = {"crons": total} if group == "system" else {"archived": total} + await self._seed(setup.sm, setup.db, **kwargs) + + seen: list[str] = [] + offset, guard = 0, 0 + while True: + guard += 1 + assert guard < 10, "pagination did not terminate" + page = setup.client.get(f"/api/sessions/{group}?offset={offset}").json() + seen += [s["id"] for s in page["sessions"]] + if not page["has_more"]: + break + offset = page["next_offset"] + assert len(seen) == len(set(seen)) == total diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f9a77fca..aa578633 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,5 +1,13 @@ const API_BASE = '/api'; +/** One page of a lazily-loaded sidebar group (Archived / System). */ +export interface Page { + sessions: any[]; + /** More rows exist past next_offset — the sidebar renders its '...' row. */ + has_more: boolean; + next_offset: number; +} + export interface TaskStatusDef { name: string; label: string; @@ -332,7 +340,13 @@ export const api = { }>('/models'), // Sessions - listSessions: () => request<{ sessions: any[] }>('/sessions'), + listSessions: (offset = 0) => + request<{ sessions: any[]; archived_count: number; system_count: number; has_more: boolean; next_offset: number }>( + `/sessions?offset=${offset}`), + listArchivedSessions: (offset = 0) => + request(`/sessions/archived?offset=${offset}`), + listSystemSessions: (offset = 0) => + request(`/sessions/system?offset=${offset}`), searchSessions: (q: string) => request<{ sessions: any[] }>(`/sessions/search?q=${encodeURIComponent(q)}`), getSession: (id: string) => request(`/sessions/${id}`), @@ -381,6 +395,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 d4cbf3ee..a87a7b9e 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -1,8 +1,8 @@ -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 { groupByDate, parseTimestamp, loadCollapsedGroups, saveCollapsedGroups } from '../../utils/dateGroups'; import { useChatStore } from '../../stores/chatStore'; import { useModalSurface } from '../../hooks/useModalSurface'; import { safeAreaInsets } from '../../utils/safeArea'; @@ -34,27 +34,9 @@ function formatShortDate(dateStr: string): string { return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); } -// Which session groups (Running / Starred / date buckets) the user has -// collapsed, persisted across reloads. Keyed by the group's visible label, -// mirroring the quota-safe write-through pattern in helpers/draftStorage.ts — -// if localStorage is full or disabled the collapse state stays in memory only. -const COLLAPSED_GROUPS_KEY = 'nerve_sidebar_collapsed_groups'; - -function loadCollapsedGroups(): Set { - try { - const raw = localStorage.getItem(COLLAPSED_GROUPS_KEY); - const arr = raw ? JSON.parse(raw) : []; - return new Set(Array.isArray(arr) ? arr.filter((x: unknown): x is string => typeof x === 'string') : []); - } catch { - return new Set(); - } -} - -function saveCollapsedGroups(groups: Set): void { - try { - localStorage.setItem(COLLAPSED_GROUPS_KEY, JSON.stringify([...groups])); - } catch { /* quota exceeded / disabled — keep the in-memory state only */ } -} +// Collapsed-group persistence (Running / Starred / date buckets, keyed by the +// group's visible label) lives in utils/dateGroups next to the bucket +// taxonomy and its default-collapsed set. export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed, mobile = false, onRequestClose }: { sessions: Session[]; @@ -69,6 +51,9 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onRequestClose?: () => void; }) { 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); @@ -82,7 +67,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, sessionsHasMore, loadMoreSessions, archivedSessions, archivedCount, archivedLoading, archivedHasMore, loadArchivedSessions, clearArchivedSessions, unarchiveSession, starArchivedSession, systemSessions, systemCount, systemLoading, systemHasMore, loadSystemSessions, clearSystemSessions } = useChatStore(); const searchFocusNonce = useChatStore(s => s.searchFocusNonce); // In drawer mode the list is a modal overlay: it needs focus, Tab @@ -214,15 +199,11 @@ 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( - 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]); + // Main feed = whatever the server sent. It already excludes archived rows + // and system (cron/hook) sources, which load lazily into their own groups. + // No client-side source whitelist: a source the UI hasn't heard of yet + // (workflow, a new channel) belongs in the feed rather than nowhere. + const conversations = sessions; const activeIsRunning = agentStatus.state !== 'idle'; @@ -284,18 +265,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 ( <> @@ -540,11 +510,21 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, ))} - {/* System sessions */} - {systemSessions.length > 0 && ( + {/* Feed page window exhausted — never truncate silently. */} + {sessionsHasMore && } + + {/* System sessions (cron/hook) — lazy: nothing is fetched until the + group is expanded, and collapsing drops the rows again, so the + next expand repeats the identical request. */} + {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)}
+
+ + + ))} + {systemHasMore && loadSystemSessions(true)} />} + + )} +
+ )} + + {/* Archived sessions — lazy, mirror of System: fetched on expand, + dropped on collapse. Rendered last, collapsed by default. */} + {archivedCount > 0 && ( +
+ + + {archivedExpanded && ( + <> + {archivedLoading && archivedSessions === null && ( +
+ + Loading... +
+ )} + {archivedSessions !== null && archivedSessions.length === 0 && ( +
No archived sessions
+ )} + {archivedSessions !== null && archivedSessions.map((s) => ( + + ))} + {archivedHasMore && loadArchivedSessions(true)} />} + + )}
)} @@ -599,6 +641,20 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, } +/** '...' row: pulls the next page of a list that the page window cut short. */ +function MoreRow({ onClick }: { onClick: () => void }) { + return ( + + ); +} + + /** Collapsable session-group header: chevron + label, with a hidden-count hint when collapsed. */ function GroupHeader({ label, count, collapsed, tone, onToggle }: { label: string; @@ -709,7 +765,7 @@ function StatusIndicator({ session, isActive, isRunning }: { } -function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onSelect, showDate }: { +function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onUnarchive, onStarArchived, archived, onSelect, showDate }: { session: Session; isActive: boolean; isRunning: boolean; @@ -717,6 +773,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; /** Fired when the row itself is opened (not its menu) — drawer mode uses it to close. */ onSelect?: () => void; showDate?: boolean; @@ -836,13 +895,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 ? ( + + ) : ( + + )}