Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions common/src/desktop-cloud/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
import {
THREAD_GROUPS,
arrangeThreadPanel,
movePinnedThread,
moveThreadGroup,
threadDigestSchema,
threadGroup,
Expand Down Expand Up @@ -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<typeof arrangeThreadPanel>[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'])
Expand Down
58 changes: 50 additions & 8 deletions common/src/desktop-cloud/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(),
}: { order?: readonly string[]; pinned?: ReadonlySet<string> } = {},
pinnedOrder = [],
}: {
order?: readonly string[]
pinned?: ReadonlySet<string>
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. */
Expand All @@ -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
}
Loading