web: order the chat list by recency, not an imperative bump (#210)
Bug: an unpinned chat only floated to the top when a message arrived while you were looking elsewhere — the active chat and your own sends never moved it, because ordering relied on the imperative bumpThread (which is skipped for the active thread) and otherwise kept server load-order. Fix: make the render sort by recency, the single source of truth — mirroring the server's ORDER BY. pinnedFirst now orders each band (pinned, then the rest) most-recent-first by last_at, falling back to created_at so a brand-new empty chat sorts by when it was made. last_at is already updated on every activity (active or not), so every chat with newer activity rises. Stable sort keeps same-second ties in place. - ui.js: pinnedFirst gains a `lastOf` accessor (defaults to a no-op, so existing callers/tests are unchanged); sorts each band newest-first. - shell.js: renderThreadList passes last_at||created_at; bumpThread removed (function + its call) — the sort supersedes it. - This also floats the Reminders thread to the top when a reminder fires (supersedes PR #206): the fired event updates last_at, so the sort lifts it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 files changed,
+48
−22
changelog/2026-07-05-chat-list-recency.md+1 −0test/ui.test.mjs+22 −0web/static/apps.js+3 −3web/static/shell.js+12 −14web/static/ui.js+10 −5
diff --git a/changelog/2026-07-05-chat-list-recency.md b/changelog/2026-07-05-chat-list-recency.md| new file mode 100644 |
| index 0000000..5fe2792 |
| --- /dev/null |
| +++ b/changelog/2026-07-05-chat-list-recency.md |
| @@ -0,0 +1 @@ |
| +Your chat list now keeps the most recent conversations on top, the way you'd expect: any chat with new activity floats up — including the one you're currently in, and ones you've just sent to. (Previously a chat only moved up when a message arrived while you were looking elsewhere.) Pinned chats still sit above the rest. A reminder firing floats its Reminders chat up too. |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index e5e9d6f..892225b 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -164,6 +164,28 @@ await test("pinnedFirst: no pins leaves the list unchanged", () => { |
| assert.deepEqual(pinnedFirst(list, () => false), list); |
| }); |
| +await test("pinnedFirst: orders each band most-recent-first by lastOf", () => { |
| + // Out-of-recency-order input (the pinned d is older than pinned b; unpinned c |
| + // newer than a) — each band is re-sorted newest-first, pinned band on top. |
| + const list = [ |
| + { id: "a", at: 10 }, |
| + { id: "b", at: 40 }, |
| + { id: "c", at: 30 }, |
| + { id: "d", at: 20 }, |
| + ]; |
| + const pins = { b: true, d: true }; |
| + assert.deepEqual( |
| + pinnedFirst(list, (t) => pins[t.id], (t) => t.at).map((t) => t.id), |
| + ["b", "d", "c", "a"], |
| + ); |
| + // Ties keep input order (stable) — so a same-second burst doesn't jump around. |
| + const tied = [{ id: "x", at: 5 }, { id: "y", at: 5 }, { id: "z", at: 9 }]; |
| + assert.deepEqual( |
| + pinnedFirst(tied, () => false, (t) => t.at).map((t) => t.id), |
| + ["z", "x", "y"], |
| + ); |
| +}); |
| + |
| await test("ownMessage: matches by uid when both sides have one", () => { |
| assert.equal(ownMessage({ uid: "u1", name: "Paul" }, "u1", "Paul"), true); |
| assert.equal(ownMessage({ uid: "u2", name: "Paul" }, "u1", "Paul"), false); // same name, other user |
diff --git a/web/static/apps.js b/web/static/apps.js| index c5e11e7..1f8d0cf 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -961,9 +961,9 @@ export function appUnread(event) { |
| // notification (permission permitting) — the background equivalent of the push |
| // a closed app gets. There is deliberately no in-app floating toast: a live app |
| // event announces itself where it belongs — the event lands in its thread, which |
| -// floats up the chat list with an unread dot and preview (see bumpThread). A |
| -// pop-up that overlays whatever you're reading is exactly the thing CLAUDE.md |
| -// bans. Never called during history refolds: replaying the past must not re-alert. |
| +// floats to the top of the chat list (sorted by recency) with an unread dot and |
| +// preview. A pop-up that overlays whatever you're reading is exactly the thing |
| +// CLAUDE.md bans. Never called during history refolds: replaying the past must not re-alert. |
| export function notifyLive(threadId, event) { |
| const app = typeOwner.get(event && event.type); |
| if (!app) return; |
diff --git a/web/static/shell.js b/web/static/shell.js| index 6364cb4..f74bcf2 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1255,11 +1255,16 @@ function renderThreadList() { |
| return; |
| } |
| // A search reveals everything that matches (so a closed chat can be found and |
| - // reopened); the default list hides closed chats and floats pinned ones to the |
| - // top (bumpThread keeps recency order within each of the two bands). |
| - const shown = chatFilter |
| + // reopened); the default list hides closed chats. Both bands (pinned, then the |
| + // rest) are ordered most-recent-first: last_at is the last message's time, with |
| + // created_at as the fallback so a brand-new empty chat sorts by when it was made |
| + // (mirrors the server's ORDER BY). This is what floats a chat to the top the |
| + // moment it has new activity — the active chat and your own sends included. |
| + const recencyOf = (t) => t.last_at || t.created_at || 0; |
| + const base = chatFilter |
| ? threads.filter((t) => (threadName[t.id] || "").toLowerCase().includes(chatFilter)) |
| - : pinnedFirst(threads.filter((t) => !hidden[t.id]), (t) => pinned[t.id]); |
| + : threads.filter((t) => !hidden[t.id]); |
| + const shown = pinnedFirst(base, (t) => pinned[t.id], recencyOf); |
| if (!shown.length) { |
| const empty = document.createElement("p"); |
| empty.className = "thread-empty-note"; |
| @@ -3488,9 +3493,10 @@ function connectWS() { |
| } |
| if (tid !== activeId) { |
| unread[tid] = !ownMessage(payload, myUserId, session.name); |
| - bumpThread(tid); |
| } |
| - // Refresh the chat-list preview + last-activity time for this thread. |
| + // Refresh the chat-list preview + last-activity time for this thread. last_at |
| + // is what floats this thread to the top on the next renderThreadList (the list |
| + // sorts by recency), so it's updated whether or not the thread is active. |
| const t = threads.find((x) => x.id === tid); |
| if (t) { |
| t.last_at = msg.created_at || Math.floor(Date.now() / 1000); |
| @@ -3526,14 +3532,6 @@ function connectWS() { |
| }; |
| } |
| -function bumpThread(id) { |
| - const i = threads.findIndex((t) => t.id === id); |
| - if (i > 0) { |
| - const [t] = threads.splice(i, 1); |
| - threads.unshift(t); |
| - } |
| -} |
| - |
| // ---- typing indicators (thread-scoped, ephemeral) ----------------------- |
| // The name + expiry ride encrypted inside the blob; the server is a blind relay |
| // (see hub.go's "typing" case) and fans it only to the thread's other members. |
diff --git a/web/static/ui.js b/web/static/ui.js| index 1851243..268f952 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -101,11 +101,16 @@ export function memberCountLabel(people, bots, agents = 0) { |
| } |
| // ---- chat list ordering -------------------------------------------------- |
| -// Stable pinned-first partition of the thread list: pinned chats float to the |
| -// top keeping their relative (recency) order, everything else follows in its |
| -// existing order. Pure, so the ordering rule is unit-tested in Node. |
| -export function pinnedFirst(list, isPinned) { |
| - return [...list.filter((t) => isPinned(t)), ...list.filter((t) => !isPinned(t))]; |
| +// Pinned-first ordering of the thread list: pinned chats float to the top, and |
| +// WITHIN each band chats are ordered most-recent-first by `lastOf(t)` (a |
| +// timestamp). This is the single source of truth for list order — every chat |
| +// with newer activity rises, mirroring the server's ORDER BY, so the list stays |
| +// correct without any imperative bump. `lastOf` defaults to a no-op (sort by 0), |
| +// which — because the sort is stable — leaves the input order untouched. Pure, |
| +// so the ordering rule is unit-tested in Node. |
| +export function pinnedFirst(list, isPinned, lastOf = () => 0) { |
| + const byRecent = (a, b) => (lastOf(b) || 0) - (lastOf(a) || 0); |
| + return [...list.filter((t) => isPinned(t)).sort(byRecent), ...list.filter((t) => !isPinned(t)).sort(byRecent)]; |
| } |
| // ---- attachment policy -------------------------------------------------- |