eleven / ocho Public

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>
Paul Campbell a54fcc8a2eee5417e22e4ddd6882565749457eb8 parent 3ad2100
5 files changed, +48 −22
  • changelog/2026-07-05-chat-list-recency.md +1 −0
  • test/ui.test.mjs +22 −0
  • web/static/apps.js +3 −3
  • web/static/shell.js +12 −14
  • web/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 --------------------------------------------------