From af4face3c346f12c695eb9a68f451c38e7a59753 Mon Sep 17 00:00:00 2001 From: Fahad Bin Hussain Date: Tue, 27 Oct 2026 17:38:07 +0600 Subject: [PATCH] Honour a saved pinned-thread order in the Threads panel model arrangeThreadPanel now takes the viewer's pinnedOrder alongside the group order, so a pinned thread stays where it was put instead of re-sorting on every message, and movePinnedThread gives the panel the same "move one row" helper moveThreadGroup already gives it for groups. Pinned threads the saved order does not name keep their newest-first place behind the named ones, and without pinnedOrder the behaviour is exactly what it was. The panel wiring that persists the order and offers the drag handle lives in the private tree; this is the shared model half of #1559. --- common/src/desktop-cloud/threads.test.ts | 37 +++++++++++++++ common/src/desktop-cloud/threads.ts | 58 ++++++++++++++++++++---- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/common/src/desktop-cloud/threads.test.ts b/common/src/desktop-cloud/threads.test.ts index e93637d0db..2617414629 100644 --- a/common/src/desktop-cloud/threads.test.ts +++ b/common/src/desktop-cloud/threads.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import { THREAD_GROUPS, arrangeThreadPanel, + movePinnedThread, moveThreadGroup, threadDigestSchema, threadGroup, @@ -124,6 +125,42 @@ test('the viewer arranges groups and pins threads to the top of their group', () expect(arrangeThreadPanel(groups).map((entry) => entry.group)).toEqual([...THREAD_GROUPS]) }) +test('the viewer arranges the pinned block itself, and the rest stays newest-first', () => { + const { groups } = threadPanel([ + digest({ id: 'new', updatedAt: '2026-10-10T03:00:00Z' }), + digest({ id: 'middle', updatedAt: '2026-10-10T02:00:00Z' }), + digest({ id: 'oldest', updatedAt: '2026-10-10T01:00:00Z' }), + digest({ id: 'unpinned', updatedAt: '2026-10-10T00:00:00Z' }), + ]) + const pinned = new Set(['new', 'middle', 'oldest']) + // Every row here is idle, so the whole panel is one group. + const arranged = (options: Parameters[1]) => + arrangeThreadPanel(groups, options).flatMap((entry) => entry.threads.map((t) => t.id)) + + // The saved order wins over activity, so a busy thread stops shuffling the block. + expect(arranged({ pinned, pinnedOrder: ['oldest', 'new'] })).toEqual(['oldest', 'new', 'middle', 'unpinned']) + + // Nothing saved: the pinned block is the newest-first order it always was. + expect(arranged({ pinned })).toEqual(['new', 'middle', 'oldest', 'unpinned']) + + // A saved order naming threads that are gone or unpinned keeps them; only the viewer trims it. + expect(arranged({ pinned, pinnedOrder: ['deleted', 'oldest', 'unpinned'] })).toEqual([ + 'oldest', + 'new', + 'middle', + 'unpinned', + ]) +}) + +test('moving a pinned thread leaves it above the row it was dropped on', () => { + expect(movePinnedThread(['a', 'b', 'c'], 'c', 'a')).toEqual(['c', 'a', 'b']) + expect(movePinnedThread(['a', 'b', 'c'], 'a', 'c')).toEqual(['b', 'a', 'c']) + // Never arranged and unknown targets both resolve without losing anyone. + expect(movePinnedThread(['a', 'b'], 'c', 'a')).toEqual(['c', 'a', 'b']) + expect(movePinnedThread(['a', 'b'], 'a', 'gone')).toEqual(['b', 'a']) + expect(movePinnedThread([], 'a', 'b')).toEqual(['a']) +}) + test('moving a group puts it where the target was', () => { expect(moveThreadGroup(THREAD_GROUPS, 'resolved', 'waiting')).toEqual(['resolved', 'waiting', 'working', 'idle']) expect(moveThreadGroup(THREAD_GROUPS, 'waiting', 'idle')).toEqual(['working', 'idle', 'waiting', 'resolved']) diff --git a/common/src/desktop-cloud/threads.ts b/common/src/desktop-cloud/threads.ts index a6640a7ecb..cb57cecc2e 100644 --- a/common/src/desktop-cloud/threads.ts +++ b/common/src/desktop-cloud/threads.ts @@ -241,27 +241,45 @@ function threadPanelHeadline(groups: readonly ThreadPanelGroup[]): string { * The panel's groups as the viewer arranged them: groups in `order` (a saved * order missing a group, or naming an unknown one, still yields all four), * and pinned threads first within each group, keeping newest-first otherwise. + * + * Within the pinned block the viewer's own `pinnedOrder` wins, so a pinned + * thread stays where it was put instead of shuffling with every message. It is + * a partial order: pinned threads it does not name — never arranged, or since + * deleted from it — sort in behind the named ones, and sort is stable, so they + * keep their newest-first order there. */ export function arrangeThreadPanel( groups: readonly ThreadPanelGroup[], { order = THREAD_GROUPS, pinned = new Set(), - }: { order?: readonly string[]; pinned?: ReadonlySet } = {}, + pinnedOrder = [], + }: { + order?: readonly string[] + pinned?: ReadonlySet + pinnedOrder?: readonly string[] + } = {}, ): ThreadPanelGroup[] { const rank = (group: ThreadGroup) => { const at = order.indexOf(group) return at === -1 ? order.length + THREAD_GROUPS.indexOf(group) : at } + const rankPinned = (digest: ThreadDigest) => { + const at = pinnedOrder.indexOf(digest.id) + return at === -1 ? pinnedOrder.length : at + } return [...groups] .sort((a, b) => rank(a.group) - rank(b.group)) - .map((entry) => ({ - ...entry, - threads: [ - ...entry.threads.filter((digest) => pinned.has(digest.id)), - ...entry.threads.filter((digest) => !pinned.has(digest.id)), - ], - })) + .map((entry) => { + const pinnedThreads = entry.threads.filter((digest) => pinned.has(digest.id)) + return { + ...entry, + threads: [ + ...pinnedThreads.sort((a, b) => rankPinned(a) - rankPinned(b)), + ...entry.threads.filter((digest) => !pinned.has(digest.id)), + ], + } + }) } /** `order` with `group` moved to where `target` sits; unknown groups are dropped. */ @@ -278,3 +296,27 @@ export function moveThreadGroup( without.splice(known.indexOf(target), 0, group) return without } + +/** + * `pinnedOrder` with `thread` moved to sit directly above `target`, so a drag + * onto a row leaves it where it was dropped. + * + * Unlike the group order this list is partial — it holds only the pinned + * threads the viewer has arranged — so a `thread` it never named is simply + * inserted, and a `target` it never named (one still resting in the + * newest-first tail) sends `thread` to the end of the saved order, where the + * tail follows it. Names not matching a pinned thread are kept: a saved order + * outlives threads that were deleted or unpinned, and dropping them is the + * viewer's call, not this function's. + */ +export function movePinnedThread( + pinnedOrder: readonly string[], + thread: string, + target: string, +): string[] { + const without = pinnedOrder.filter((id) => id !== thread) + const at = without.indexOf(target) + if (at === -1) return [...without, thread] + without.splice(at, 0, thread) + return without +}