Skip to content

fix(coderd): sort pinned chats first in GetChats pagination#24222

Merged
mafredri merged 4 commits intomainfrom
fix/pinned-chats-pagination
Apr 10, 2026
Merged

fix(coderd): sort pinned chats first in GetChats pagination#24222
mafredri merged 4 commits intomainfrom
fix/pinned-chats-pagination

Conversation

@mafredri
Copy link
Copy Markdown
Member

@mafredri mafredri commented Apr 9, 2026

Why

Pinned chats in the Agents sidebar don't appear until the user scrolls far
enough to load the page containing them. The GetChats SQL query ordered by
(updated_at, id) DESC with no pin_order awareness, so a pinned chat
with an old updated_at ends up on page 2+ and is invisible in the Pinned
section.

What changed

The GetChats ORDER 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_id cursor WHERE clause is updated to match the expanded sort key.

A false handler comment claiming PinChatByID bumps updated_at is
corrected; the actual SQL never touched updated_at.

Migration 000466 drops idx_chats_owner_updated_id which was purpose-built
for the old sort and is now pure write overhead. The simpler idx_chats_owner
covers the owner_id filter.

No frontend changes needed: the sidebar already filters pin_order > 0 from
loaded 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 GetChats SQL query orders by (updated_at, id) DESC with no pin_order awareness. A pinned chat with an old updated_at can land on page 2+ and be invisible in the sidebar's "Pinned" section.

Decisions

# Question Choice Classification
D1 How to guarantee pinned chats appear early SQL ordering change, following workspace favorites CASE pattern Human ratified agent recommendation
D2 Handle cursor pagination (after_id) Negate pin_order so all sort columns are DESC, preserving tuple < comparison Agent-recommended
D3 Pinned chats and pagination boundary Pinned chats participate in normal pagination, no special guarantee Human decision

Implementation flow

Phase 1: Red (prove the bug)

Add new subtests to TestListChats in coderd/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 oldest updated_at and sorts last.

Test 2: Cursor pagination with pinned chats. Creates a mix of pinned and unpinned chats, paginates with after_id cursors, 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 the GetChats query.

ORDER BY change: Add pinned-first sort terms before the existing (updated_at, id) DESC. Use the -pin_order negation 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_order column is already on the chats table.

Phase 3: Refactor

Review the SQL for clarity. Fix the false handler comment claiming PinChatByID bumps updated_at.

Phase 4: Frontend verification

The frontend sidebar already filters pin_order > 0 from loaded chats and sorts by pin_order ascending. No frontend code changes needed.

Output files

File Change
coderd/database/queries/chats.sql ORDER BY and cursor WHERE clause in GetChats
coderd/database/queries.sql.go Regenerated (make gen)
coderd/exp_chats.go Fix false handler comment
coderd/exp_chats_test.go New subtests: PinnedOnFirstPage, CursorWithPins
coderd/database/migrations/000466_* Drop dead pagination index
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 > 0 from chatsQuery.data.pages.flat(). If the chat hasn't been loaded yet, it isn't in the flat list and won't appear.

Key evidence

  • SQL ordering: ORDER BY (updated_at, id) DESC with no pin_order awareness.
  • Frontend split: Filters pin_order > 0 from already-loaded chats only. No separate pinned-chats query exists.
  • Workspace favorites pattern: CASE WHEN favorite ... THEN 0 ELSE 1 END ASC in ORDER BY is the established codebase convention for sort-priority items.
  • False comment: Handler comment claims PinChatByID bumps updated_at, but the SQL only sets pin_order. The intended workaround was never implemented.
  • Cursor pagination: Uses (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:

ORDER BY
    CASE WHEN pin_order > 0 THEN 1 ELSE 0 END DESC,
    -pin_order DESC,
    updated_at DESC,
    id DESC

Verification with sample data (all DESC, < gives "next page"):

Row Sort tuple
Pinned #1 (pin_order=1) (1, -1, ts1, id1)
Pinned #2 (pin_order=2) (1, -2, ts2, id2)
Unpinned (recent) (0, 0, ts4, id4)
Unpinned (old) (0, 0, ts5, id5) where ts5 < ts4

DESC 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_id for 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."

🤖 This PR was created with the help of Coder Agents, and will be reviewed by a human. 🏂🏻

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.
@mafredri mafredri force-pushed the fix/pinned-chats-pagination branch from 804b284 to 6f3e8da Compare April 9, 2026 20:12
mafredri

This comment was marked as outdated.

Copy link
Copy Markdown
Member Author

@mafredri mafredri left a comment

Choose a reason for hiding this comment

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

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.

Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/database/queries/chats.sql
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.
Copy link
Copy Markdown
Member Author

@mafredri mafredri left a comment

Choose a reason for hiding this comment

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

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.

@mafredri mafredri marked this pull request as ready for review April 10, 2026 15:02
Copy link
Copy Markdown
Member

@johnstcn johnstcn left a comment

Choose a reason for hiding this comment

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

Human review below. Rest of the changes seem fine to me, mostly nits on my end.

Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/exp_chats_test.go Outdated
Comment thread coderd/exp_chats_test.go
- 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
Comment thread coderd/exp_chats_test.go Outdated
A just-created chat should always be fetchable. Use require.NoError
instead of swallowing the error and retrying.
@mafredri mafredri enabled auto-merge (squash) April 10, 2026 17:07
@mafredri mafredri merged commit a62ead8 into main Apr 10, 2026
26 checks passed
@mafredri mafredri deleted the fix/pinned-chats-pagination branch April 10, 2026 17:13
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants