diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index a6cee59ea0ead..98c4f312ede53 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -419,16 +419,35 @@ export function ChatSidebar({ return out }, [pinnedSessionIds, sessionByAnyId]) - const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions]) - const pinnedIdSet = useMemo(() => new Set(pinnedSessionIds), [pinnedSessionIds]) + // Every id a pin is reachable under: the raw stored ids, plus BOTH identities + // of each session we resolved one to. A pin is stored on the durable lineage + // root, but the lists that must filter it out are fed from three independent + // fetches (recents, the messaging slice, the backend project tree) and each + // can surface the same conversation under either its live tip or its root. + // Comparing one identity against the other is how a pinned session ended up + // rendered twice — once in Pinned, once in its project group. + const pinnedIdentitySet = useMemo(() => { + const ids = new Set(pinnedSessionIds) + + for (const session of pinnedSessions) { + ids.add(session.id) + + if (session._lineage_root_id) { + ids.add(session._lineage_root_id) + } + } + + return ids + }, [pinnedSessionIds, pinnedSessions]) // A pinned session belongs to the Pinned section and nowhere else, so every - // other list filters it out (the flat recents already did). Match on the live - // id AND the durable pin id — a backend snapshot can surface either side of a - // compression tip rotation. + // other list filters it out. Match on either identity the row carries — a + // backend snapshot can surface either side of a compression tip rotation. const isPinnedSession = useCallback( - (session: SessionInfo) => pinnedRealIdSet.has(session.id) || pinnedIdSet.has(sessionPinId(session)), - [pinnedRealIdSet, pinnedIdSet] + (session: SessionInfo) => + pinnedIdentitySet.has(session.id) || + (session._lineage_root_id != null && pinnedIdentitySet.has(session._lineage_root_id)), + [pinnedIdentitySet] ) // Full-text search across *all* sessions (not just the loaded page) so 699 @@ -519,10 +538,11 @@ export function ChatSidebar({ } }, [agentOrderIds, agentOrderManual, unpinnedAgentSessions]) - const agentSessions = useMemo( - () => (agentOrderManual ? orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds) : unpinnedAgentSessions), - [unpinnedAgentSessions, agentOrderIds, agentOrderManual] - ) + // Recents render in recency order. The hand-picked order is layered on per + // date group inside the section (orderRowsWithinGroups) rather than baked + // into the list here, so a drag ranks a row among its own day's chats + // instead of flattening the whole sidebar into an undated manual mode. + const agentSessions = unpinnedAgentSessions // Recents are local-only: messaging-platform sessions are fetched as their // own slice ($messagingSessions) and rendered in self-managed per-platform @@ -1304,7 +1324,7 @@ export function ChatSidebar({ // virtualized long list, which must keep its own scroller. !recentsVirtualizes && COMPACT_FLAT )} - dateGrouped={inProject || !agentOrderManual} + dateGrouped dndSensors={dndSensors} emptyState={ showSessionSkeletons ? ( @@ -1419,6 +1439,7 @@ export function ChatSidebar({ ) : undefined } liveSessions={inProject ? agentSessions : undefined} + manualOrderIds={agentOrderManual ? agentOrderIds : undefined} onArchiveSession={onArchiveSession} onBranchSession={onBranchSession} onDeleteSession={onDeleteSession} diff --git a/apps/desktop/src/store/layout-pinned-order.test.ts b/apps/desktop/src/store/layout-pinned-order.test.ts new file mode 100644 index 0000000000000..219c73d2f6c17 --- /dev/null +++ b/apps/desktop/src/store/layout-pinned-order.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { $pinnedSessionIds, setPinnedSessionOrder } from './layout' + +beforeEach(() => { + $pinnedSessionIds.set([]) +}) + +describe('setPinnedSessionOrder', () => { + it('applies a full reorder', () => { + $pinnedSessionIds.set(['a', 'b', 'c']) + setPinnedSessionOrder(['c', 'b', 'a']) + + expect($pinnedSessionIds.get()).toEqual(['c', 'b', 'a']) + }) + + it('reorders a subset in place, leaving unresolved pins alone', () => { + // The dragged list only contains pins whose session actually loaded. A pin + // whose row hasn't arrived is absent from the drag but must keep its slot + // — requiring the two lists to match length discarded the whole reorder. + $pinnedSessionIds.set(['loaded1', 'unresolved', 'loaded2']) + setPinnedSessionOrder(['loaded2', 'loaded1']) + + expect($pinnedSessionIds.get()).toEqual(['loaded2', 'unresolved', 'loaded1']) + }) + + it('ignores ids that are not pinned', () => { + $pinnedSessionIds.set(['a', 'b']) + setPinnedSessionOrder(['b', 'stranger', 'a']) + + expect($pinnedSessionIds.get()).toEqual(['b', 'a']) + }) + + it('is a no-op when the drag names nothing pinned', () => { + const before = ['a', 'b'] + $pinnedSessionIds.set(before) + setPinnedSessionOrder(['ghost']) + + expect($pinnedSessionIds.get()).toBe(before) + }) + + it('keeps the same array reference when the order is unchanged', () => { + const before = ['a', 'b'] + $pinnedSessionIds.set(before) + setPinnedSessionOrder(['a', 'b']) + + expect($pinnedSessionIds.get()).toBe(before) + }) +}) diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index aed28f95ddc84..ca2070792d966 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -414,17 +414,32 @@ export function unpinSession(sessionId: string) { ) } -// Replace the whole pinned order at once (drag-reorder hands back the new order -// rather than a single move). Keep only ids that are actually pinned so a stale -// row can't smuggle an unpinned id into the store. +// Apply a new pinned order from a drag. The dragged list only holds the pins +// that currently RESOLVE to a loaded row, so this is a permutation of a subset: +// re-slot the ids it names into the positions they already occupied, leaving +// any pin it doesn't mention (row not loaded yet) exactly where it was. +// Requiring both lists to be the same length instead let one unresolved pin +// silently discard the whole reorder. export function setPinnedSessionOrder(ids: string[]) { const prev = $pinnedSessionIds.get() const pinned = new Set(prev) - const next = ids.filter(id => pinned.has(id)) + const moving = ids.filter(id => pinned.has(id)) - if (next.length === prev.length && !arraysEqual(prev, next)) { - $pinnedSessionIds.set(next) + if (!moving.length) { + return } + + const movingSet = new Set(moving) + const next = [...prev] + let cursor = 0 + + prev.forEach((id, index) => { + if (movingSet.has(id)) { + next[index] = moving[cursor++] + } + }) + + setOrderIds($pinnedSessionIds, next) } export function bumpSessionsLimit(step: number = SIDEBAR_SESSIONS_PAGE_SIZE) {