From 9247f4e1a8cddccdf0990c23efc2e2be7e251ed5 Mon Sep 17 00:00:00 2001 From: james47 <220877172+james47kjv@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:11:52 +0000 Subject: [PATCH] fix(desktop): compare pinned/archived in the session list signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../src/lib/session-signatures.test.ts | 25 ++++++++++++++++++- apps/desktop/src/lib/session-signatures.ts | 9 ++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/session-signatures.test.ts b/apps/desktop/src/lib/session-signatures.test.ts index aeecd19b61..0c4c280c8e 100644 --- a/apps/desktop/src/lib/session-signatures.test.ts +++ b/apps/desktop/src/lib/session-signatures.test.ts @@ -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 => + ({ 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', () => { diff --git a/apps/desktop/src/lib/session-signatures.ts b/apps/desktop/src/lib/session-signatures.ts index 4ef20e1fab..fac4725422 100644 --- a/apps/desktop/src/lib/session-signatures.ts +++ b/apps/desktop/src/lib/session-signatures.ts @@ -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 ) }) }