Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions nerve/agent/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should also update updated_at here, so that unarchiving a session pushes it to the top?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch, thank you - 5030414

"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,
Expand Down
3 changes: 3 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)),
)


Expand Down
71 changes: 71 additions & 0 deletions nerve/db/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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 = (
Expand Down
77 changes: 72 additions & 5 deletions nerve/gateway/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will return 500 when the session is not found, let's catch the ValueError and return 404.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, done - 5c3d406

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),
Expand Down
Loading
Loading