From 9d68355ba945f59229832bf1753c63e20331da44 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 29 Oct 2026 05:02:06 +0000 Subject: [PATCH] fix(tui): drop cached unread rows the server no longer lists merge_unread only upserted, so a topic absent from a later unread answer kept its old row. When the server answered the unread request, its rows are now the whole snapshot and the rest are dropped (and a snapshot without a total clears the cached total). A poll that did not ask, or whose request failed, still keeps what is cached. The rail shows dots only; the unread total is not displayed. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL --- CHANGELOG.md | 4 ++++ src/pepper_cli/tui/app.py | 7 ++++++- src/pepper_cli/tui/poller.py | 5 +++++ src/pepper_cli/tui/state.py | 15 +++++++++++++-- tests/test_tui_poller.py | 15 +++++++++++++++ tests/test_tui_state.py | 18 ++++++++++++++++++ 6 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6642cb0..7556fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ Upgrade notes: message listed under a superseded room and its successor counts once). The request is made only when a room moved. The vendored API snapshot is refreshed with it (#147). +- The rail drops cached unread rows for topics the server's latest `GET /api/v1/groups/unread` + answer no longer lists, instead of keeping them until a local read passes them. The rail shows + dots only; `unread_total` is not displayed (#155). + ### Removed - `ApiClient.get_group_read_state`, `GroupReadState` and the read-mark fetch pool; the poller no diff --git a/src/pepper_cli/tui/app.py b/src/pepper_cli/tui/app.py index 50b678a..9da388d 100644 --- a/src/pepper_cli/tui/app.py +++ b/src/pepper_cli/tui/app.py @@ -758,7 +758,12 @@ def _on_rail(self, update: poller.RailUpdate) -> None: def _apply_groups(self, groups: GroupList, update: poller.RailUpdate) -> None: had = self.marks.mentioned_topics(self.rooms) if self._bell else set() self.rooms = groups.groups - self.marks.merge_unread(update.unread, update.server_marks, total=update.unread_total) + self.marks.merge_unread( + update.unread, + update.server_marks, + total=update.unread_total, + snapshot=update.unread_answered, + ) self.marks.settle(self.rooms) if self._bell and (self.marks.mentioned_topics(self.rooms) - had - {self.current.topic_id}): self.bell() diff --git a/src/pepper_cli/tui/poller.py b/src/pepper_cli/tui/poller.py index 2cc5960..0ee9c06 100644 --- a/src/pepper_cli/tui/poller.py +++ b/src/pepper_cli/tui/poller.py @@ -54,6 +54,10 @@ class RailUpdate: unread: list[GroupUnreadRow] = field(default_factory=list) #: The server's deduped total, to show as it is; never a sum of ``unread``. unread_total: int | None = None + #: The server answered the unread request, so ``unread`` is its whole snapshot: a topic it + #: no longer lists is no longer unread. ``False`` when none was made or it failed, where an + #: empty ``unread`` means "not asked", not "nothing unread". + unread_answered: bool = False etag: str | None = None seen: dict[str, _Signature] | None = None #: The newest :meth:`RailPoller.refetch_all` request this step answered by reading every @@ -122,6 +126,7 @@ def step(self) -> RailUpdate: server_marks=fetched.marks if fetched else {}, unread=fetched.unread if fetched else [], unread_total=fetched.unread_total if fetched else None, + unread_answered=fetched is not None, etag=etag if complete else None, seen=seen, full=asked if full and complete else 0, diff --git a/src/pepper_cli/tui/state.py b/src/pepper_cli/tui/state.py index ba8da98..b2573af 100644 --- a/src/pepper_cli/tui/state.py +++ b/src/pepper_cli/tui/state.py @@ -164,9 +164,14 @@ def merge_unread( marks: Mapping[str, str], *, total: int | None = None, + snapshot: bool = False, ) -> None: """Take the server's unread rows (and the cursors answered with them). + With ``snapshot`` the rows are the server's whole answer, so a topic it no longer lists + loses its cached row (otherwise rows only accumulate and a topic the server dropped + stays as it was last counted). Leave it off for a partial or absent answer. + Every row is kept, even one counted from a cursor older than one held here (an answer that crossed a read on this device or another): it may count a message newer than that read, and dropping it would hide the message until the room moved again. Each row @@ -174,11 +179,17 @@ def merge_unread( moved after it. """ self.merge_server(marks) + rows = list(rows) + if snapshot: + listed = {row.topic_id for row in rows} + for topic_id in self.unread.keys() - listed: + del self.unread[topic_id] + self.counted_from.pop(topic_id, None) for row in rows: self.unread[row.topic_id] = row self.counted_from[row.topic_id] = parse_ts(marks.get(row.topic_id)) - if total is not None: - self.unread_total = total + if total is not None or snapshot: + self.unread_total = total # a snapshot without a total leaves none, not a stale one def merge_advanced(self, payload: object) -> str | None: """Fold a ``read_state.advanced`` event payload (``{topic_id, group_id, last_read_at}``) diff --git a/tests/test_tui_poller.py b/tests/test_tui_poller.py index 5c9f31f..1cbb6e6 100644 --- a/tests/test_tui_poller.py +++ b/tests/test_tui_poller.py @@ -162,6 +162,21 @@ def test_a_failed_unread_request_is_asked_again_on_the_next_poll() -> None: assert _unread_reads(server) == 2 +def test_the_update_says_whether_the_unread_snapshot_was_answered() -> None: + server = ts.standard_server() + with server.client() as api: + rail = poller.RailPoller(api) + first = rail.step() + assert first.unread_answered is True + rail.commit(first) + server.bump() + unmoved = rail.step() # a new ETag but no room moved: no unread request + assert unmoved.groups is not None and unmoved.unread_answered is False + server.fail["/api/v1/groups/unread"] = 502 + with server.client() as api: + assert poller.RailPoller(api).step().unread_answered is False + + def test_forget_etag_asks_for_the_full_list() -> None: server = ts.standard_server() with server.client() as api: diff --git a/tests/test_tui_state.py b/tests/test_tui_state.py index e377e52..eed2fad 100644 --- a/tests/test_tui_state.py +++ b/tests/test_tui_state.py @@ -116,6 +116,24 @@ def test_a_cursor_the_server_counted_from_does_not_settle_its_answer() -> None: assert not marks.is_unread(topic) +def test_a_snapshot_drops_rows_the_server_no_longer_lists() -> None: + marks = state.ReadMarks() + marks.merge_unread([_row("t1", 2), _row("t2", 1)], {"t1": "2026-01-01T00:00:00Z"}) + marks.merge_unread([_row("t2", 1)], {}, snapshot=True) + assert set(marks.unread) == {"t2"} + assert "t1" not in marks.counted_from + marks.merge_unread([_row("t2", 1)], {}, total=1, snapshot=True) + marks.merge_unread([_row("t2", 1)], {}, snapshot=True) + assert marks.unread_total is None # the snapshot did not carry one: no stale total + + +def test_a_partial_answer_keeps_rows_it_does_not_list() -> None: + marks = state.ReadMarks() + marks.merge_unread([_row("t1", 2), _row("t2", 1)], {}) + marks.merge_unread([], {}) # nothing asked: not "nothing unread" + assert set(marks.unread) == {"t1", "t2"} + + def test_the_unread_total_is_the_servers_and_a_settled_row_does_not_change_it() -> None: (room,) = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-01T00:00:00Z")])) marks = state.ReadMarks()