fix(coderd): sort pinned chats first in GetChats pagination#24222
fix(coderd): sort pinned chats first in GetChats pagination#24222
Conversation
The GetChats SQL query ordered by (updated_at, id) DESC with no pin_order awareness. A pinned chat with an old updated_at could land on page 2+ and be invisible in the sidebar's Pinned section. Add a 4-column ORDER BY: pinned-first flag DESC, negated pin_order DESC, updated_at DESC, id DESC. The negation trick keeps all sort columns DESC so the cursor tuple < comparison still works. Update the after_id cursor clause to match the expanded sort key. Fix the false handler comment claiming PinChatByID bumps updated_at.
804b284 to
6f3e8da
Compare
mafredri
left a comment
There was a problem hiding this comment.
Clean change. The negation trick is sound, the cursor tuple stays consistent with the ORDER BY, and PinnedOnFirstPage is a well-constructed regression test. Two concerns, two inline.
Bisky on the PinnedOnFirstPage test: "Oh, this one is lovely. It sets up the exact reported scenario... Real gem."
PS. coderd/x/gitsync/worker.go:309 calls GetChats with only OwnerID (default limit 50). The sort change shifts which 50 chats appear in that window. Pre-existing design concern (the worker should probably filter by workspace directly), but worth noting since the sort key now favors pinned chats over recently-updated ones in that code path.
🤖 This review was automatically generated with Coder Agents.
Add within-pinned ordering assertion to CursorWithPins so the -pin_order DESC column has test coverage. Drop idx_chats_owner_updated_id which was purpose-built for the old (updated_at, id) sort. The simpler idx_chats_owner covers the owner_id filter; the old composite index is pure write overhead now.
mafredri
left a comment
There was a problem hiding this comment.
Both findings addressed cleanly. The within-pinned ordering assertion now covers the -pin_order DESC column, and migration 000466 drops the dead index. No new findings from 5 reviewers (Netero, Bisky, Mafuuu, Knuckle, Razor).
Bisky on CursorWithPins: "Five chats, two pinned, paginated in pages of 2... Each assertion catches a distinct failure mode. I tried mentally defeating this test with various broken ORDER BY/cursor combinations and couldn't."
🤖 This review was automatically generated with Coder Agents.
johnstcn
left a comment
There was a problem hiding this comment.
Human review below. Rest of the changes seem fine to me, mostly nits on my end.
- require.Eventually -> testutil.Eventually (context-aware) - Per-chat ListChats polling -> single GetChat-per-ID loop - for i := 0; i < N; i++ -> for range / for i := range
A just-created chat should always be fetchable. Use require.NoError instead of swallowing the error and retrying.
Why
Pinned chats in the Agents sidebar don't appear until the user scrolls far
enough to load the page containing them. The
GetChatsSQL query ordered by(updated_at, id) DESCwith nopin_orderawareness, so a pinned chatwith an old
updated_atends up on page 2+ and is invisible in the Pinnedsection.
What changed
The
GetChatsORDER BY now uses a 4-column key: pinned-first flag DESC,negated pin_order DESC, updated_at DESC, id DESC. The negation trick
(
-pin_order) keeps all sort columns DESC so the existing cursor tuple<comparison still works without decomposing into OR/AND chains.The
after_idcursor WHERE clause is updated to match the expanded sort key.A false handler comment claiming
PinChatByIDbumpsupdated_atiscorrected; the actual SQL never touched
updated_at.Migration 000466 drops
idx_chats_owner_updated_idwhich was purpose-builtfor the old sort and is now pure write overhead. The simpler
idx_chats_ownercovers the
owner_idfilter.No frontend changes needed: the sidebar already filters
pin_order > 0fromloaded chats and sorts client-side.
Implementation plan
Problem
Pinned chats in the Agents sidebar don't appear until the user scrolls far enough to load the page containing them. The
GetChatsSQL query orders by(updated_at, id) DESCwith nopin_orderawareness. A pinned chat with an oldupdated_atcan land on page 2+ and be invisible in the sidebar's "Pinned" section.Decisions
CASEpatternafter_id)pin_orderso all sort columns are DESC, preserving tuple<comparisonImplementation flow
Phase 1: Red (prove the bug)
Add new subtests to
TestListChatsincoderd/exp_chats_test.go.Test 1: Pinned chat missing from first page. Insert a chat, insert 50+ more chats (filling page 1), pin the first chat, list page 1 with
limit=50, assert the pinned chat appears on page 1. This assertion fails on current code because the pinned chat has the oldestupdated_atand sorts last.Test 2: Cursor pagination with pinned chats. Creates a mix of pinned and unpinned chats, paginates with
after_idcursors, and verifies all chats appear exactly once with pinned chats before unpinned ones.Phase 2: Green (fix the SQL)
Edit
coderd/database/queries/chats.sql, modifying only theGetChatsquery.ORDER BY change: Add pinned-first sort terms before the existing
(updated_at, id) DESC. Use the-pin_ordernegation so all sort columns are DESC.Cursor WHERE clause change: Prepend the two new computed columns to the existing cursor tuple. The comparison remains a single
<on the expanded tuple.No new SQL parameters are introduced. The
pin_ordercolumn is already on thechatstable.Phase 3: Refactor
Review the SQL for clarity. Fix the false handler comment claiming
PinChatByIDbumpsupdated_at.Phase 4: Frontend verification
The frontend sidebar already filters
pin_order > 0from loaded chats and sorts bypin_orderascending. No frontend code changes needed.Output files
coderd/database/queries/chats.sqlGetChatscoderd/database/queries.sql.gomake gen)coderd/exp_chats.gocoderd/exp_chats_test.goPinnedOnFirstPage,CursorWithPinscoderd/database/migrations/000466_*Research document
Problem context
Pinned chats in the Agents sidebar do not appear until the user scrolls down enough to load the page containing them. This happens because pinned and unpinned chats share a single paginated query ordered by
(updated_at, id) DESC. A pinned chat last updated weeks ago may live on page 3+, and the frontend cannot display it in the "Pinned" section until that page loads.The frontend splits pinned from unpinned chats client-side by filtering
pin_order > 0fromchatsQuery.data.pages.flat(). If the chat hasn't been loaded yet, it isn't in the flat list and won't appear.Key evidence
ORDER BY (updated_at, id) DESCwith nopin_orderawareness.pin_order > 0from already-loaded chats only. No separate pinned-chats query exists.CASE WHEN favorite ... THEN 0 ELSE 1 END ASCin ORDER BY is the established codebase convention for sort-priority items.PinChatByIDbumpsupdated_at, but the SQL only setspin_order. The intended workaround was never implemented.(updated_at, id) < (SELECT ...)as a 2-tuple with uniform DESC direction. The<operator works because both columns sort DESC.Cursor pagination with mixed sort directions
The negation trick (
-pin_order) keeps all sort columns DESC so a single tuple<comparison works:Verification with sample data (all DESC,
<gives "next page"):(1, -1, ts1, id1)(1, -2, ts2, id2)(0, 0, ts4, id4)(0, 0, ts5, id5)where ts5 < ts4DESC order:
(1,-1) > (1,-2) > (0,0,ts4) > (0,0,ts5). Correct.Decisions
D1: SQL ordering (Approach B)
Three approaches considered: (A) Separate pinned-chats query, (B) SQL ordering change, (C) Handler-level prepend. Chose B for single query/single cache simplicity, following workspace favorites convention.
D2: Negated pin_order for cursor compatibility
No production code uses
after_idfor chats (frontend uses offset), but the SQL supports it and tests exercise it. The negation approach avoids complex OR/AND decomposition.D3: Pinned chats participate in normal pagination
If a user pins 60 chats, the first 50 appear on page 1 and the rest on page 2. Human decision: "the pins also falls naturally into pagination."