fix(desktop): compare pinned/archived in the session list signature

`refreshSessions` swaps the session page into `$sessions` only when
`sameCronSignature` reports a change, and that signature compared row
content — id, lineage root, title, source, profile, preview,
message_count, last_active, ended_at — but not row state. A page whose
only delta was `pinned` was judged identical and discarded, so the row
cached in the atom kept its old flag indefinitely. An idle conversation
never moves any of the compared fields again, which is exactly the kind
a user goes and unpins.

`session-pin-sync` treats that row as authoritative. Its write guard
(daeedf67c) is released by a page that CONFIRMS the value it wrote, and
falls back to letting the server win once WRITE_GUARD_MS elapses with no
confirmation. Because the confirming page was filtered out one layer up,
the fallback was the only branch that ever ran: ~10s after an unpin the
next reconcile read the frozen `pinned: true` row and called
pinSession() again. Adoption marks the id `mirrored`, so the push pass
never corrected the backend either — the local pin set and
sessions.pinned drifted apart permanently, which is why four of five
pins rendered in the sidebar read pinned=0 in state.db.

Compare both flags so a pin-only page reaches the atom. That restores
the guard's confirm path and makes WRITE_GUARD_MS a backstop again
rather than the load-bearing branch. `archived` is included for the same
reason: it is row state a consumer reads. Neither flag moves outside a
deliberate user action, so the churn the gate exists to prevent is
unaffected.

The existing `releases the guard once a page confirms the written value`
test passes on main because it hands `$sessions` the confirming page
directly — the gap was in the pipeline that decides whether such a page
is ever delivered.

Fixes #76919

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
james47
2026-08-14 17:11:52 +00:00
committed by Teknium
parent d029575407
commit 9247f4e1a8
2 changed files with 32 additions and 2 deletions
@@ -4,7 +4,8 @@ import type { SessionInfo } from '@/hermes'
import { sameCronSignature, sessionMessagesSignature } from './session-signatures'
const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo
const session = (id: string, title: string | null, extra: Partial<SessionInfo> = {}): SessionInfo =>
({ id, title, ...extra }) as SessionInfo
describe('sameCronSignature', () => {
it('is false when the lengths differ', () => {
@@ -28,6 +29,28 @@ describe('sameCronSignature', () => {
const b = [session('b', 't'), session('a', 't')]
expect(sameCronSignature(a, b)).toBe(false)
})
// A pin-only page must reach $sessions: session-pin-sync treats the row as
// authoritative and releases its write guard when a page confirms the value
// it wrote. Gating that page out froze the row and re-pinned what the user
// had just unpinned (#76919).
it('is false when only the pinned flag changed', () => {
const a = [session('a', 't', { pinned: true })]
const b = [session('a', 't', { pinned: false })]
expect(sameCronSignature(a, b)).toBe(false)
})
it('is false when only the archived flag changed', () => {
const a = [session('a', 't', { archived: false })]
const b = [session('a', 't', { archived: true })]
expect(sameCronSignature(a, b)).toBe(false)
})
it('is true when both flags match', () => {
const a = [session('a', 't', { archived: false, pinned: true })]
const b = [session('a', 't', { archived: false, pinned: true })]
expect(sameCronSignature(a, b)).toBe(true)
})
})
describe('sessionMessagesSignature', () => {
+8 -1
View File
@@ -23,7 +23,14 @@ export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
session.preview === other.preview &&
session.message_count === other.message_count &&
session.last_active === other.last_active &&
session.ended_at === other.ended_at
session.ended_at === other.ended_at &&
// Row STATE, not just row content: session-pin-sync reconciles the
// sidebar's pins against `pinned` on the rows in this atom, so a page
// whose only delta is a flag has to swap in or the reconciler reads a
// frozen copy forever. An idle conversation never moves any of the
// fields above again, which is exactly when a pin gets toggled (#76919).
session.pinned === other.pinned &&
session.archived === other.archived
)
})
}