fix(desktop): show a pinned session once, and keep its drag order
Two ways a pin got misfiled. The duplicate: a pin is stored on the durable lineage root, but recents, the messaging slice and the backend project tree are three independent fetches and each can surface the same conversation under either its live tip or its root — so the filter compared one identity against the other, missed, and the session rendered in both Pinned and its project group. Match on every id the pin is reachable under. The lost reorder: a drag only reports the pins whose row is loaded, and setPinnedSessionOrder required that list to match the stored one in length, so a single unresolved pin discarded the whole reorder. Treat it as a permutation of a subset — re-slot the named ids, leave the rest.
This commit is contained in:
parent
daeedf67c9
commit
256aac54d9
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue