From 6a272c3ed4bb6b306e109936a8c9a8b1e06ee24f Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 3 Oct 2026 17:17:30 +0800 Subject: [PATCH] fix(desktop): keep Workbar visibility until the first session list lands After a reload, a sessions:changed event for a Session whose turn is still finishing can be read and committed through commitPatch before bootstrapSessions' sessions.list() commits. commitPatch moves the catalog revision off 0, and selectAuthoritativeSessionIds read any revision above 0 as a membership observation, so it published a one-row set. useWorkbarLayoutState then dispatched retain-sessions, which dropped every other Session's right-Workbar visibility (no Session is active yet at that point) and persisted the loss. The Electron test session-workbar.spec.ts:179 caught it as a collapsed Workbar at line 224. The catalog now records whether a list read has committed. The selector and the commitSessions no-op check key on that flag instead of the revision, so a first list that only repeats rows patches already admitted still counts as the observation. Row patches can precede the list since #5532. Generated-by: Claude Opus 5.5 --- .../src/main/__tests__/workbar-model.test.ts | 33 +++++++++++++++++++ .../session-catalog/session-catalog-state.ts | 18 +++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/__tests__/workbar-model.test.ts b/apps/desktop/src/main/__tests__/workbar-model.test.ts index e33c1084371..847e37ebaa4 100644 --- a/apps/desktop/src/main/__tests__/workbar-model.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-model.test.ts @@ -19,6 +19,7 @@ import { createSessionCatalogController, selectAuthoritativeSessionIds } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { sessionIdSetsEqual } from '../../renderer/features/conversation/index.js'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import assert from 'node:assert/strict'; import { afterEach, describe, it } from 'node:test'; import { @@ -386,6 +387,38 @@ describe('Workbar topology', () => { assert.equal(sessionIdSetsEqual(empty, new Set()), true); }); + it('keeps other Sessions\' visibility when a row patch lands before the first list', () => { + cleanups.push(installMemoryLocalStorage({ + 'maka-session-workbar-collapsed-v2': JSON.stringify({ owner: false, running: false }), + })); + const row = (id: string) => + ({ id, name: id, activityAt: 1, isArchived: false, revision: 1 }) as DesktopSessionSummary; + const catalog = createSessionCatalogController(); + let state = loadWorkbarLayout(undefined); + // Mirrors useWorkbarLayoutState: retain whenever the catalog is authoritative. + const retain = () => { + const sessionIds = selectAuthoritativeSessionIds(catalog.getState()); + if (sessionIds) state = reduceWorkbarLayout(state, { type: 'retain-sessions', sessionIds }); + }; + // After a reload, a still-running Session's change event can be read + // before the startup list; that row alone says nothing about the others. + catalog.commitPatch('running', row('running')); + retain(); + assert.equal(selectAuthoritativeSessionIds(catalog.getState()), undefined); + catalog.commitSessions([row('owner'), row('running')]); + retain(); + persistWorkbarLayout(state, 'right-visibility'); + assert.equal(isSessionWorkbarCollapsed(loadWorkbarLayout('owner')), false); + }); + + it('treats a first list that matches the patched rows as authoritative', () => { + const row = { id: 'a', name: 'a', activityAt: 1, isArchived: false, revision: 1 } as DesktopSessionSummary; + const catalog = createSessionCatalogController(); + catalog.commitPatch('a', row); + catalog.commitSessions([row]); + assert.deepEqual(selectAuthoritativeSessionIds(catalog.getState()), new Set(['a'])); + }); + it('evicts deleted Sessions without dropping an active Session awaiting catalog hydration', () => { cleanups.push(installMemoryLocalStorage({ 'maka-session-workbar-collapsed-v2': JSON.stringify({ a: false, b: false, deleted: false }), diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts index ee9b2148a69..bb1c84271a0 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts @@ -44,6 +44,11 @@ import { createObservableState } from './observable-state.js'; export interface SessionCatalogState { readonly sessions: readonly DesktopSessionSummary[]; readonly revision: number; + /** + * A list read has committed. A row patch also moves `revision`, but it can + * only vouch for its own row, so membership readers wait for a list. + */ + readonly listed: boolean; readonly activeSessionId: string | undefined; readonly automaticQueryBlockedSessionIds: ReadonlySet; /** @@ -76,6 +81,7 @@ export function createSessionCatalogController(source: SessionCatalogSource = DE const state = createObservableState({ sessions: [], revision: 0, + listed: false, activeSessionId: undefined, automaticQueryBlockedSessionIds: new Set(), removedIds: new Set(), @@ -169,14 +175,15 @@ export function createSessionCatalogController(source: SessionCatalogSource = DE const sameRows = sessions.length === current.sessions.length && sessions.every((s, i) => s === current.sessions[i]); // A commit that changed nothing publishes nothing — except the first - // one: revision 0 means "no authoritative observation yet", and even an - // empty list is one. - if (sameRows && removedIds === current.removedIds && current.revision > 0) return; + // list: until one lands there is no authoritative observation, and even + // an empty list, or one matching the rows patches admitted, is one. + if (sameRows && removedIds === current.removedIds && current.listed) return; state.replaceState({ ...current, sessions: sameRows ? current.sessions : sessions, removedIds, revision: current.revision + 1, + listed: true, }); }, commitPatch(sessionId: string, summary: DesktopSessionSummary | null): void { @@ -303,8 +310,9 @@ export const selectActiveSessionId = (state: SessionCatalogState): string | unde export const selectAuthoritativeSessionIds = ( state: SessionCatalogState, ): ReadonlySet | undefined => - // The initial empty catalog cannot prove that persisted Sessions were deleted. - state.revision > 0 ? new Set(state.sessions.map(({ id }) => id)) : undefined; + // Neither the initial empty catalog nor rows admitted by targeted patches + // before the first list can prove that persisted Sessions were deleted. + state.listed ? new Set(state.sessions.map(({ id }) => id)) : undefined; /** * The shell's catalog instance, mounted once above the feature services.