Skip to content

feat(chatlist): page the dialog list instead of stopping at fifty - #61

Merged
Ceesaxp merged 2 commits into
mainfrom
feat/chatlist-paging
Sep 3, 2026
Merged

feat(chatlist): page the dialog list instead of stopping at fifty#61
Ceesaxp merged 2 commits into
mainfrom
feat/chatlist-paging

Conversation

@Ceesaxp

@Ceesaxp Ceesaxp commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes #50.

What

ListChats could paginate to MaxDialogsLimit, but the TUI called LoadChats(50) once at startup and never asked again. The "All" tab showed the fifty most recent dialogs plus whatever arrived later by update; older chats were reachable only through search.

The cursor is what asks now. Coming within ten rows of the end requests the next page — ahead of the bottom rather than at it, so rows arrive before the reader runs out of them.

It declines to ask when asking would be pointless: while a request is in flight (holding j at the bottom would otherwise be a round trip per keystroke), once a short page has said the list is exhausted, and while a filter is applied — filtering narrows what is already loaded, so the end of three matches says nothing about the dialog list.

The cursor stays on the client. It's a gotd InputPeer; a chat list holding one would be a UI component carrying a protocol type it can do nothing else with. LoadMoreChats and MoreChatsToLoad are the whole surface.

UnreadCountMsg is deleted, not fixed

The issue asks that a page sum stop being announced as the account total. It turns out the sum has been announced to nobody since 59960be retired the status bar, which was its only consumer — the fourth dead message type I've found in this repo.

What is actually on screen comes from ChatStore.TotalUnread over loaded chats. That is honest about its scope, satisfies the issue's stated minimum, and now grows as pages arrive.

Two seams, both with precedent

pageDialogs takes the page fetcher as a function, so every rule that ends or advances pagination is testable without a server. The chat list holds a two-method dialogSource rather than the concrete client, so whether a keystroke asks — and asks once — is testable with a fake. Both follow notifySettingsGetter in internal/telegram.

What the mutation pass caught

Two tests were passing for the wrong reason. TestACursorTheServerDidNotAdvanceEndsTheList and TestAPageWithNothingToContinueFromEndsTheList both used fakes whose pages were shorter than requested — so the short-page rule ended the list before the guard each test names ever ran. Both would have passed against a build with their guard deleted.

Fixed with full pages, and the short-page test now counts requests: "it stopped" is not the same claim as "it stopped because of this".

Acceptance criteria

  • ✅ scrolling to the bottom of "All" loads older dialogs
  • ✅ the unread total does not drop for chats outside the first page — it counts loaded chats and grows
  • ✅ tests cover the second-page request and the counter behaviour with fakes

Testing

Build, vet, go test ./..., gofmt clean. 18 mutants, no survivors.

🤖 Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 72768a5695

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +876 to +880
// Reaching the bottom is the ask. The dialog list is loaded a
// page at a time — an account's whole history at startup would
// be a long wait for chats nobody scrolled to — so the cursor
// arriving near the end is what requests the next one.
cmds = append(cmds, m.pageAheadCmd())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the page command when selecting a chat

When the cursor is already within the paging threshold and the user presses Enter, pageAheadCmd() sets pagingMore and returns a command, but the selection branch immediately returns only ChatSelectedMsg, discarding the queued page command. The request therefore never runs while pagingMore remains true, so returning to the chat list leaves pagination permanently disabled for the session; this is reproducible after jumping to the bottom with G and then opening that chat.

Useful? React with 👍 / 👎.

Comment on lines +876 to +880
// Reaching the bottom is the ask. The dialog list is loaded a
// page at a time — an account's whole history at startup would
// be a long wait for chats nobody scrolled to — so the cursor
// arriving near the end is what requests the next one.
cmds = append(cmds, m.pageAheadCmd())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Trigger paging after mouse-wheel navigation

Pagination is consulted only inside the tea.KeyPressMsg path, but the application's mouse-wheel handler moves this same cursor through chatList.ScrollBy and returns without a command. A user navigating the chat list with the mouse can therefore reach the last loaded dialog but never request the next page, leaving the list capped at the initial 50 until they also press a keyboard key.

Useful? React with 👍 / 👎.

Comment thread internal/telegram/chats.go Outdated
Comment on lines +176 to +177
if raw < pageLimit || after.peer == nil ||
(after.date == cursor.date && after.id == cursor.id) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the peer when detecting a stalled cursor

The pagination cursor consists of date, message ID, and peer, but the stalled-cursor check compares only the first two fields. If the last dialog in the new page belongs to a different peer whose top message has the same ID and timestamp as the previous cursor—a possible collision because channel message IDs are peer-scoped—the page is incorrectly marked exhausted, MoreChatsToLoad becomes false, and all older dialogs are permanently omitted.

Useful? React with 👍 / 👎.

@Ceesaxp Ceesaxp left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P1] Pressing Enter near the end permanently wedges pagination. pageAheadCmd runs before m.list.Update and sets pagingMore=true, but when the list reports selection, the early return at lines 888-890 returns only ChatSelectedMsg and discards the queued page command. After returning to the list, shouldPageAhead always rejects further requests because pagingMore never clears. A regression test with 50 rows, cursor 49, and KeyEnter reproduces it. Either request paging only after a movement key, or include the queued commands in the selection return.

@Ceesaxp

Ceesaxp commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

Fixed in c205dcc. All three, and the P1 reproduced exactly as described before I touched anything: pagingMore=true, zero requests, permanently wedged.

[P1] Enter near the bottom. You offered two fixes; I took neither literally. Threading the command through that return would leave the coupling in place, and restricting paging to movement keys would still let the next early return re-introduce it. The early return is gone instead — the request is made after m.list.Update, once, and every path falls through to the single tea.Batch at the end.

That is also more correct on its own terms: paging was being decided on where the cursor was before the key moved it.

The underlying shape is divergence 39 again, in a new place: pageAheadCmd both marks a request in flight and returns the command that clears it, so it is a call every site has to remember to propagate — and one of them forgot. Worth noting because it will recur.

[P2] The wheel. Real, and it made the feature keyboard-only. ScrollBy returns a command now and the app's wheel handler returns it.

[P2] The stalled cursor. Also real. sameCursor compares the peer's identity rather than the InputPeer value — those carry access hashes the server is free to reissue, so comparing the values would make two descriptions of one peer look different and defeat the guard from the other direction. Both directions have tests.

21 mutants, no survivors.

Andrei Popov and others added 2 commits September 3, 2026 19:24
Closes #50.

ListChats could paginate to MaxDialogsLimit, but the TUI called
LoadChats(50) once at startup and never asked again. The "All" tab showed
the fifty most recent dialogs plus whatever arrived later by update; older
chats were reachable only through search.

The cursor asking for more is what asks now: reaching within ten rows of the
end requests the next page, ahead of the bottom rather than at it, so the
rows arrive before the reader runs out of them. One request at a time —
holding j at the bottom would otherwise be a round trip per keystroke — and
none at all once a short page has said the list is exhausted, or while a
filter is applied, since filtering narrows what is already loaded and the
end of three matches says nothing about the dialog list.

The cursor stays on the client. It is a gotd InputPeer, and a chat list
holding one would be a UI component carrying a protocol type it can do
nothing else with; LoadMoreChats and MoreChatsToLoad are the whole surface.

UnreadCountMsg is deleted rather than fixed. The issue asks that a page sum
stop being announced as the account total — but the sum has been announced
to nobody since the status bar was retired in 59960be, which was its only
consumer. What is on screen comes from ChatStore.TotalUnread over loaded
chats, which is honest about its scope and now grows as pages arrive. Fourth
dead message type found in this repo.

Two seams, both following notifySettingsGetter's precedent in the same
package: pageDialogs takes the page fetcher as a function, so every rule
that ends or advances pagination is testable without a server, and the chat
list holds a two-method dialogSource rather than the concrete client, so
whether a keystroke asks — and asks once — is testable with a fake.

The mutation pass found two tests passing for the wrong reason. Both loop
guards were covered by tests whose fakes let the SHORT-PAGE rule fire first,
so neither the stalled cursor nor the nil continuation peer was ever
exercised. Full pages and an endless fake fixed both; the short-page test
now counts requests, because "it stopped" is not the same claim as "it
stopped because of this".

18 mutants, no survivors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review findings, one of them a real wedge.

[P1] pageAheadCmd does two things at once: it marks a request in flight and
returns the command that clears the mark. It ran BEFORE m.list.Update, and
the selection branch then returned ChatSelectedMsg on its own — so pressing
Enter near the bottom of the list charged for a request whose command was
discarded, and pagingMore stayed true for the rest of the session. G then
Enter was enough. Reproduced before fixing.

The fix is not to thread the command through that return but to remove the
early return: the request is made after the move, once, and every path falls
through to the single tea.Batch at the end. Which is also more correct —
paging was being decided on where the cursor was before the key moved it.

This is divergence 39's lesson again, in a new place. A call that mutates
state AND returns the thing that undoes it is a call every site has to
remember to propagate, and one of them forgets.

[P2] The wheel moves the same cursor the keyboard does and asked for
nothing, so a mouse user reached the last loaded dialog and stopped there.
ScrollBy returns a command now, and the app's wheel handler returns it.

[P2] The stalled-cursor check compared date and id but not the peer. A
message id is scoped to its peer, so two dialogs in different channels can
share an id and a date — and the loop would then call the cursor stalled
when it had in fact moved, dropping every older dialog for the session.
sameCursor compares the peer's IDENTITY rather than the InputPeer value:
those carry access hashes the server is free to reissue, and two values
describing one peer would otherwise compare unequal.

21 mutants, no survivors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ceesaxp
Ceesaxp force-pushed the feat/chatlist-paging branch from c205dcc to f0ce984 Compare September 3, 2026 17:24
@Ceesaxp
Ceesaxp merged commit 8f87330 into main Sep 3, 2026
7 checks passed
@Ceesaxp
Ceesaxp deleted the feat/chatlist-paging branch September 3, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P3] Chat list loads one page of 50 dialogs and never pages; the global unread total is computed from that page only

1 participant