-
Notifications
You must be signed in to change notification settings - Fork 27
Chat sidebar: lazy Archived + System groups, unbounded conversation feed #269
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we maybe split off by SYSTEM_SOURCES here? Otherwise we're mixing user sessions and cron/hook sessions together here. |
||
| " 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 = ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's go through the |
||
| 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") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| 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), | ||
|
|
||
There was a problem hiding this comment.
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_athere, so that unarchiving a session pushes it to the top?