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 +}