From 5a50a77bc424e00743183f794eb76f3cd563b450 Mon Sep 17 00:00:00 2001 From: serxa Date: Mon, 10 Aug 2026 19:02:56 +0000 Subject: [PATCH 1/2] Telegram /sessions: paginate the switcher, pin starred, exclude cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /sessions switcher fetched the 30 most-recently-updated sessions across all sources, then filtered to interactive (telegram/web) ones. Cron/automation sessions update constantly and dominated that window, so only a few interactive sessions survived the filter — often well under the button cap. Ordering was pure recency, and starring only added a marker to a label if the session already appeared, so starred (kept-alive) sessions — which exist precisely to survive going idle — sank below the cutoff and became the least reachable. Add SessionStore.list_interactive_sessions(): filter to interactive sources and non-empty sessions in SQL (so cron/automation and 0-message sessions never consume the window) and order current -> starred -> most-recent. /sessions now pages over it with Prev/More buttons (sess:page:) so every switchable session is reachable; the current session is pinned first (always visible on page one) and starred sessions ahead of merely-recent ones. Page size 8 -> 10. The per-row star toggle is unchanged. Co-Authored-By: Claude Opus 4.8 --- nerve/agent/sessions.py | 15 +++ nerve/channels/router.py | 11 +++ nerve/channels/telegram.py | 91 +++++++++++++----- nerve/db/sessions.py | 41 ++++++++ tests/test_list_interactive_sessions.py | 83 ++++++++++++++++ tests/test_telegram_sessions.py | 123 +++++++++++++++++------- 6 files changed, 304 insertions(+), 60 deletions(-) create mode 100644 tests/test_list_interactive_sessions.py diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index 2d1512d2..eeb5bda7 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -609,6 +609,21 @@ async def list_sessions( limit=limit, include_archived=include_archived, ) + async def list_interactive_sessions( + self, + limit: int, + offset: int = 0, + sources: tuple[str, ...] = ("telegram", "web"), + current_id: str | None = None, + ) -> list[dict]: + """Paginated switchable sessions (current → starred → recent) for the + channel /sessions keyboards. See + :meth:`nerve.db.sessions.SessionStore.list_interactive_sessions`. + """ + return await self.db.list_interactive_sessions( + limit=limit, offset=offset, sources=sources, current_id=current_id, + ) + async def set_starred(self, session_id: str, starred: bool) -> bool: """Star or unstar a session. diff --git a/nerve/channels/router.py b/nerve/channels/router.py index 32f7d8f6..8dbd7190 100644 --- a/nerve/channels/router.py +++ b/nerve/channels/router.py @@ -430,6 +430,17 @@ async def list_sessions(self, limit: int = 20) -> list[dict[str, Any]]: """List sessions, most recently updated first.""" return await self.engine.sessions.list_sessions(limit=limit) + async def list_interactive_sessions( + self, limit: int, offset: int = 0, current_id: str | None = None, + ) -> list[dict[str, Any]]: + """Switchable sessions for the /sessions keyboard: interactive sources + only, current → starred → recent, paginated. See + :meth:`nerve.agent.sessions.SessionManager.list_interactive_sessions`. + """ + return await self.engine.sessions.list_interactive_sessions( + limit=limit, offset=offset, current_id=current_id, + ) + async def set_session_starred(self, session_id: str, starred: bool) -> bool: """Star/unstar a session. Starred sessions are never auto-archived.""" return await self.engine.sessions.set_starred(session_id, starred) diff --git a/nerve/channels/telegram.py b/nerve/channels/telegram.py index bb1d7b74..b656b194 100644 --- a/nerve/channels/telegram.py +++ b/nerve/channels/telegram.py @@ -223,7 +223,7 @@ def _format_reply_context(message: Any) -> str: # Inline-keyboard /sessions rendering ------------------------------------- # -_SESSIONS_BUTTON_LIMIT = 8 # keep the keyboard thumb-friendly on mobile +_SESSIONS_PAGE_SIZE = 10 # sessions shown per /sessions page; ⬅️/➡️ page the rest _SESSION_LABEL_MAX = 40 # Telegram wraps long button labels poorly @@ -240,18 +240,26 @@ def _session_label(session: dict, current_id: str | None) -> str: def build_sessions_view( - sessions: list[dict], current_id: str | None, + sessions: list[dict], + current_id: str | None, + *, + offset: int = 0, + has_prev: bool = False, + has_next: bool = False, ) -> "tuple[str, InlineKeyboardMarkup]": """Render the /sessions inline keyboard (pure, sync — unit-testable). One tap-to-switch button per session (the current one marked ✓), with the session id carried in ``callback_data`` as ``sess:`` — so switching routing takes a single tap and never requires copy-pasting an id on mobile. - A trailing ``➕ New session`` button (``sess:new``) starts a fresh one. - Returns ``(message_text, keyboard)``. + ``sessions`` is one already-ordered page (the caller pins the current + session first and starred ones ahead of merely-recent ones); ``⬅️``/``➡️`` + page through the rest via ``sess:page:`` when ``has_prev`` / + ``has_next`` report more exist. A trailing ``➕ New session`` button + (``sess:new``) starts a fresh one. Returns ``(message_text, keyboard)``. """ rows: list[list[InlineKeyboardButton]] = [] - for s in sessions[:_SESSIONS_BUTTON_LIMIT]: + for s in sessions[:_SESSIONS_PAGE_SIZE]: sid = s.get("id") cb = f"sess:{sid}" # callback_data is capped at 64 bytes by Telegram; interactive session @@ -269,13 +277,34 @@ def build_sessions_view( ) ) rows.append(row) + shown = len(rows) + + # Pager row — only the arrows that lead somewhere, so no tap is a dead end. + nav: list[InlineKeyboardButton] = [] + if has_prev: + nav.append( + InlineKeyboardButton( + "⬅️ Prev", + callback_data=f"sess:page:{max(0, offset - _SESSIONS_PAGE_SIZE)}", + ) + ) + if has_next: + nav.append( + InlineKeyboardButton( + "➡️ More", + callback_data=f"sess:page:{offset + _SESSIONS_PAGE_SIZE}", + ) + ) + if nav: + rows.append(nav) + rows.append([InlineKeyboardButton("➕ New session", callback_data="sess:new")]) # The New-session button switches routing to a fresh session WITHOUT # stopping the current one (unlike /new). A session switched away from # keeps running any in-flight turn and still delivers its result to the # chat (output is bound per-session, not to the channel's current map). - if rows[:-1]: + if shown: current_title = next( ( (s.get("title") or s.get("id")) @@ -287,8 +316,12 @@ def build_sessions_view( text = "🗂 Sessions — tap to switch." if current_title: text += f"\nCurrent: {current_title}" + if offset or has_next: + text += f"\nPage {offset // _SESSIONS_PAGE_SIZE + 1}" text += "\n➕ New session keeps the current one running." text += "\n⭐ keeps a session alive (never auto-closed); tap ☆/⭐ to toggle." + elif has_prev: + text = "No more sessions — tap ⬅️ Prev to go back." else: text = "No sessions yet — tap ➕ to start one." return text, InlineKeyboardMarkup(rows) @@ -1104,29 +1137,28 @@ async def _handle_session(self, update: Update, context: Any) -> None: await update.message.reply_text(str(e)) async def _sessions_view_for( - self, channel_key: str, + self, channel_key: str, offset: int = 0, ) -> "tuple[str, InlineKeyboardMarkup]": """Build the inline-keyboard sessions view for a channel. Shared by /sessions and the button-press handler so both render the - same thing. Only interactive (telegram/web) sessions are offered as - switch targets — cron/automation sessions are never listed. + same page. Only interactive (telegram/web) sessions that have history + are switch targets — cron/automation sessions are never listed — and the + store pins the current session first with starred (kept-alive) ones + ahead of the rest, so an idle starred session is never buried. Filtering + and ordering happen in SQL; we fetch one extra row to learn whether a + further page exists. """ + offset = max(0, offset) current = await self.router.get_last_session(channel_key) - sessions = await self.router.list_sessions(limit=30) - # Keep the most-recent interactive sessions that have history: empty - # (0-message) sessions are useless to switch to, and cron/automation - # sessions are never switch targets. Stop once the keyboard is full so - # we don't count every session. - non_empty: list[dict] = [] - for s in sessions: - if s.get("source") not in ("telegram", "web"): - continue - if await self.router.count_session_messages(s["id"]) > 0: - non_empty.append(s) - if len(non_empty) >= _SESSIONS_BUTTON_LIMIT: - break - return build_sessions_view(non_empty, current) + page = await self.router.list_interactive_sessions( + limit=_SESSIONS_PAGE_SIZE + 1, offset=offset, current_id=current, + ) + has_next = len(page) > _SESSIONS_PAGE_SIZE + return build_sessions_view( + page[:_SESSIONS_PAGE_SIZE], current, + offset=offset, has_prev=offset > 0, has_next=has_next, + ) async def _handle_sessions(self, update: Update, context: Any) -> None: """Handle /sessions — native inline keyboard to switch session routing. @@ -1832,7 +1864,8 @@ async def _handle_session_button(self, query: Any) -> None: """Handle /sessions inline-keyboard presses. callback_data forms: - ``sess:list`` — (re)show the session switch list + ``sess:list`` — (re)show the session switch list (first page) + ``sess:page:`` — show the switch list at a pagination offset ``sess:new`` — create a fresh session (current keeps running) ``sess:`` — switch routing to , then show its history ``sesstail::`` — widen the catch-up window (informational only) @@ -1855,6 +1888,16 @@ async def _handle_session_button(self, query: Any) -> None: await self._safe_edit(query, text, markup) return + if data.startswith("sess:page:"): + try: + page_offset = max(0, int(data.rsplit(":", 1)[1])) + except (IndexError, ValueError): + page_offset = 0 + await query.answer() + text, markup = await self._sessions_view_for(channel_key, page_offset) + await self._safe_edit(query, text, markup) + return + if data.startswith("sesstail:"): parts = data.split(":") sid = parts[1] if len(parts) > 1 else "" diff --git a/nerve/db/sessions.py b/nerve/db/sessions.py index 331e2a88..ae0c7fa6 100644 --- a/nerve/db/sessions.py +++ b/nerve/db/sessions.py @@ -73,6 +73,47 @@ async def list_sessions( async with self.db.execute(query, (limit,)) as cursor: return [dict(row) async for row in cursor] + async def list_interactive_sessions( + self, + limit: int, + offset: int = 0, + sources: tuple[str, ...] = ("telegram", "web"), + current_id: str | None = None, + ) -> list[dict]: + """Page through switchable sessions for a channel's /sessions keyboard. + + Returns non-archived sessions whose source is interactive (``sources``, + default telegram + web) and which have at least one message, ordered so + the switcher stays useful: + + 1. the current session (``current_id``) first, so you can always see + where you are however the rest is sorted; + 2. then starred (kept-alive) sessions — they exist precisely to + survive going idle, so recency must not bury them; + 3. then everything else, most recently updated first. + + Filtering source and non-emptiness in SQL — rather than post-filtering a + plain recency fetch — is what stops constantly-updating cron/automation + sessions from filling the window and starving the interactive rows a + human actually switches between. Paginate with ``limit``/``offset``; + fetch ``page_size + 1`` to learn whether a further page exists. + """ + if not sources: + return [] + placeholders = ",".join("?" for _ in sources) + query = ( + f"SELECT * FROM sessions AS s " + f"WHERE s.status != 'archived' " + f"AND s.source IN ({placeholders}) " + f"AND EXISTS (SELECT 1 FROM messages AS m WHERE m.session_id = s.id) " + f"ORDER BY (s.id = ?) DESC, COALESCE(s.starred, 0) DESC, " + f"s.updated_at DESC " + f"LIMIT ? OFFSET ?" + ) + params = (*sources, current_id or "", limit, offset) + async with self.db.execute(query, params) as cursor: + return [dict(row) async for row in cursor] + async def count_sessions(self, include_archived: bool = False) -> int: """Count sessions without loading them. Used by diagnostics.""" if include_archived: diff --git a/tests/test_list_interactive_sessions.py b/tests/test_list_interactive_sessions.py new file mode 100644 index 00000000..29f10250 --- /dev/null +++ b/tests/test_list_interactive_sessions.py @@ -0,0 +1,83 @@ +"""Tests for SessionStore.list_interactive_sessions — the switchable-session +query behind the channel /sessions keyboards (current → starred → recent, +interactive sources only, non-empty, paginated).""" + +import pytest + +from nerve.db import Database + + +async def _make(db, sid, source, updated_at, *, starred=False, msg=True, + status="idle"): + await db.create_session(sid, title=sid, source=source, status=status) + if msg: + await db.add_message(sid, "user", "hi") # bumps updated_at → override below + if starred: + await db.update_session_fields(sid, {"starred": 1}) + # updated_at is not settable via update_session_fields (it means "last + # message activity"); set it directly, after add_message, to pin recency. + await db._write( + "UPDATE sessions SET updated_at = ? WHERE id = ?", (updated_at, sid), + ) + + +async def _seed(db): + # Interactive, non-empty — the rows that should appear. + await _make(db, "cur", "telegram", "2026-01-02T00:00:00+00:00") # current, oldest + await _make(db, "starOld", "web", "2026-01-01T00:00:00+00:00", starred=True) + await _make(db, "starNew", "telegram", "2026-03-01T00:00:00+00:00", starred=True) + await _make(db, "recent1", "web", "2026-04-01T00:00:00+00:00") # newest non-starred + await _make(db, "recent2", "telegram", "2026-02-01T00:00:00+00:00") + # Rows that must be excluded. + await _make(db, "cronjob", "cron", "2026-05-01T00:00:00+00:00") # cron source + await _make(db, "emptyone", "web", "2026-04-15T00:00:00+00:00", msg=False) # 0 messages + await _make(db, "arch", "web", "2026-04-20T00:00:00+00:00", + starred=True, status="archived") # archived (even if starred) + + +@pytest.mark.asyncio +async def test_current_first_then_starred_then_recent(db: Database): + await _seed(db) + rows = await db.list_interactive_sessions(limit=50, current_id="cur") + ids = [r["id"] for r in rows] + # current pinned, then starred by recency, then recent non-starred by recency + assert ids == ["cur", "starNew", "starOld", "recent1", "recent2"] + + +@pytest.mark.asyncio +async def test_excludes_cron_empty_and_archived(db: Database): + await _seed(db) + ids = {r["id"] for r in await db.list_interactive_sessions(limit=50)} + assert "cronjob" not in ids # cron/automation source never listed + assert "emptyone" not in ids # 0-message sessions are useless to switch to + assert "arch" not in ids # archived excluded even when starred + + +@pytest.mark.asyncio +async def test_starred_lead_when_no_current(db: Database): + await _seed(db) + ids = [r["id"] for r in await db.list_interactive_sessions(limit=50)] + # No current pin: starred lead (by recency), then recent — including the + # (non-starred, old) "cur" which now sinks to the bottom. + assert ids == ["starNew", "starOld", "recent1", "recent2", "cur"] + + +@pytest.mark.asyncio +async def test_pagination_via_limit_offset(db: Database): + await _seed(db) + order = ["cur", "starNew", "starOld", "recent1", "recent2"] + p1 = [r["id"] for r in await db.list_interactive_sessions(limit=2, offset=0, current_id="cur")] + p2 = [r["id"] for r in await db.list_interactive_sessions(limit=2, offset=2, current_id="cur")] + p3 = [r["id"] for r in await db.list_interactive_sessions(limit=2, offset=4, current_id="cur")] + assert p1 == order[0:2] + assert p2 == order[2:4] + assert p3 == order[4:5] # last partial page + + +@pytest.mark.asyncio +async def test_next_page_probe_detects_more(db: Database): + await _seed(db) + # The channel view fetches page_size + 1 to learn whether a further page + # exists; with 5 rows and page_size 2, page 1's probe must return 3. + probe = await db.list_interactive_sessions(limit=3, offset=0, current_id="cur") + assert len(probe) == 3 diff --git a/tests/test_telegram_sessions.py b/tests/test_telegram_sessions.py index fb302231..4f61181c 100644 --- a/tests/test_telegram_sessions.py +++ b/tests/test_telegram_sessions.py @@ -61,15 +61,20 @@ def test_missing_title_falls_back_to_id(): assert _flat(markup)[0].text == "✓ dddd4444" -def test_button_limit_caps_sessions_but_keeps_new(): +def test_page_size_caps_session_rows_but_keeps_new(): + from nerve.channels.telegram import _SESSIONS_PAGE_SIZE sessions = [ - {"id": f"id{n:06d}", "title": f"S{n}", "source": "web"} for n in range(20) + {"id": f"id{n:06d}", "title": f"S{n}", "source": "web"} for n in range(50) ] _text, markup = build_sessions_view(sessions, current_id=None) - rows = markup.inline_keyboard - # 8 session buttons + 1 trailing "New session" row. - assert len(rows) == 9 - assert rows[-1][0].callback_data == "sess:new" + switch_btns = [ + b.callback_data for b in _flat(markup) + if (b.callback_data or "").startswith("sess:") + and b.callback_data != "sess:new" + and not (b.callback_data or "").startswith("sess:page:") + ] + assert len(switch_btns) == _SESSIONS_PAGE_SIZE # one page's worth of rows + assert markup.inline_keyboard[-1][0].callback_data == "sess:new" def test_oversized_callback_data_is_skipped(): @@ -168,56 +173,102 @@ def test_tail_stays_within_telegram_message_limit(): assert len(text) <= 4096 -# --- /sessions list excludes empty sessions -------------------------------- # +# --- /sessions paging (interactive-only, current→starred→recent) ----------- # class _FakeRouter: - def __init__(self, sessions, counts, current): - self._s, self._c, self._cur = sessions, counts, current - self.count_calls = 0 + """Stands in for the channel router. ``sessions`` is the already-filtered, + already-ordered interactive list the store would return (current first, + starred ahead of recent); the fake just paginates it, mirroring the SQL + ``LIMIT``/``OFFSET`` so the view's paging logic is what's under test.""" + + def __init__(self, sessions, counts=None, current=None): + self._s, self._cur = sessions, current + self.calls = [] async def get_last_session(self, _channel_key): return self._cur - async def list_sessions(self, limit=20): - return self._s + async def list_interactive_sessions(self, limit, offset=0, current_id=None): + self.calls.append((limit, offset, current_id)) + return self._s[offset:offset + limit] - async def count_session_messages(self, session_id): - self.count_calls += 1 - return self._c.get(session_id, 0) +def _cbs_of(markup): + return [b.callback_data for row in markup.inline_keyboard for b in row] -@pytest.mark.asyncio -async def test_sessions_list_excludes_empty_sessions(): - from nerve.channels.telegram import TelegramChannel - sessions = [ - {"id": "has111", "title": "Has messages", "source": "telegram"}, - {"id": "empty2", "title": "empty2", "source": "web"}, # 0 messages + +def _switch_cbs(markup): + return [ + c for c in _cbs_of(markup) + if c.startswith("sess:") and c != "sess:new" and not c.startswith("sess:page:") ] + + +@pytest.mark.asyncio +async def test_sessions_view_renders_store_page_from_current_view(): + from nerve.channels.telegram import TelegramChannel, _SESSIONS_PAGE_SIZE + # The store excludes empty/cron rows and orders the rest; the view renders + # that page verbatim and always keeps the New-session button. + sessions = [{"id": "has111", "title": "Has messages", "source": "telegram"}] ch = TelegramChannel.__new__(TelegramChannel) # bypass __init__; only .router needed - ch.router = _FakeRouter(sessions, {"has111": 4, "empty2": 0}, current="has111") + ch.router = _FakeRouter(sessions, current="has111") _text, markup = await ch._sessions_view_for("telegram:1") - cbs = [b.callback_data for row in markup.inline_keyboard for b in row] - assert "sess:has111" in cbs # non-empty shown - assert "sess:empty2" not in cbs # empty hidden - assert "sess:new" in cbs # New button still present + cbs = _cbs_of(markup) + assert "sess:has111" in cbs + assert "sess:new" in cbs + # Fetched interactive-only, one page + 1 (next-page probe), current pinned. + assert ch.router.calls == [(_SESSIONS_PAGE_SIZE + 1, 0, "has111")] @pytest.mark.asyncio -async def test_sessions_list_caps_at_button_limit_and_stops_counting(): - from nerve.channels.telegram import TelegramChannel, _SESSIONS_BUTTON_LIMIT +async def test_sessions_view_first_page_offers_more_not_prev(): + from nerve.channels.telegram import TelegramChannel, _SESSIONS_PAGE_SIZE sessions = [ - {"id": f"s{n:02d}", "title": f"S{n}", "source": "telegram"} for n in range(12) + {"id": f"s{n:02d}", "title": f"S{n}", "source": "telegram"} + for n in range(_SESSIONS_PAGE_SIZE * 3) ] - counts = {s["id"]: 5 for s in sessions} ch = TelegramChannel.__new__(TelegramChannel) - ch.router = _FakeRouter(sessions, counts, current="s00") + ch.router = _FakeRouter(sessions, current="s00") _text, markup = await ch._sessions_view_for("telegram:1") - session_btns = [ - b for row in markup.inline_keyboard for b in row - if b.callback_data.startswith("sess:") and b.callback_data != "sess:new" + cbs = _cbs_of(markup) + assert len(_switch_cbs(markup)) == _SESSIONS_PAGE_SIZE # exactly one page + assert f"sess:page:{_SESSIONS_PAGE_SIZE}" in cbs # ➡️ More → page 2 + assert "sess:page:0" not in cbs # no ⬅️ Prev on page 1 + assert "sess:new" in cbs + + +@pytest.mark.asyncio +async def test_sessions_view_middle_page_offers_prev_and_more(): + from nerve.channels.telegram import TelegramChannel, _SESSIONS_PAGE_SIZE + sessions = [ + {"id": f"s{n:03d}", "title": f"S{n}", "source": "telegram"} + for n in range(_SESSIONS_PAGE_SIZE * 3) ] - assert len(session_btns) == _SESSIONS_BUTTON_LIMIT # keyboard capped - assert ch.router.count_calls == _SESSIONS_BUTTON_LIMIT # stopped counting early + ch = TelegramChannel.__new__(TelegramChannel) + ch.router = _FakeRouter(sessions, current="s000") + off = _SESSIONS_PAGE_SIZE + text, markup = await ch._sessions_view_for("telegram:1", off) + cbs = _cbs_of(markup) + assert "sess:page:0" in cbs # ⬅️ Prev → page 1 + assert f"sess:page:{off + _SESSIONS_PAGE_SIZE}" in cbs # ➡️ More → page 3 + assert "Page 2" in text + assert ch.router.calls[-1] == (_SESSIONS_PAGE_SIZE + 1, off, "s000") + + +@pytest.mark.asyncio +async def test_sessions_view_last_page_has_prev_no_more(): + from nerve.channels.telegram import TelegramChannel, _SESSIONS_PAGE_SIZE + # Two pages exactly: at offset=page_size there is no further page. + sessions = [ + {"id": f"s{n:02d}", "title": f"S{n}", "source": "telegram"} + for n in range(_SESSIONS_PAGE_SIZE * 2) + ] + ch = TelegramChannel.__new__(TelegramChannel) + ch.router = _FakeRouter(sessions, current="s00") + _text, markup = await ch._sessions_view_for("telegram:1", _SESSIONS_PAGE_SIZE) + cbs = _cbs_of(markup) + assert "sess:page:0" in cbs # ⬅️ Prev present + assert not any(c.startswith("sess:page:") and c != "sess:page:0" for c in cbs) # no ➡️ More def test_tail_empty_session(): From c36d80ed8504de65dd322bedc561d94fff563c41 Mon Sep 17 00:00:00 2001 From: serxa Date: Mon, 10 Aug 2026 19:49:13 +0000 Subject: [PATCH 2/2] Drop the per-row star toggle (match the live /sessions view) Telegram splits a keyboard row's width evenly across its buttons with no API to size a narrow second column, so the per-row star toggle took half of every session row. Keep the star as a read-only "kept alive" marker in the switch label; star/unstar via /star and /unstar. Each row is now a single full-width tap-to-switch button. Removes the dead sessstar callback and routing, and the unused Router.toggle_session_starred wrapper. Co-Authored-By: Claude Opus 4.8 --- nerve/channels/router.py | 4 --- nerve/channels/telegram.py | 36 +++---------------- tests/test_telegram_sessions.py | 63 +++++++-------------------------- 3 files changed, 16 insertions(+), 87 deletions(-) diff --git a/nerve/channels/router.py b/nerve/channels/router.py index 8dbd7190..63e721a1 100644 --- a/nerve/channels/router.py +++ b/nerve/channels/router.py @@ -445,10 +445,6 @@ async def set_session_starred(self, session_id: str, starred: bool) -> bool: """Star/unstar a session. Starred sessions are never auto-archived.""" return await self.engine.sessions.set_starred(session_id, starred) - async def toggle_session_starred(self, session_id: str) -> bool: - """Toggle a session's starred flag. Returns the new state.""" - return await self.engine.sessions.toggle_starred(session_id) - async def get_session(self, session_id: str) -> dict[str, Any] | None: """Fetch a session row (title/status/…), or None if it is gone.""" return await self.engine.db.get_session(session_id) diff --git a/nerve/channels/telegram.py b/nerve/channels/telegram.py index b656b194..a0c970db 100644 --- a/nerve/channels/telegram.py +++ b/nerve/channels/telegram.py @@ -266,17 +266,9 @@ def build_sessions_view( # ids are short, but guard defensively so a button always round-trips. if sid is None or len(cb.encode("utf-8")) > 64: continue - row = [InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)] - # Per-session star toggle: ⭐ = kept alive (never auto-closed), - # ☆ = normal. Add it only when its callback also round-trips. - star_cb = f"sessstar:{sid}" - if len(star_cb.encode("utf-8")) <= 64: - row.append( - InlineKeyboardButton( - "⭐" if s.get("starred") else "☆", callback_data=star_cb, - ) - ) - rows.append(row) + rows.append( + [InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)] + ) shown = len(rows) # Pager row — only the arrows that lead somewhere, so no tap is a dead end. @@ -319,7 +311,7 @@ def build_sessions_view( if offset or has_next: text += f"\nPage {offset // _SESSIONS_PAGE_SIZE + 1}" text += "\n➕ New session keeps the current one running." - text += "\n⭐ keeps a session alive (never auto-closed); tap ☆/⭐ to toggle." + text += "\n⭐ = kept alive (never auto-closed); /star or /unstar the current session." elif has_prev: text = "No more sessions — tap ⬅️ Prev to go back." else: @@ -1869,7 +1861,6 @@ async def _handle_session_button(self, query: Any) -> None: ``sess:new`` — create a fresh session (current keeps running) ``sess:`` — switch routing to , then show its history ``sesstail::`` — widen the catch-up window (informational only) - ``sessstar:`` — toggle the session's starred (kept-alive) flag After a switch/create the card is replaced by that session's recent history (native order, oldest→newest) so the user can catch up — which @@ -1909,24 +1900,6 @@ async def _handle_session_button(self, query: Any) -> None: await self._edit_session_tail(query, sid, window) return - if data.startswith("sessstar:"): - sid = data.split(":", 1)[1] - try: - now_starred = await self.router.toggle_session_starred(sid) - except ValueError: - await query.answer( - "That session is no longer available", show_alert=True, - ) - else: - await query.answer( - "⭐ Kept alive — won't auto-close" - if now_starred - else "☆ Normal — may auto-close when idle" - ) - text, markup = await self._sessions_view_for(channel_key) - await self._safe_edit(query, text, markup) - return - # sess:new / sess: target = data.split(":", 1)[1] sid = target @@ -1963,7 +1936,6 @@ async def _handle_callback_query(self, update: Update, context: Any) -> None: if ( query.data.startswith("sess:") or query.data.startswith("sesstail:") - or query.data.startswith("sessstar:") ): await self._handle_session_button(query) return diff --git a/tests/test_telegram_sessions.py b/tests/test_telegram_sessions.py index 4f61181c..f1c9da5f 100644 --- a/tests/test_telegram_sessions.py +++ b/tests/test_telegram_sessions.py @@ -288,69 +288,30 @@ def test_tail_active_status_shows_live_emoji(): assert "🟢" in text and "•" not in text -# --- star (kept-alive) toggle --------------------------------------------- # +# --- star (kept-alive) marker (no per-row toggle) -------------------------- # -def test_starred_session_shows_marker_and_filled_toggle(): +def test_starred_session_shows_marker_in_label_without_toggle_button(): sessions = [ {"id": "aaaa1111", "title": "Kept", "source": "web", "starred": True}, {"id": "bbbb2222", "title": "Normal", "source": "web"}, ] _text, markup = build_sessions_view(sessions, current_id=None) by_cb = {b.callback_data: b for b in _flat(markup)} - # Starred: switch label carries ⭐; its toggle button is the filled star. + # Starred: the switch label carries the ⭐ marker (read-only indicator). assert by_cb["sess:aaaa1111"].text == "⭐ Kept" - assert by_cb["sessstar:aaaa1111"].text == "⭐" - # Normal: no marker; toggle button is the hollow star. + # Normal: no marker. assert by_cb["sess:bbbb2222"].text == "Normal" - assert by_cb["sessstar:bbbb2222"].text == "☆" + # No per-row star toggle buttons — starring is via /star and /unstar, so the + # switch list stays one full-width tap-to-switch button per row (Telegram + # would render a cramped half-width 2nd column otherwise). + assert not any( + (b.callback_data or "").startswith("sessstar:") for b in _flat(markup) + ) def test_sessions_view_explains_star_keeps_alive(): text, _markup = build_sessions_view( [{"id": "aaaa1111", "title": "Work", "source": "web"}], current_id=None, ) - assert "keeps a session alive" in text - - -@pytest.mark.asyncio -async def test_sessstar_callback_toggles_and_rerenders(): - from nerve.channels.telegram import TelegramChannel - - sessions = [{"id": "keep01", "title": "Keep", "source": "web"}] - ch = TelegramChannel.__new__(TelegramChannel) - router = _FakeRouter(sessions, {"keep01": 3}, current="keep01") - toggled = {} - - async def _toggle(sid): - toggled["sid"] = sid - return True - - router.toggle_session_starred = _toggle - ch.router = router - - edited = {} - - async def _safe_edit(query, text, markup, **kw): - edited["text"] = text - - ch._safe_edit = _safe_edit - - answers = [] - - class _Chat: - id = 1 - - class _Msg: - chat = _Chat() - - class _Query: - data = "sessstar:keep01" - message = _Msg() - - async def answer(self, *a, **k): - answers.append(a[0] if a else "") - - await ch._handle_session_button(_Query()) - assert toggled["sid"] == "keep01" # toggle routed with the id - assert edited # list re-rendered in place - assert answers and "Kept alive" in answers[0] + assert "kept alive" in text.lower() + assert "/star" in text