diff --git a/docs/api.md b/docs/api.md index 8ed77bea..9050d905 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,11 +23,40 @@ Response: { "authenticated": true } ### Sessions -#### `GET /api/sessions` -List all sessions, ordered by most recently updated. +#### `GET /api/sessions?offset=0` +Sidebar feed: one page of conversations plus every starred session. + +`sessions` is a single page of the conversation feed (page size = `sessions.sidebar_page_size`, default 50; `0` = unlimited). The window covers only non-archived, non-system (cron/hook), non-starred rows, so cron traffic can never displace conversations. On the first page (`offset=0`) all starred sessions are prepended in full and are never truncated; pass the returned `next_offset` back as `?offset=N` to load subsequent pages. `archived_count`/`system_count` are the collapsed-group badge counts, and `has_more`/`next_offset` drive the "…" load-more control. + +```json +Response: { + "sessions": [{ "id": "main", "title": "Main", "source": "system", "updated_at": "..." }], + "archived_count": 12, + "system_count": 3, + "has_more": true, + "next_offset": 50 +} +``` + +#### `GET /api/sessions/archived?offset=0` +One page of archived **conversations** — system/cron sessions are excluded. Fetched only when the sidebar's Archived group is expanded. + +```json +Response: { "sessions": [{ "id": "a1b2c3d4", "title": "Old chat", "status": "archived", "updated_at": "..." }], "has_more": false, "next_offset": 7 } +``` + +#### `GET /api/sessions/system?offset=0` +One page of live (non-archived) system/cron/hook sessions. Fetched only when the sidebar's System group is expanded. + +```json +Response: { "sessions": [{ "id": "cron-1", "title": "task-heartbeat", "source": "system", "updated_at": "..." }], "has_more": false, "next_offset": 3 } +``` + +#### `POST /api/sessions/{id}/unarchive` +Restore an archived session to idle so it resurfaces at the top of the conversation feed. Returns 404 if the session doesn't exist. ```json -Response: { "sessions": [{ "id": "main", "title": "Main", "source": "system", "updated_at": "..." }] } +Response: { "unarchived": true } ``` #### `POST /api/sessions` diff --git a/docs/config.md b/docs/config.md index 686c92ad..af122d04 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1281,6 +1281,7 @@ The proxy binary is automatically downloaded from [CLIProxyAPI](https://github.c | `sessions.interactive_archive_after_hours` | int | `0` | Auto-close interactive (web/telegram/…) sessions after this many idle hours (`0` = disabled; opt-in). Cron/persistent sessions are unaffected. | | `sessions.max_sessions` | int | `500` | Max active (non-archived) sessions before cleanup | | `sessions.cron_session_mode` | string | `per_run` | `per_run` (unique session per cron run) or `reuse` (shared session per job) | +| `sessions.sidebar_page_size` | int | `50` | 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. | **Starred sessions are exempt from all auto-archival.** A session starred via the star toggle (web sidebar, or the Telegram `/sessions` list / `/star`) is diff --git a/docs/web-ui.md b/docs/web-ui.md index f09dbe16..67b961cb 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -66,7 +66,7 @@ The sidebar is collapsible (toggle in header, persists via localStorage). The si ## Features ### Session Management -- **Sidebar** — Collapsible sidebar with sessions split into Conversations (grouped by date) and System (cron/hook, collapsed). Toggle via header button; state persists in localStorage. +- **Sidebar** — Collapsible sidebar with sessions split into four groups: **Starred** (pinned, any source), **Conversations** (the feed, grouped by date and paginated with a "…" load-more), **Archived** (lazy, collapsed — archived conversations only; cron/hook sessions are excluded), and **System** (lazy, collapsed — live cron/hook sessions). The Archived and System groups fetch on first expand and drop their rows again on collapse. Toggle the sidebar via header button; state persists in localStorage. - **Auto-naming** — New sessions get AI-generated titles via Haiku (e.g. "Italy Summer Vacation Planning" instead of the first message text) - **Resumable sessions** — Sessions persist across server restarts via SDK `--resume` flag; full conversation context is restored - **Stop button** — Red stop button replaces send during streaming; cancels agent task, saves partial response diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index eeb5bda7..7279c952 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -684,6 +684,54 @@ 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.""" + 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, + }) + # Bump updated_at so the unarchived session sorts to the top of the feed. + await self.db.touch_session(session_id) + 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..dc192688 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1856,6 +1856,8 @@ 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, starred exempt). + sidebar_page_size: int = 50 @classmethod @_coerced @@ -1869,6 +1871,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 ae0c7fa6..2f7a5b3b 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -5,6 +5,10 @@ import json from datetime import datetime, timezone +# Sources the sidebar treats as "system" (machine-driven); everything else is a conversation by exclusion, so a new source shows up in the feed by default. +SYSTEM_SOURCES = ("cron", "hook") +_SYSTEM_SQL = "('" + "', '".join(SYSTEM_SOURCES) + "')" + class SessionStore: """Mixin providing session CRUD and lifecycle operations.""" @@ -126,6 +130,73 @@ 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`` = unbounded, LIMIT/OFFSET omitted).""" + 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 (off-budget for the page size, any source).""" + 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 (window applied after excluding system sources).""" + 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( + f"SELECT * FROM sessions WHERE status = 'archived' AND source NOT IN {_SYSTEM_SQL}" + " ORDER BY archived_at DESC", (), limit, offset, + ) + + async def count_archived_sessions(self) -> int: + """Count archived conversation sessions (drives the collapsed badge + has_more).""" + return await self._count(f"status = 'archived' AND source NOT IN {_SYSTEM_SQL}") + + 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 excluded).""" + 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..e65d0519 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -134,17 +134,44 @@ 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 (starred ride along in full on offset=0).""" + 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 +190,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() @@ -338,6 +387,13 @@ async def update_session(session_id: str, req: dict, user: dict = Depends(requir if not fields: raise HTTPException(status_code=400, detail="No valid fields to update") old_starred = int(session.get("starred") or 0) + # Starring an archived session restores it via the shared unarchive path (logs "unarchived", bumps updated_at) before the star write, so the star->project hook fires on a live session. + if fields.get("starred") == 1 and session.get("status") == "archived": + if deps.engine: + await deps.engine.sessions.unarchive_session(session_id) + else: + fields["status"] = "idle" + fields["archived_at"] = None await deps.db.update_session_fields(session_id, fields) updated = await deps.db.get_session(session_id) # Star = opt-in project registration (sessions.star_project_hook, default @@ -466,6 +522,17 @@ 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() + try: + await deps.engine.sessions.unarchive_session(session_id) + return {"unarchived": True} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + @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..65c20bfb 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -443,6 +443,179 @@ 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_refreshes_updated_at(self, sm: SessionManager, db: Database): + await sm.get_or_create("unarch-ts") + old = "2000-01-01T00:00:00+00:00" + await db._write("UPDATE sessions SET updated_at = ? WHERE id = ?", (old, "unarch-ts")) + await sm.archive_session("unarch-ts") + await db._write("UPDATE sessions SET updated_at = ? WHERE id = ?", (old, "unarch-ts")) + await sm.unarchive_session("unarch-ts") + session = await db.get_session("unarch-ts") + assert session["updated_at"] > old + + 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_archived_excludes_system_sources(self, sm: SessionManager): + """Archived group holds conversations only; archived cron/hook excluded.""" + await sm.get_or_create("arch-web", source="web") + await sm.archive_session("arch-web") + await sm.get_or_create("arch-cron", source="cron") + await sm.archive_session("arch-cron") + ids = {s["id"] for s in await sm.list_archived_sessions()} + assert "arch-web" in ids + assert "arch-cron" not in ids + assert await sm.count_archived_sessions() == 1 + + async def test_star_archived_field_write_restores(self, sm: SessionManager, db: Database): + """The update_session route composites star+unarchive; verify the write restores the row to a live, starred state.""" + 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 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.""" + 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 @@ -755,6 +928,53 @@ async def fake_run(): assert "run_finished" in call_log +@pytest.mark.asyncio +class TestUnarchiveRoute: + """HTTP contract for POST /api/sessions/{id}/unarchive.""" + + @pytest_asyncio.fixture + async def setup(self, db: Database): + from types import SimpleNamespace + + 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 test_unarchive_nonexistent_returns_404(self, setup): + resp = setup.client.post("/api/sessions/does-not-exist/unarchive") + assert resp.status_code == 404 + + async def test_star_archived_restores_via_shared_path(self, setup): + await setup.sm.get_or_create("star-arch") + await setup.sm.archive_session("star-arch") + resp = setup.client.patch("/api/sessions/star-arch", json={"starred": True}) + assert resp.status_code == 200 + session = await setup.db.get_session("star-arch") + assert session["status"] == "idle" + assert session["starred"] == 1 + assert session["archived_at"] is None + events = await setup.db.get_session_events("star-arch") + assert any(e["event_type"] == "unarchived" for e in events) + + class MockClient: """Mock SDK client for testing.""" diff --git a/tests/test_sidebar_pagination_api.py b/tests/test_sidebar_pagination_api.py new file mode 100644 index 00000000..53b6b335 --- /dev/null +++ b/tests/test_sidebar_pagination_api.py @@ -0,0 +1,132 @@ +"""HTTP tests for the sidebar's three lists (``gateway/routes/sessions.py``).""" + +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/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index c4d3bffa..290aae1d 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -4,6 +4,7 @@ import { useChatStore, EMPTY_REVIEW_LOOP } from '../../stores/chatStore'; import type { QuoteAction, QuoteEntry } from '../../stores/chatStore'; import { api } from '../../api/client'; import { randomUUID } from '../../utils/uuid'; +import { findSessionById } from '../../utils/findSession'; import { PromptRewriteCard } from './PromptRewriteCard'; import { BackendSelector } from './BackendSelector'; import { ReviewLoopPanel } from './ReviewLoopPanel'; @@ -99,9 +100,13 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { const backendDefault = useChatStore(s => s.backendDefault); const chosenBackend = newChatBackend ?? backendDefault; const sessions = useChatStore(s => s.sessions); + const archivedSessions = useChatStore(s => s.archivedSessions); + const systemSessions = useChatStore(s => s.systemSessions); + // The active session row may live in the feed or a lazy archived/system group. + const activeSessionRow = findSessionById(activeSession, sessions, archivedSessions, systemSessions); const activeBackend = isVirtualChat ? (chosenBackend ?? 'claude') - : (sessions.find(s => s.id === activeSession)?.backend ?? 'claude'); + : (activeSessionRow?.backend ?? 'claude'); // ── Model picker (per-chat) ── // A virtual chat's pick lives in newChatModels until the session is @@ -117,7 +122,7 @@ export function ChatInput({ onSend, onStop, isStreaming, disabled }: { const modelsDefault = modelDefaults[activeBackend] ?? null; const currentModel = isVirtualChat ? (newChatModels[activeBackend] ?? modelsDefault) - : (sessions.find(s => s.id === activeSession)?.model ?? modelsDefault); + : (activeSessionRow?.model ?? modelsDefault); const [prevQuoteCount, setPrevQuoteCount] = useState(0); diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index d4cbf3ee..413d903f 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,28 +34,6 @@ 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 */ } -} - export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed, mobile = false, onRequestClose }: { sessions: Session[]; activeSession: string; @@ -69,6 +47,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); + // Collapsed-group persistence (Running / Starred / date buckets, keyed by visible label) lives in utils/dateGroups. const [collapsedGroups, setCollapsedGroups] = useState>(loadCollapsedGroups); const [localQuery, setLocalQuery] = useState(''); const [searchHovered, setSearchHovered] = useState(false); @@ -82,7 +63,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 +195,8 @@ 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 (already excludes archived + system sources); no client-side source whitelist, so an unknown source lands in the feed rather than nowhere. + const conversations = sessions; const activeIsRunning = agentStatus.state !== 'idle'; @@ -284,18 +258,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 +503,19 @@ 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 fetched until expanded, dropped on collapse, 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 +631,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 +755,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 +763,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 +885,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 ? ( + + ) : ( + + )}