From 21b37a9818b741c7db1b6068b1e8a2caa2b0d363 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 21:59:54 +0800 Subject: [PATCH 01/14] perf(desktop): commit the session catalog at the granularity of change sessions:changed already carries the changed row's id, but the shell answered every hint with a full sessions.list() and committed fresh row objects, so one background session's event stream invalidated every row reference and re-rendered the whole AppShell tree each commit (#5441). - Add sessions.get (host session.catalog.query{kind:'get'} -> IPC -> preload) with the same runningTurnIds merge and pendingCleanup filter as sessions.list. - handleSessionChange folds same-id hints into one sessions.get per row; a failed row read falls back to a deduped full refresh instead of evicting the row. Membership changes still take the full-list path. - commitSessions reconciles by id and commitPatch upserts one row: published references change iff values change, and a stale snapshot never regresses a row patched to a newer revision. - AppShell subscribes at the granularity it displays (count, active row, membership set, the two draft rows); the rail, archive/tasks pages, turn-request inbox, palette and setting-intent read the catalog where they consume it instead of through a shell-carried sessions array. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../app-shell-first-send-cleanup.test.ts | 4 +- .../import-tasks-settings-page.test.ts | 3 +- .../session-navigation-controller.test.ts | 87 +++++++++---- .../__tests__/session-setting-intent.test.ts | 21 ++-- .../session-settings-controller.test.ts | 47 +++---- .../runtime-host-session-catalog-ipc-main.ts | 12 ++ apps/desktop/src/preload/bridge-contract.d.ts | 1 + apps/desktop/src/preload/preload.ts | 15 +++ .../preload/runtime-host-session-catalog.ts | 24 ++-- .../src/renderer/app-shell-command-actions.ts | 13 +- .../desktop/src/renderer/app-shell-effects.ts | 6 +- apps/desktop/src/renderer/app-shell.tsx | 117 +++++++++++++----- .../src/renderer/command-palette-commands.ts | 2 +- .../use-app-shell-session-ui-state.ts | 22 +++- .../session-bundle/session-bundle-tasks.tsx | 9 +- .../turn-request-inbox-context.tsx | 25 +++- .../use-session-navigation-reads.ts | 107 +++++++++++----- .../features/session-navigation/index.ts | 1 + .../features/session-navigation/testing.ts | 1 + .../ui/session-navigation-provider.tsx | 26 +++- .../use-session-setting-intent.ts | 20 +-- .../tools/side-chat/use-quote-companion.ts | 16 ++- .../src/renderer/session-catalog-state.ts | 74 ++++++++++- .../renderer/settings/settings-surface.tsx | 2 +- .../renderer/settings/tasks-settings-page.tsx | 12 +- apps/desktop/src/renderer/stale-sessions.ts | 9 ++ .../renderer/use-app-shell-session-list.ts | 66 ++++++++-- .../use-app-shell-session-workspace.ts | 2 +- .../src/shared/desktop-session-projection.ts | 16 +++ .../settings/settings-pages.stories.tsx | 55 ++++---- packages/ui/src/components.tsx | 2 +- .../ui/src/session-setting-intent.test.ts | 64 +++++++--- packages/ui/src/session-setting-intent.ts | 27 ++-- 33 files changed, 683 insertions(+), 225 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 6931544b17..c14affd456 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -39,6 +39,7 @@ import { act, createElement } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; +import { createSessionCatalogController } from '../../renderer/session-catalog-state.js'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; @@ -713,9 +714,10 @@ describe('composer first-send cleanup', () => { } as unknown as DesktopTranscriptRangeController; const { root } = installReactRenderer(); let publication!: ReturnType['publication']; + const catalog = createSessionCatalogController(); function Probe(): null { publication = useAppShellSessionUiState( - [], undefined, deps.activeIdRef, + catalog, undefined, deps.activeIdRef, (_sessionId, _messages, _controller: DesktopTranscriptRangeController) => true, ).publication; return null; diff --git a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts index 0776e81cf0..c62ed2faea 100644 --- a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts @@ -30,6 +30,7 @@ import { SessionBundleServicesProvider, SessionBundleTasks, } from '../../renderer/features/session-bundle/index.js'; +import { createSessionCatalogController } from '../../renderer/session-catalog-state.js'; import { ImportTasksSettingsPage } from '../../renderer/settings/import-tasks-settings-page.js'; import { RuntimeHostSettingsTarget } from '../../renderer/settings/runtime-host-settings-target.js'; @@ -911,7 +912,7 @@ async function renderPage(options: { // own is a composition production never has. const page = createElement(SessionBundleTasks, { isLocalTarget: options.offersBundleSource === true, - sessions: [], + catalog: createSessionCatalogController(), renderSection: ({ children }: { children: ReactNode }) => createElement('div', null, children), children: bare, diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index ce3329f6c3..4745d9271f 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -21,13 +21,14 @@ import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; import { act, createElement } from 'react'; import type { ProjectRecord } from '@maka/core/project'; -import { LocaleProvider } from '@maka/ui'; +import { LocaleProvider, useSessionRailData, type SessionRailData } from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeSessionNavigationServices, createSessionOpenCommand, deriveSessionRail, sessionMatchesRail, + SessionNavigationProvider, SessionNavigationServicesProvider, useSessionNavigationController, useSessionNavigationReads, @@ -36,11 +37,13 @@ import { type SessionNavigationSession, type UseSessionNavigationControllerInput, } from '../../renderer/features/session-navigation/testing.js'; +import { createSessionCatalogController } from '../../renderer/session-catalog-state.js'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; function session( id: string, - overrides: Partial = {}, -): SessionNavigationSession { + overrides: Partial = {}, +): DesktopSessionSummary { return { id, name: id, @@ -57,6 +60,8 @@ function session( profileId: 'local', profileName: 'Local', profileKind: 'local', + revision: 0, + activityAt: 0, runtimeHostId: 'local-host', ...overrides, }; @@ -69,6 +74,20 @@ const project: ProjectRecord = { available: true, }; +const localProjectScope = { + key: JSON.stringify(['local-host', project.id]), + profileId: 'local', + hostId: 'local-host', + profileName: 'Local', + profileKind: 'local' as const, + project, + capabilities: { + chooseClientDirectory: true, + chooseHostDirectory: false, + selectNoProject: true, + }, +}; + const hiddenSessionIds = new Set(['hidden']); const fakeServices = createFakeSessionNavigationServices(); @@ -132,19 +151,7 @@ function input( (candidate) => !hiddenSessionIds.has(candidate.id) && sessionMatchesRail(candidate), ), projectScopes: [ - { - key: JSON.stringify(['local-host', project.id]), - profileId: 'local', - hostId: 'local-host', - profileName: 'Local', - profileKind: 'local', - project, - capabilities: { - chooseClientDirectory: true, - chooseHostDirectory: false, - selectNoProject: true, - }, - }, + localProjectScope, ...sessions .filter((session) => session.profileKind !== 'local') .map((session) => ({ @@ -251,39 +258,69 @@ describe('useSessionNavigationController', () => { describe('useSessionNavigationReads', () => { let latestReads: ReturnType | undefined; + let latestRail: SessionRailData | undefined; function ReadsProbe(props: Parameters[0]) { latestReads = useSessionNavigationReads(props); return null; } + function RailProbe() { + latestRail = useSessionRailData(); + return null; + } + afterEach(() => { latestReads = undefined; + latestRail = undefined; }); it('projects linked, archived, hidden, side-conversation, Project, and Runtime Host Sessions once', async () => { const { root } = installReactRenderer(); + const catalog = createSessionCatalogController(); + catalog.commitSessions(linkedCatalog); await act(async () => root.render( createElement(LocaleProvider, { locale: 'en', - children: createElement(ReadsProbe, { - sessions: linkedCatalog, - activeSessionId: 'child', - activeSession: linkedCatalog[1], - hiddenSessionIds, - }), + children: createElement( + SessionNavigationServicesProvider, + { services: fakeServices }, + createElement(ReadsProbe, { catalog, activeSessionId: 'child' }), + createElement( + SessionNavigationProvider, + { + catalog, + activeSessionId: 'child', + hiddenSessionIds, + projectScopes: [localProjectScope], + streamingSessionIds: new Set(), + staleSessionIds: new Set(), + ports: ports(linkedCatalog, 'child'), + commandsRef: { current: null }, + selection: { section: 'sessions' }, + workHubActive: false, + onSelect: () => undefined, + onOpenSettings: () => undefined, + onNew: () => undefined, + onExitWorkHub: () => undefined, + onSelectSession: () => undefined, + }, + createElement(RailProbe), + ), + ), }), ), ); assert.ok(latestReads); + assert.ok(latestRail); assert.deepEqual( - latestReads.rail.sessions.map(({ id }) => id), + latestRail.sessions.map(({ id }) => id), ['root', 'remote', 'environment'], ); - assert.equal(latestReads.rail.activeRowId, 'root'); - assert.equal(latestReads.rail.activeParentSession?.id, 'root'); + assert.equal(latestRail.activeId, 'root'); + assert.equal(latestReads.activeParentSession?.id, 'root'); assert.deepEqual(latestReads.branchBanner, { parentSessionId: 'root', parentSessionName: 'root', diff --git a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts index 562bdc5d8d..05386b45de 100644 --- a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts @@ -22,7 +22,8 @@ import { afterEach, test } from 'node:test'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import { useSessionSettingIntent } from '@maka/ui'; +import { useSessionSettingIntent, type SessionSettingIntentCatalog } from '@maka/ui'; +import { createObservableState } from '../../renderer/observable-state.js'; type SessionSettingIntentController = ReturnType< typeof useSessionSettingIntent<{ setting: Value }> @@ -68,10 +69,16 @@ test('Runtime leaving Plan after approval supersedes the committed Plan overlay' mountedRoot = root; let controller: SessionSettingIntentController | undefined; - const render = async (catalogRevision: number, catalogValue: boolean) => { + const catalogRevision = createObservableState(0); + const catalog: SessionSettingIntentCatalog = { + revision: catalogRevision.getState, + subscribeChanged: catalogRevision.subscribe, + }; + const render = async (revision: number, catalogValue: boolean) => { await act(async () => { + catalogRevision.replaceState(revision); root.render(createElement(Harness, { - catalogRevision, + catalog, catalogValue, capture: (next) => { controller = next; @@ -156,16 +163,16 @@ test('rapid requests share the worker and settle only after the latest value com }); function Harness({ - catalogRevision, + catalog, catalogValue, capture, }: { - catalogRevision: number; + catalog: SessionSettingIntentCatalog; catalogValue: boolean; capture(controller: SessionSettingIntentController): void; }) { const controller = useSessionSettingIntent<{ setting: boolean }>({ - catalogRevision, + catalog, refreshCatalog: async () => { throw new Error('catalog unavailable'); }, @@ -190,7 +197,7 @@ function LatestIntentHarness({ write(sessionId: string, value: string): Promise; }) { const controller = useSessionSettingIntent<{ setting: string }>({ - catalogRevision: 0, + catalog: { revision: () => 0, subscribeChanged: () => () => {} }, refreshCatalog: async () => {}, channels: { setting: { diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts index ce3175b4f4..e6f9f077ca 100644 --- a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -28,6 +28,10 @@ import { useSessionSettingIntent, } from '../../renderer/features/session-settings/index.js'; import { reconcileRuntimeHostSessionCatalog } from '../../preload/runtime-host-session-catalog.js'; +import { + createSessionCatalogController, + type SessionCatalogController, +} from '../../renderer/session-catalog-state.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; type Controller = ReturnType>; @@ -228,8 +232,10 @@ test('retains the Model overlay while a partial Host catalog still has the prior model: 'model-c', }; let controller: Controller | undefined; - const render = async (catalogRevision: number, sessions: readonly DesktopSessionSummary[]) => { + const catalog = createSessionCatalogController(); + const render = async (sessions: readonly DesktopSessionSummary[]) => { await act(async () => { + catalog.commitSessions(sessions); root.render(createElement( SessionSettingsServicesProvider, { @@ -241,14 +247,13 @@ test('retains the Model overlay while a partial Host catalog still has the prior capture: (next) => { controller = next; }, - catalogRevision, - sessions, + catalog, }), )); }); }; - await render(0, [targetBeforeWrite, otherHostSession]); + await render([targetBeforeWrite, otherHostSession]); await act(async () => { assert.equal(await controller!.setSessionModel('session-a', { llmConnectionId: 'connection-c', @@ -270,7 +275,7 @@ test('retains the Model overlay while a partial Host catalog still has the prior ); assert.equal(partialCatalog.find((session) => session.id === 'session-a')?.revision, 1); assert.equal(partialCatalog.find((session) => session.id === 'session-b')?.revision, 2); - await render(1, partialCatalog); + await render(partialCatalog); assert.equal(controller!.overlays.modelConfiguration['session-a']?.modelTarget.model, 'model-c'); const caughtUpCatalog = reconcileRuntimeHostSessionCatalog(partialCatalog, { @@ -282,7 +287,7 @@ test('retains the Model overlay while a partial Host catalog still has the prior knownOwnerProfileIds: ['profile-a', 'profile-b'], }); assert.equal(caughtUpCatalog.find((session) => session.id === 'session-a')?.revision, 2); - await render(2, caughtUpCatalog); + await render(caughtUpCatalog); assert.equal(controller!.overlays.modelConfiguration['session-a'], undefined); }); @@ -331,8 +336,10 @@ test('retires Permission and Orchestration overlays by their committed Session r orchestrationMode: 'swarm' as const, }; let controller: Controller | undefined; - const render = async (catalogRevision: number, sessions: readonly DesktopSessionSummary[]) => { + const catalog = createSessionCatalogController(); + const render = async (sessions: readonly DesktopSessionSummary[]) => { await act(async () => { + catalog.commitSessions(sessions); root.render(createElement( SessionSettingsServicesProvider, { @@ -345,14 +352,13 @@ test('retires Permission and Orchestration overlays by their committed Session r capture: (next) => { controller = next; }, - catalogRevision, - sessions, + catalog, }), )); }); }; - await render(0, [targetBeforeWrite, otherHostSession]); + await render([targetBeforeWrite, otherHostSession]); await act(async () => { assert.equal(await controller!.setPermissionMode('bypass'), true); assert.equal(await controller!.setOrchestrationMode('session-a', 'swarm'), true); @@ -368,15 +374,15 @@ test('retires Permission and Orchestration overlays by their committed Session r knownOwnerProfileIds: ['profile-a', 'profile-b'], }, ); - await render(1, partialCatalog); + await render(partialCatalog); assert.equal(controller!.overlays.permissionMode['session-a'], 'bypass'); assert.equal(controller!.overlays.orchestrationMode['session-a'], 'swarm'); - await render(2, [targetAfterPermission, otherHostSession]); + await render([targetAfterPermission, otherHostSession]); assert.equal(controller!.overlays.permissionMode['session-a'], undefined); assert.equal(controller!.overlays.orchestrationMode['session-a'], 'swarm'); - await render(3, [targetAfterOrchestration, otherHostSession]); + await render([targetAfterOrchestration, otherHostSession]); assert.equal(controller!.overlays.orchestrationMode['session-a'], undefined); }); @@ -433,6 +439,8 @@ async function mountController(overrides: { const root = createRoot(container); mountedRoot = root; let captured: Controller | undefined; + const catalog = createSessionCatalogController(); + catalog.commitSessions(overrides.sessions ?? []); await act(async () => { root.render(createElement( @@ -443,7 +451,7 @@ async function mountController(overrides: { captured = controller; }, owner: overrides.owner ?? {}, - sessions: overrides.sessions ?? [], + catalog, setNewTaskPermissionMode: overrides.setNewTaskPermissionMode ?? (() => {}), confirmBypass: overrides.confirmBypass ?? (async () => true), saveComposerDefaults: overrides.saveComposerDefaults ?? (() => {}), @@ -462,7 +470,7 @@ async function mountController(overrides: { function Harness(props: { capture(controller: Controller): void; owner: { sessionId?: string }; - sessions: readonly DesktopSessionSummary[]; + catalog: SessionCatalogController; setNewTaskPermissionMode(mode: 'ask' | 'bypass'): void; confirmBypass(): Promise; saveComposerDefaults(model: { @@ -472,9 +480,8 @@ function Harness(props: { }): void; }) { const controller = useSessionSettingIntent({ - catalogRevision: 0, + catalog: props.catalog, isActiveSession: () => true, - sessions: props.sessions, newSessionPermissionMode: 'ask', refreshCatalog: async () => {}, saveComposerDefaults: props.saveComposerDefaults, @@ -492,13 +499,11 @@ function Harness(props: { function CausalRetirementHarness(props: { capture(controller: Controller): void; - catalogRevision: number; - sessions: readonly DesktopSessionSummary[]; + catalog: SessionCatalogController; }) { const controller = useSessionSettingIntent({ - catalogRevision: props.catalogRevision, + catalog: props.catalog, isActiveSession: () => true, - sessions: props.sessions, newSessionPermissionMode: 'ask', refreshCatalog: async () => {}, saveComposerDefaults: () => {}, diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index b0a67aea14..cf8d450af3 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -55,6 +55,7 @@ import { type RuntimeHostSessionCatalogClient = Pick< DesktopRuntimeHostClient, | 'createSession' + | 'getSession' | 'listSessions' | 'previewSessionRemoval' | 'removeSession' @@ -115,6 +116,17 @@ export function registerRuntimeHostSessionCatalogIpc( handleReconnectableRead(ipcMain, 'sessions:list', (_event, filter?: unknown) => listSessions(normalizeSessionListFilter(filter)), ); + handleReconnectableRead(ipcMain, 'sessions:get', async (_event, sessionId: unknown) => { + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new Error('Invalid Session id'); + } + await recoveryTask; + if (pendingCleanup.has(sessionId)) return null; + const session = await deps.client.getSession(sessionId); + return session === null + ? null + : toDesktopHostSessionListSummary(session, deps.runningTurnIds(sessionId)); + }); ipcMain.handle('sessions:cleanupSessionCopy', async (_event, sessionId: string) => { await deps.sessionCopyCleanup.cleanup(sessionId); pendingCleanup.delete(sessionId); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 2cddb6b9b8..35749c7814 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1123,6 +1123,7 @@ export interface MakaBridge { }; sessions: { list(filter?: SessionListFilter): Promise; + get(sessionId: string): Promise; listWithCoverage(): Promise<{ sessions: DesktopSessionSummary[]; completeHostIds: string[]; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fc42c7d750..c9bb44ff59 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2139,6 +2139,21 @@ const makaBridge = { list(filter?: SessionListFilter): Promise { return listDesktopSessions(filter); }, + async get(sessionId: string): Promise { + const session = await runtimeHostSessionRef(sessionId); + const summary = await ipcRenderer.invoke( + 'sessions:get', + session.scope, + session.sessionId, + ) as DesktopSessionSummaryInput | null; + if (summary === null) { + desktopSessionCatalogRefresher.evict(sessionId); + return null; + } + const projected = projectSessionSummary(session.scope, summary); + desktopSessionCatalogRefresher.admit(projected); + return projected; + }, listWithCoverage() { return desktopSessionCatalogRefresher.refresh(); }, diff --git a/apps/desktop/src/preload/runtime-host-session-catalog.ts b/apps/desktop/src/preload/runtime-host-session-catalog.ts index 151cab16cd..2659e2685f 100644 --- a/apps/desktop/src/preload/runtime-host-session-catalog.ts +++ b/apps/desktop/src/preload/runtime-host-session-catalog.ts @@ -17,7 +17,10 @@ * under the License. */ -import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; +import { + compareDesktopSessionCatalogSummaries, + type DesktopSessionSummary, +} from '../shared/desktop-session-projection.js'; export interface RuntimeHostSessionCatalogRequest { readonly hostId: string; @@ -41,6 +44,8 @@ export interface RuntimeHostSessionCatalogRefresher { refresh(): Promise; /** Commit a newly created Session and fence any catalog read started before it. */ admit(session: DesktopSessionSummary): void; + /** Commit a Session removal and fence any catalog read started before it. */ + evict(sessionId: string): void; /** Begin an asynchronous bootstrap read whose result may later seed the catalog. */ beginSeed(): { commit(catalog: RuntimeHostSessionCatalogCoverage): boolean; @@ -98,6 +103,14 @@ export function createRuntimeHostSessionCatalogRefresher(input: { ]), }); }, + evict(sessionId) { + dirty = true; + const current = input.currentCatalog(); + commitCatalog({ + ...current, + sessions: current.sessions.filter(({ id }) => id !== sessionId), + }); + }, beginSeed() { const admittedCatalogGeneration = catalogGeneration; return { @@ -209,12 +222,5 @@ function sortSessionCatalogs(sessions: DesktopSessionSummary[]): DesktopSessionS unique.set(session.id, session); } } - return [...unique.values()].sort((left, right) => { - const leftActivity = left.localState === 'pending' ? left.localCreatedAt : left.activityAt; - const rightActivity = right.localState === 'pending' ? right.localCreatedAt : right.activityAt; - if (leftActivity === undefined || rightActivity === undefined) { - throw new Error('Runtime Host Session Catalog activity is unavailable'); - } - return rightActivity - leftActivity || left.id.localeCompare(right.id); - }); + return [...unique.values()].sort(compareDesktopSessionCatalogSummaries); } diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index a009b19a3f..8c9c3a4c22 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -35,6 +35,7 @@ import { buildSessionCommands, } from "./command-palette-commands.js"; import type { Command } from './features/overlays/index.js'; +import type { SessionCatalogController } from './session-catalog-state.js'; import { renderConversationMarkdown } from "./conversation-markdown.js"; import { commandPaletteActionErrorMessage, @@ -75,9 +76,9 @@ export interface AppShellCommandListOptions { newTaskProfileId: string | undefined; settingsOpen: boolean; settingsProfileId: string | undefined; - sessions: readonly SessionSummary[]; + sessionCatalog: SessionCatalogController; themePref: ThemePreference; - visibleSessions: SessionSummary[]; + visibleSessions: readonly SessionSummary[]; captureComposerImportOwner: () => ComposerImportOwner; createSession: () => void; openSideConversation: () => void; @@ -224,9 +225,9 @@ export function buildAppShellCommandList( optionsRef.current.setNavSelection(selection); }, onExportActiveConversation: async () => { - const { activeId, messages, sessions, toastApi } = optionsRef.current; + const { activeId, messages, sessionCatalog, toastApi } = optionsRef.current; if (!activeId) return; - const session = sessions.find((s) => s.id === activeId); + const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); const markdown = renderConversationMarkdown( session?.name ?? copy.newConversation, messages, @@ -243,9 +244,9 @@ export function buildAppShellCommandList( } }, onSaveActiveConversationToFile: async () => { - const { activeId, messages, sessions, toastApi } = optionsRef.current; + const { activeId, messages, sessionCatalog, toastApi } = optionsRef.current; if (!activeId) return; - const session = sessions.find((s) => s.id === activeId); + const session = sessionCatalog.getState().sessions.find((s) => s.id === activeId); const sessionName = session?.name ?? copy.newConversation; const markdown = renderConversationMarkdown( sessionName, diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index e309fa4581..93bd0effbb 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -161,6 +161,7 @@ export function useAppShellBootstrapSubscriptions(options: { refreshProjects: () => Promise; refreshShellSettings: () => Promise; refreshSessions: () => Promise; + refreshChangedSession: (sessionId: string) => Promise; rendererMountedRef: RefBox; retireSession: (sessionId: string) => void; retiredSessionIds(sessions: readonly { id: string }[]): string[]; @@ -198,7 +199,10 @@ export function useAppShellBootstrapSubscriptions(options: { }); const handleSessionChange = useEffectEvent( (event: SessionChangedEvent) => { - const refreshedSessions = options.refreshSessions(); + const refreshedSessions: Promise = event.sessionId === undefined + ? options.refreshSessions() + : options.refreshChangedSession(event.sessionId).then((session) => + session === null ? [] : [session]); if (event.reason === 'archived' && event.sessionId) options.retireSession(event.sessionId); if (event.reason === 'created' || event.reason === 'migrated') { void options.refreshProjects(); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 22ec2bf498..537c57699b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -79,11 +79,19 @@ import * as ModuleHub from './features/module-hub'; import { SessionNavigationProvider, createSessionOpenCommand, + sessionMatchesRail, sessionRailLayoutStore, useSessionNavigationReads, type SessionNavigationPorts, type SessionNavigationRowActions, } from './features/session-navigation'; +import { + selectSessionById, + selectSessionCount, + selectSessions, + type SessionCatalogState, +} from './session-catalog-state'; +import { useExternalStoreSelector } from './use-external-store-selector'; import * as TaskEntry from './features/task-entry'; import type { TaskEntryShellProjection } from './features/task-entry'; import * as Overlays from './features/overlays/index.js'; @@ -124,7 +132,7 @@ import { getDesktopConversationCopy } from './locales/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useSessionSettingIntent } from './features/session-settings'; -import { deriveStaleSessionIds } from './stale-sessions'; +import { selectStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; import { readScrollMotionBehavior } from './scroll-motion-policy'; @@ -212,6 +220,27 @@ type ComposerImportOwner = { */ const SETTLE_FALLBACK_GRACE_MS = 1000; const { useSessionCollaborationDialog } = SessionCollaboration; + +/** The command palette lists the rail's membership minus its hidden rows. */ +const selectPaletteSessions = ( + state: SessionCatalogState, + hiddenSessionIds: ReadonlySet, +) => + state.sessions.filter( + (session) => sessionMatchesRail(session) && !hiddenSessionIds.has(session.id), + ); + +/** A palette command shows id, name and the flag glyph; nothing else re-lists it. */ +function paletteSessionsEqual( + left: readonly DesktopSessionSummary[], + right: readonly DesktopSessionSummary[], +): boolean { + return left.length === right.length + && left.every((session, index) => + session.id === right[index]!.id + && session.name === right[index]!.name + && session.isFlagged === right[index]!.isFlagged); +} type AppShellProps = { /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ initialOnboardingSnapshot?: OnboardingSnapshot | null; @@ -300,11 +329,10 @@ function AppShellContent({ const sharedSessionDialog = useSessionCollaborationDialog(); const previousInterruptionShownRef = useRef(false); const { - sessions, - catalogRevision, authoritativeSessionIds, sessionsRef, refreshSessions, + refreshChangedSession, seedSessions, activeId, activeIdRef, @@ -332,6 +360,7 @@ function AppShellContent({ messageLoadPending, setMessageLoadPending, sessionUiController, + sessionCatalogController, activeCatalogSession, activeHostSession, requestedCatalogSession, @@ -340,6 +369,10 @@ function AppShellContent({ ownerActiveId, switchingSession, } = useAppShellSessionWorkspace(toastApi); + // The shell's own readings of the catalog, at the granularity it displays — + // background row churn belongs to the rail, which subscribes the catalog + // inside SessionNavigationProvider (#4109). + const sessionCount = useExternalStoreSelector(sessionCatalogController, selectSessionCount); // Only the outstanding read needs a fence; past Sessions leave no hydration metadata. const interactionHydrationRef = useRef<{ sessionId: string } | null>(null); const markInteractionChanged = useCallback((sessionId: string) => { @@ -537,7 +570,7 @@ function AppShellContent({ const onboardingSettled = hasSettledInitialOnboarding(onboarding.snapshot?.milestones ?? []); const onboardingActivationCandidate = getOnboardingActivationCandidate( onboarding.snapshot, - sessions.length > 0, + sessionCount > 0, ); const { themePref, @@ -615,19 +648,31 @@ function AppShellContent({ revisionDraftRef.current = draft; setRevisionDraft(draft); }, []); + // The draft survives on exactly two rows; only their changes can retire it. + const revisionDraftSource = useExternalStoreSelector( + sessionCatalogController, + selectSessionById, + revisionDraft?.sourceSessionId, + ); + const revisionDraftOwner = useExternalStoreSelector( + sessionCatalogController, + selectSessionById, + revisionDraft?.draftSessionId, + ); useEffect(() => { const draft = revisionDraftRef.current; if (!draft) return; - const source = sessions.find((session) => session.id === draft.sourceSessionId); - const owner = sessions.find((session) => session.id === draft.draftSessionId); - if (source && owner && !source.isArchived && !owner.isArchived) return; + if ( + revisionDraftSource && revisionDraftOwner + && !revisionDraftSource.isArchived && !revisionDraftOwner.isArchived + ) return; composerRef.current?.clearDraft(draft.draftSessionId); if (draft.sourceSessionId !== draft.draftSessionId) composerRef.current?.clearDraft(draft.sourceSessionId); if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); else void abandonTurnRevisionCopyAttempt(draft); commitRevisionDraft(null); - }, [sessions, commitRevisionDraft]); + }, [revisionDraftSource, revisionDraftOwner, commitRevisionDraft]); const { resumePendingSessionId, @@ -639,20 +684,17 @@ function AppShellContent({ // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat // header banner). Derivation is pure (see `stale-sessions.ts`) so the // classifier is testable without a DOM. - const staleSessionIds = useMemo( - () => - deriveStaleSessionIds({ - sessions, - sendOutcomes: onboarding.snapshot?.sessionSendOutcomes ?? {}, - }), - [sessions, onboarding.snapshot?.sessionSendOutcomes], + const staleSessionIds = useExternalStoreSelector( + sessionCatalogController, + selectStaleSessionIds, + onboarding.snapshot?.sessionSendOutcomes, + Conversation.sessionIdSetsEqual, ); const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); const activeSession = activeCatalogSession; const sessionSettingIntent = useSessionSettingIntent({ - catalogRevision, + catalog: sessionCatalogController, isActiveSession: (sessionId) => activeIdRef.current === sessionId, - sessions, newSessionPermissionMode, refreshCatalog: refreshSessions, saveComposerDefaults: (model) => saveComposerDefaults({ model }), @@ -1072,12 +1114,12 @@ function AppShellContent({ // while the initial snapshot is in flight. Otherwise sessions.length===0 // + snapshot===null flashes the prompt-suggestion EmptyChatHero before // the state-routed OnboardingHero mounts. - const isOnboardingLoading = sessions.length === 0 && onboardingState === undefined && !onboardingSettled; + const isOnboardingLoading = sessionCount === 0 && onboardingState === undefined && !onboardingSettled; // Only unfinished setup takes the chat surface over. A configured user with // no sessions is not onboarding: they land on the normal empty chat and use // the one real Composer, which creates the session on its first send. const showOnboardingHero = - sessions.length === 0 && + sessionCount === 0 && !onboardingSettled && onboardingState !== undefined && onboardingState.kind !== 'ready_with_history' && @@ -1328,32 +1370,36 @@ function AppShellContent({ toastApi, }; const { - rail: sessionRail, branchBanner, revisionNavigation, + activeParentSession, layout: railLayout, } = useSessionNavigationReads({ - sessions, + catalog: sessionCatalogController, activeSessionId: activeId, - activeSession, - hiddenSessionIds: selectors.hiddenSessionIds, }); - const visibleSessions = sessionRail.sessions; + // The palette's 会话 rows: rail membership minus what the rail itself hides, + // re-rendered only when a field a command displays actually changes. + const visibleSessions = useExternalStoreSelector( + sessionCatalogController, + selectPaletteSessions, + selectors.hiddenSessionIds, + paletteSessionsEqual, + ); const sessionListCollapsed = railLayout.collapsed; const sessionListWidth = railLayout.width; const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef; const titlebarParentSession = useMemo(() => { - const parent = sessionRail.activeParentSession; - if (!parent) return undefined; - const parentId = parent.id; + if (!activeParentSession) return undefined; + const parentId = activeParentSession.id; return { - name: parent.name, + name: activeParentSession.name, onOpen: () => openSessionInChatRef.current(parentId), }; - }, [sessionRail.activeParentSession]); + }, [activeParentSession]); const archivedTasksBridge = useMemo( () => ({ - sessions, + catalog: sessionCatalogController, projects: localProjects, onRestore: (sessionId) => void sessionNavigationCommandsRef.current?.unarchiveSession(sessionId), @@ -1362,7 +1408,7 @@ function AppShellContent({ onPurge: (sessionIds) => sessionNavigationCommandsRef.current!.purgeSessions(sessionIds), }), - [sessions, localProjects], + [sessionCatalogController, localProjects], ); const activateSessionForFirstSend = useCallback((sessionId: string): Promise => { @@ -1879,6 +1925,7 @@ function AppShellContent({ refreshProjects, refreshShellSettings, refreshSessions, + refreshChangedSession, rendererMountedRef, retireSession: clearSessionRendererState, retiredSessionIds, @@ -2120,7 +2167,7 @@ function AppShellContent({ newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, settingsProfileId: overlays.selectors.settings.request.profileId, - sessions, + sessionCatalog: sessionCatalogController, themePref, visibleSessions, captureComposerImportOwner, @@ -2183,7 +2230,7 @@ function AppShellContent({ render={renderComposerMentionsProvider(composerMentionsSurface)} >
( = ( /** The rendered messages and the earlier-history flag are a single publication. */ export function useAppShellSessionUiState< Controller extends { readonly store: TranscriptSource }, - Session extends SessionSummary & { localState?: string; shared?: boolean }, >( - sessions: readonly Session[], + catalog: SessionCatalogController, requestedSessionId: string | undefined, activeIdRef: { current: string | undefined }, commitTranscript: (sessionId: string, messages: StoredMessage[], controller: Controller) => boolean, @@ -90,8 +94,16 @@ export function useAppShellSessionUiState< }, })); - const activeCatalogSession = sessions.find((session) => session.id === view.sessionId); - const requestedCatalogSession = sessions.find((session) => session.id === requestedSessionId); + const activeCatalogSession = useExternalStoreSelector( + catalog, + selectSessionById, + view.sessionId, + ); + const requestedCatalogSession = useExternalStoreSelector( + catalog, + selectSessionById, + requestedSessionId, + ); // Locally staged tasks cannot admit Host reads until creation completes. const activeHostSession = activeCatalogSession?.localState !== 'pending' ? activeCatalogSession : undefined; const requestedHostSession = requestedCatalogSession?.localState !== 'pending' ? requestedCatalogSession : undefined; diff --git a/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx index dc5eb8555f..7ea93ff855 100644 --- a/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx +++ b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx @@ -31,6 +31,8 @@ import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/Segme import { HStack, VStack } from '@astryxdesign/core/Stack'; import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; +import { selectSessions, type SessionCatalogController } from '../../session-catalog-state.js'; +import { useExternalStoreSelector } from '../../use-external-store-selector.js'; import { getExternalSessionImportCopy } from '../../locales/external-session-import-copy.js'; import { getSettingsSharedCopy } from '../../locales/settings-shared-copy.js'; import { ExportTree } from './export-tree.js'; @@ -56,8 +58,8 @@ export function SessionBundleTasks(props: { isLocalTarget: boolean; /** The adapter catalog, rendered when the import half is showing. */ children: ReactNode; - /** Local tasks for the export half. Archived ones are left out here. */ - sessions?: readonly DesktopSessionSummary[]; + /** The shell's session catalog, subscribed for the export half's task list. */ + catalog: SessionCatalogController; /** The settings surface's own section chrome, supplied rather than imported. */ renderSection: (input: { title?: string; @@ -76,7 +78,8 @@ export function SessionBundleTasks(props: { // filesystem. A Guest projection is not ours to carry at all -- a Guest's // Desktop does not even register these channels, and a remote owner is not // granted the operations. - const exportable = (props.sessions ?? []).filter( + const sessions = useExternalStoreSelector(props.catalog, selectSessions); + const exportable = sessions.filter( (session) => session.profileKind === 'local' && session.shared !== true, ); diff --git a/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx index 9a4c34458f..a9555289f6 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx +++ b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx @@ -21,6 +21,22 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; import { useToast, useUiLocale } from '@maka/ui'; import { getSessionCollaborationCopy } from '../../locales/session-collaboration-copy.js'; import { useSessionTurnRequestInbox } from './controller/use-turn-request-inbox.js'; +import type { SessionCatalogController, SessionCatalogState } from '../../session-catalog-state.js'; +import { useExternalStoreSelector } from '../../use-external-store-selector.js'; + +const selectSessionIdNames = ( + state: SessionCatalogState, +): readonly { readonly id: string; readonly name: string }[] => + state.sessions.map(({ id, name }) => ({ id, name })); + +function sessionIdNamesEqual( + left: readonly { readonly id: string; readonly name: string }[], + right: readonly { readonly id: string; readonly name: string }[], +): boolean { + return left.length === right.length + && left.every((session, index) => + session.id === right[index]!.id && session.name === right[index]!.name); +} export interface SessionTurnRequestInboxCopy { readonly sharedTask: string; @@ -45,13 +61,20 @@ type SessionTurnRequestInbox = ReturnType & { const SessionTurnRequestInboxContext = createContext(null); export function SessionTurnRequestInboxProvider(props: { - readonly sessions: readonly { readonly id: string; readonly name: string }[]; + readonly catalog: SessionCatalogController; readonly onOpenSession: (sessionId: string) => void; readonly children?: ReactNode; }) { const copy = getSessionCollaborationCopy(useUiLocale()); + const sessions = useExternalStoreSelector( + props.catalog, + selectSessionIdNames, + undefined, + sessionIdNamesEqual, + ); const inbox = useSessionTurnRequestInbox({ ...props, + sessions, toast: useToast(), copy, }); diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts index a84bc26d96..7ad917040c 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts @@ -17,11 +17,10 @@ * under the License. */ -import { useMemo } from 'react'; import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; +import type { SessionCatalogState } from '../../../session-catalog-state.js'; +import type { SessionCatalogController } from '../../../session-catalog-state.js'; import { deriveBranchBanner, type BranchBanner } from '../model/branch-banner.js'; -import { sessionMatchesRail } from '../model/session-nav-filter.js'; -import { deriveSessionRail, type SessionRailProjection } from '../model/session-rail.js'; import { selectRailLayout, sessionRailLayoutStore, @@ -34,46 +33,96 @@ import { import type { SessionNavigationSession } from '../ports.js'; export interface SessionNavigationReads { - /** The rail's membership, derived once and shared with the command palette. */ - rail: SessionRailProjection; branchBanner: BranchBanner | undefined; revisionNavigation: SessionRevisionNavigation | undefined; + /** The active Session's parent row, for the titlebar breadcrumb. */ + activeParentSession: SessionNavigationSession | undefined; layout: SessionRailLayoutState; } +const selectBranchBanner = ( + state: SessionCatalogState, + activeSessionId: string | undefined, +): BranchBanner | undefined => + deriveBranchBanner( + activeSessionId === undefined + ? undefined + : state.sessions.find((session) => session.id === activeSessionId), + state.sessions, + ); + +const selectRevisionNavigation = ( + state: SessionCatalogState, + activeSessionId: string | undefined, +): SessionRevisionNavigation | undefined => + deriveSessionRevisionNavigation(state.sessions, activeSessionId); + +const selectActiveParentSession = ( + state: SessionCatalogState, + activeSessionId: string | undefined, +): SessionNavigationSession | undefined => { + const parentSessionId = state.sessions.find( + (session) => session.id === activeSessionId, + )?.parentSessionId; + return parentSessionId === undefined + ? undefined + : state.sessions.find((session) => session.id === parentSessionId); +}; + +function branchBannersEqual( + left: BranchBanner | undefined, + right: BranchBanner | undefined, +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return left.parentSessionId === right.parentSessionId + && left.parentSessionName === right.parentSessionName + && left.fromAbortedTurn === right.fromAbortedTurn; +} + +function revisionNavigationsEqual( + left: SessionRevisionNavigation | undefined, + right: SessionRevisionNavigation | undefined, +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return left.current === right.current + && left.total === right.total + && left.previousSessionId === right.previousSessionId + && left.nextSessionId === right.nextSessionId; +} + /** * What the shell reads from Session Navigation, as opposed to what it owns. * - * Nothing here holds state: three `useMemo`s over the catalog the shell already - * has, and one subscription to the rail's geometry — which the window frame - * needs, because `--maka-sidenav-width` is where the titlebar's breadcrumb - * starts. The rail's own state lives under `SessionNavigationProvider` and is - * not visible from here, which is the point of #4109: a hook called in the - * shell's render body has the whole tree as its scope, so the ones that remain - * had better hold nothing. + * Each reading is its own selector with value equality, so a background + * Session's catalog churn re-renders the shell only when the reading the + * shell actually displays changes — the rail itself is not read here at all: + * it subscribes the catalog inside `SessionNavigationProvider`, where the + * churn it displays belongs (#4109). */ export function useSessionNavigationReads(input: { - sessions: readonly SessionNavigationSession[]; + catalog: SessionCatalogController; activeSessionId: string | undefined; - activeSession: SessionNavigationSession | undefined; - hiddenSessionIds: ReadonlySet; }): SessionNavigationReads { - const { activeSession, activeSessionId, hiddenSessionIds, sessions } = input; - const rail = useMemo( - () => - deriveSessionRail(sessions, activeSessionId, (session) => - !hiddenSessionIds.has(session.id) && sessionMatchesRail(session), - ), - [activeSessionId, hiddenSessionIds, sessions], + const { activeSessionId, catalog } = input; + const branchBanner = useExternalStoreSelector( + catalog, + selectBranchBanner, + activeSessionId, + branchBannersEqual, ); - const branchBanner = useMemo( - () => deriveBranchBanner(activeSession, sessions), - [activeSession, sessions], + const revisionNavigation = useExternalStoreSelector( + catalog, + selectRevisionNavigation, + activeSessionId, + revisionNavigationsEqual, ); - const revisionNavigation = useMemo( - () => deriveSessionRevisionNavigation(sessions, activeSessionId), - [activeSessionId, sessions], + const activeParentSession = useExternalStoreSelector( + catalog, + selectActiveParentSession, + activeSessionId, ); const layout = useExternalStoreSelector(sessionRailLayoutStore, selectRailLayout); - return { rail, branchBanner, revisionNavigation, layout }; + return { branchBanner, revisionNavigation, activeParentSession, layout }; } diff --git a/apps/desktop/src/renderer/features/session-navigation/index.ts b/apps/desktop/src/renderer/features/session-navigation/index.ts index 38cadf9fe5..69313296c2 100644 --- a/apps/desktop/src/renderer/features/session-navigation/index.ts +++ b/apps/desktop/src/renderer/features/session-navigation/index.ts @@ -22,6 +22,7 @@ export { SessionNavigationProvider } from './ui/session-navigation-provider.js'; export { createSessionOpenCommand } from './controller/session-open-command.js'; export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js'; export { deriveSessionRail } from './model/session-rail.js'; +export { sessionMatchesRail } from './model/session-nav-filter.js'; export { sessionRailLayoutStore } from './model/session-rail-layout-store.js'; export type { SessionNavigationRowActions, diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index 9626db5afc..a08d2942c4 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -39,6 +39,7 @@ export { export { useSessionSelection } from './controller/use-session-selection.js'; export type { SessionNavigationRowActions } from './controller/session-row-actions.js'; export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js'; +export { SessionNavigationProvider } from './ui/session-navigation-provider.js'; export { sessionMatchesRail } from './model/session-nav-filter.js'; export { deriveBranchBanner } from './model/branch-banner.js'; export { deriveSessionRail } from './model/session-rail.js'; diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index 4a47d2780b..f1ee1fbe68 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -40,13 +40,16 @@ import { SESSION_LIST_EXPANDED_MAX_WIDTH, SESSION_LIST_EXPANDED_MIN_WIDTH, } from '../model/session-list-layout.js'; -import type { SessionRailProjection } from '../model/session-rail.js'; +import { deriveSessionRail } from '../model/session-rail.js'; +import { sessionMatchesRail } from '../model/session-nav-filter.js'; import { sessionRailLayoutStore } from '../model/session-rail-layout-store.js'; import type { SessionNavigationPorts, SessionNavigationProjectScope, SessionNavigationSession, } from '../ports.js'; +import { selectSessions, type SessionCatalogController } from '../../../session-catalog-state.js'; +import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; /** The chrome the shell owns and the rail only displays. */ export interface SessionNavigationChromeInput { @@ -65,7 +68,10 @@ export interface SessionNavigationChromeInput { } export interface SessionNavigationProviderProps extends SessionNavigationChromeInput { - rail: SessionRailProjection; + /** The rail subscribes the catalog itself: its rows are the churn it displays. */ + catalog: SessionCatalogController; + activeSessionId: string | undefined; + hiddenSessionIds: ReadonlySet; projectScopes: readonly SessionNavigationProjectScope[]; streamingSessionIds: ReadonlySet; staleSessionIds: ReadonlySet; @@ -92,8 +98,16 @@ export interface SessionNavigationProviderProps extends SessionNavigationChromeI * first, the few dozen fibers of permanent chrome on the second. */ export function SessionNavigationProvider(props: SessionNavigationProviderProps) { + const sessions = useExternalStoreSelector(props.catalog, selectSessions); + const rail = useMemo( + () => + deriveSessionRail(sessions, props.activeSessionId, (session) => + !props.hiddenSessionIds.has(session.id) && sessionMatchesRail(session), + ), + [sessions, props.activeSessionId, props.hiddenSessionIds], + ); const controller = useSessionNavigationController({ - rail: props.rail, + rail, projectScopes: props.projectScopes, ports: props.ports, }); @@ -173,8 +187,8 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) const data = useMemo( () => ({ - sessions: props.rail.sessions, - activeId: props.workHubActive ? undefined : props.rail.activeRowId, + sessions: rail.sessions, + activeId: props.workHubActive ? undefined : rail.activeRowId, streamingSessionIds: props.streamingSessionIds, staleSessionIds: props.staleSessionIds, worktreeSessionIds: controller.selectors.worktreeSessionIds, @@ -197,7 +211,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) props.onSelectSession, projectActions, relinkableProjectIds, - props.rail, + rail, props.staleSessionIds, props.streamingSessionIds, props.workHubActive, diff --git a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts index f99f0d8518..444e0c55f3 100644 --- a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts @@ -17,6 +17,7 @@ * under the License. */ +import { useRef } from 'react'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -26,6 +27,7 @@ import { } from '@maka/core/settings'; import { useSessionSettingIntent as useSharedSessionSettingIntent, + type SessionSettingIntentCatalog, } from '@maka/ui'; import { equalSessionModelConfigurationIntent, @@ -34,7 +36,7 @@ import { type SessionModelConfigurationIntent, type SessionModelTarget, } from './session-model-configuration-intent.js'; -import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; +import type { SessionCatalogController } from '../../session-catalog-state.js'; import { useSessionSettingsServices } from './services-context.js'; type SessionSettingValues = { @@ -45,9 +47,8 @@ type SessionSettingValues = { }; export function useSessionSettingIntent(input: { - catalogRevision: number; + catalog: SessionCatalogController; isActiveSession(sessionId: string): boolean; - sessions: readonly DesktopSessionSummary[]; newSessionPermissionMode: ChatDefaultPermissionMode; refreshCatalog(): Promise; saveComposerDefaults(model: SessionModelTarget): void; @@ -75,9 +76,14 @@ export function useSessionSettingIntent(in input.showSessionError(sessionId, failure.title, failure.description); }; const catalogSessionRevision = (sessionId: string) => - input.sessions.find((session) => session.id === sessionId)?.revision; + input.catalog.getState().sessions.find((session) => session.id === sessionId)?.revision; + const catalogRef = useRef(null); + catalogRef.current ??= { + revision: () => input.catalog.getState().revision, + subscribeChanged: input.catalog.subscribe, + }; const intent = useSharedSessionSettingIntent({ - catalogRevision: input.catalogRevision, + catalog: catalogRef.current, refreshCatalog: input.refreshCatalog, channels: { modelConfiguration: { @@ -142,7 +148,7 @@ export function useSessionSettingIntent(in intent.request('modelConfiguration', sessionId, modelConfigurationIntentForModel(modelTarget)), setSessionThinkingLevel: (sessionId: string, thinkingLevel: ThinkingLevel | null) => { const pending = intent.overlayByChannel.modelConfiguration[sessionId]; - const session = input.sessions.find((candidate) => candidate.id === sessionId); + const session = input.catalog.getState().sessions.find((candidate) => candidate.id === sessionId); const currentModelTarget = session?.llmConnectionId ? { llmConnectionId: session.llmConnectionId, @@ -161,7 +167,7 @@ export function useSessionSettingIntent(in const sessionId = owner.sessionId; const overlay = sessionId ? intent.overlayByChannel.permissionMode[sessionId] : undefined; const currentMode = sessionId - ? overlay ?? input.sessions.find((session) => session.id === sessionId)?.permissionMode + ? overlay ?? input.catalog.getState().sessions.find((session) => session.id === sessionId)?.permissionMode : input.newSessionPermissionMode; if (currentMode === mode) { return sessionId && overlay !== undefined diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index eb42cc70a5..00658b7682 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -29,6 +29,7 @@ import { reconcileLiveTurnBuffer, useMountedRef, useSessionSettingIntent, + type SessionSettingIntentCatalog, type InteractionQueues, type LiveTurnProjection, type TransientUserMessageProjection, @@ -54,6 +55,7 @@ import type { UserQuestionResponse } from '@maka/core/user-question'; import type { InteractionFormResponse } from '@maka/core/interaction'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; import { useWorkbarServices } from '../../services-context.js'; +import { createObservableState } from '../../../../observable-state.js'; import type { WorkbarIngestInput } from '../../ports.js'; import { abandonPendingCompanionCopy, @@ -368,9 +370,16 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // double-invoke; a hand-rolled disposed flag would stay tripped after replay). const mountedRef = useMountedRef(); const dismissalGuardRef = useRef(createCompanionDismissalGuard()); - const [permissionCatalogRevision, setPermissionCatalogRevision] = useState(0); + // The companion's own one-row catalog: its commits retire the intent + // overlay, not the shell catalog's. + const permissionCatalogRevisionRef = useRef(createObservableState(0)); + const permissionCatalogRef = useRef(null); + permissionCatalogRef.current ??= { + revision: () => permissionCatalogRevisionRef.current.getState(), + subscribeChanged: permissionCatalogRevisionRef.current.subscribe, + }; const permissionModeIntent = useSessionSettingIntent({ - catalogRevision: permissionCatalogRevision, + catalog: permissionCatalogRef.current, refreshCatalog: async () => { const sessionId = companionIdRef.current; if (!sessionId) return; @@ -379,7 +388,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan if (!mountedRef.current || companionIdRef.current !== sessionId || !next) return; companionRef.current = next; setCompanion(next); - setPermissionCatalogRevision((revision) => revision + 1); + const revisions = permissionCatalogRevisionRef.current; + revisions.replaceState(revisions.getState() + 1); }, channels: { permissionMode: { diff --git a/apps/desktop/src/renderer/session-catalog-state.ts b/apps/desktop/src/renderer/session-catalog-state.ts index 873f3afb67..1daf401506 100644 --- a/apps/desktop/src/renderer/session-catalog-state.ts +++ b/apps/desktop/src/renderer/session-catalog-state.ts @@ -18,7 +18,10 @@ */ import { useRef } from 'react'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; +import { + compareDesktopSessionCatalogSummaries, + type DesktopSessionSummary, +} from '../shared/desktop-session-projection.js'; import { createObservableState } from './observable-state.js'; /** @@ -54,7 +57,50 @@ export function createSessionCatalogController() { subscribe: state.subscribe, commitSessions(next: readonly DesktopSessionSummary[]): void { const current = state.getState(); - state.replaceState({ ...current, sessions: next, revision: current.revision + 1 }); + // Published references change iff values change: an unchanged row keeps + // its identity so per-row readers and memos survive a re-list, and a + // row already patched to a newer revision is never regressed by an + // older snapshot. + const previousById = new Map(current.sessions.map((s) => [s.id, s])); + const reconciled = next.map((s) => { + const prior = previousById.get(s.id); + return prior !== undefined && (isStaleSummary(prior, s) || summaryValuesEqual(prior, s)) + ? prior + : s; + }); + const sameRows = reconciled.length === current.sessions.length + && reconciled.every((s, i) => s === current.sessions[i]); + state.replaceState({ + ...current, + sessions: sameRows ? current.sessions : reconciled, + revision: current.revision + 1, + }); + }, + commitPatch(sessionId: string, summary: DesktopSessionSummary | null): void { + const current = state.getState(); + const index = current.sessions.findIndex((s) => s.id === sessionId); + const prior = index < 0 ? undefined : current.sessions[index]; + if (summary === null) { + if (prior === undefined) return; + state.replaceState({ + ...current, + sessions: current.sessions.filter((s) => s.id !== sessionId), + revision: current.revision + 1, + }); + return; + } + if (prior !== undefined && isStaleSummary(prior, summary)) return; + const row = prior !== undefined && summaryValuesEqual(prior, summary) ? prior : summary; + const sessions = [...current.sessions]; + if (index < 0) sessions.push(row); else sessions[index] = row; + sessions.sort(compareDesktopSessionCatalogSummaries); + const sameRows = sessions.length === current.sessions.length + && sessions.every((s, i) => s === current.sessions[i]); + state.replaceState({ + ...current, + sessions: sameRows ? current.sessions : sessions, + revision: current.revision + 1, + }); }, setActiveSessionId(next: string | undefined): void { const current = state.getState(); @@ -66,8 +112,32 @@ export function createSessionCatalogController() { export type SessionCatalogController = ReturnType; +/** A committed row at a newer revision is authoritative over an older snapshot of it. */ +function isStaleSummary(prior: DesktopSessionSummary, next: DesktopSessionSummary): boolean { + return prior.revision > next.revision; +} + +function summaryValuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) || Array.isArray(b)) { + return Array.isArray(a) && Array.isArray(b) && a.length === b.length + && a.every((v, i) => summaryValuesEqual(v, b[i])); + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return aKeys.length === bKeys.length + && aKeys.every((k) => summaryValuesEqual((a as Record)[k], (b as Record)[k])); +} + export const selectSessions = (state: SessionCatalogState): readonly DesktopSessionSummary[] => state.sessions; +export const selectSessionById = ( + state: SessionCatalogState, + sessionId: string | undefined, +): DesktopSessionSummary | undefined => + sessionId === undefined ? undefined : state.sessions.find((s) => s.id === sessionId); +export const selectSessionCount = (state: SessionCatalogState): number => state.sessions.length; export const selectCatalogRevision = (state: SessionCatalogState): number => state.revision; export const selectActiveSessionId = (state: SessionCatalogState): string | undefined => state.activeSessionId; diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index bd7cc9199a..d277602c93 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -1246,7 +1246,7 @@ function SettingsPageBody(props: { ( {children} )} diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index 2b36e16ee2..d4cb29bac9 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -28,6 +28,8 @@ import { List, ListItem } from '@astryxdesign/core/List'; import { TextInput } from '@astryxdesign/core/TextInput'; import type { SessionPurgeOutcome } from '../features/session-navigation'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; +import { selectSessions, type SessionCatalogController } from '../session-catalog-state.js'; +import { useExternalStoreSelector } from '../use-external-store-selector.js'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; import { getSettingsTasksCopy } from '../locales/settings-tasks-copy.js'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -44,7 +46,8 @@ import { * not have to understand. */ export interface ArchivedTasksBridge { - sessions: readonly DesktopSessionSummary[]; + /** The shell's session catalog; the page subscribes it while it is open. */ + catalog: SessionCatalogController; projects: readonly ProjectRecord[]; onRestore(sessionId: string): void; onDelete(sessionId: string): void; @@ -100,12 +103,13 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { [copy.noProject, projectNames], ); + const sessions = useExternalStoreSelector(props.catalog, selectSessions); // Store order is already recency-first with a stable id tie-break, and the // projection preserves it, so there is nothing left to sort here. - const archived = useMemo(() => archivedTaskRows(props.sessions), [props.sessions]); + const archived = useMemo(() => archivedTaskRows(sessions), [sessions]); const knownSessionIds = useMemo( - () => new Set(props.sessions.map((session) => session.id)), - [props.sessions], + () => new Set(sessions.map((session) => session.id)), + [sessions], ); const isSearching = query.trim().length > 0; const visible = useMemo( diff --git a/apps/desktop/src/renderer/stale-sessions.ts b/apps/desktop/src/renderer/stale-sessions.ts index 7a978f9066..c6ba8952cc 100644 --- a/apps/desktop/src/renderer/stale-sessions.ts +++ b/apps/desktop/src/renderer/stale-sessions.ts @@ -18,6 +18,7 @@ */ import type { SessionSendProjection } from '@maka/core/session-send-projection'; +import type { SessionCatalogState } from './session-catalog-state.js'; export interface StaleSessionsInput { /** Sessions visible in the sidebar (already filtered + grouped). */ @@ -38,6 +39,14 @@ export function deriveStaleSessionIds(input: StaleSessionsInput): Set { return stale; } +const NO_SEND_OUTCOMES: Readonly> = {}; + +export const selectStaleSessionIds = ( + state: SessionCatalogState, + sendOutcomes: Readonly> | undefined, +): Set => + deriveStaleSessionIds({ sessions: state.sessions, sendOutcomes: sendOutcomes ?? NO_SEND_OUTCOMES }); + /** * A row is stale when its owning Runtime Host says the next send cannot go * anywhere the user can fix from the rail. diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index 8e59c10401..ce6affb861 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useCallback, useRef } from 'react'; +import { useRef } from 'react'; import { useUiLocale } from '@maka/ui'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -30,8 +30,6 @@ import { } from './session-read-state.js'; import { selectAuthoritativeSessionIds, - selectCatalogRevision, - selectSessions, type SessionCatalogController, } from './session-catalog-state.js'; import { sessionIdSetsEqual } from './features/conversation/index.js'; @@ -55,9 +53,9 @@ export function useAppShellSessionList( uiLocaleRef.current = uiLocale; const { catalog } = options; // Selected from the catalog store rather than held here: the rail follows the - // same authority without the shell carrying it down a prop chain (#4109). - const sessions = useExternalStoreSelector(catalog, selectSessions); - const catalogRevision = useExternalStoreSelector(catalog, selectCatalogRevision); + // same authority without the shell carrying it down a prop chain (#4109). The + // shell reads rows through its own selectors — this hook only carries the + // membership set and the imperative surface. const authoritativeSessionIds = useExternalStoreSelector( catalog, selectAuthoritativeSessionIds, @@ -66,12 +64,63 @@ export function useAppShellSessionList( ); const sessionsRef = useRef([]); const refresherRef = useRef | null>(null); + const pendingPatchesRef = useRef( + new Map void }[]>(), + ); + const patchDrainActiveRef = useRef(false); function commitSessions(next: DesktopSessionSummary[]): void { sessionsRef.current = next; catalog.commitSessions(next); } + function commitPatch(sessionId: string, summary: DesktopSessionSummary | null): void { + catalog.commitPatch(sessionId, summary); + sessionsRef.current = [...catalog.getState().sessions]; + } + + // `sessions:changed` carries the changed row's id, so the hot path reads and + // commits only that row. Calls arriving while a batch is in flight fold into + // the next drain instead of queueing one IPC per event. + async function drainSessionPatches(): Promise { + try { + while (pendingPatchesRef.current.size > 0) { + const batch = [...pendingPatchesRef.current.entries()]; + pendingPatchesRef.current.clear(); + await Promise.all(batch.map(async ([sessionId, waiters]) => { + try { + const summary = await window.maka.sessions.get(sessionId); + const normalized = summary === null + ? null + : normalizeSessionSummaryForDisplay(summary); + commitPatch(sessionId, normalized); + waiters.forEach(({ resolve }) => resolve(normalized)); + } catch { + // A failed row read must not evict the row; fall back to a full + // refresh (deduped by the refresher) so it cannot strand stale. + waiters.forEach(({ resolve }) => resolve(null)); + void refresherRef.current?.refresh().catch(() => undefined); + } + })); + } + } finally { + patchDrainActiveRef.current = false; + } + } + + function refreshChangedSession(sessionId: string): Promise { + const pending = new Promise((resolve) => { + const waiters = pendingPatchesRef.current.get(sessionId); + if (waiters) waiters.push({ resolve }); + else pendingPatchesRef.current.set(sessionId, [{ resolve }]); + }); + if (!patchDrainActiveRef.current) { + patchDrainActiveRef.current = true; + void drainSessionPatches(); + } + return pending; + } + if (!refresherRef.current) { refresherRef.current = createSessionListRefresher({ listSessions: () => window.maka.sessions.list(), @@ -93,6 +142,7 @@ export function useAppShellSessionList( // down as props (see `session-workspace-actions.ts`). const actionsRef = useRef<{ refreshSessions(): Promise; + refreshChangedSession(sessionId: string): Promise; seedSessions( snapshotSessions: readonly DesktopSessionSummary[], ): DesktopSessionSummary[]; @@ -101,6 +151,7 @@ export function useAppShellSessionList( async refreshSessions() { return refresherRef.current!.refresh(); }, + refreshChangedSession, seedSessions(snapshotSessions) { const next = snapshotSessions.map(normalizeSessionSummaryForDisplay); commitSessions(next); @@ -110,11 +161,10 @@ export function useAppShellSessionList( const { refreshSessions, seedSessions } = actionsRef.current; return { - sessions, - catalogRevision, authoritativeSessionIds, sessionsRef, refreshSessions, + refreshChangedSession: actionsRef.current.refreshChangedSession, seedSessions, }; } diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index f857023546..df623d71f6 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -47,7 +47,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const actionsRef = useRef(null); const sessionList = useAppShellSessionList(toastApi, { catalog }); const { controller: sessionUiController, publication, display } = Conversation.useAppShellSessionUiState( - sessionList.sessions, + catalog, requestedSessionId, activeIdRef, (sessionId, messages, controller: DesktopTranscriptRangeController) => diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index c90c967e17..d5c15ffcbe 100644 --- a/apps/desktop/src/shared/desktop-session-projection.ts +++ b/apps/desktop/src/shared/desktop-session-projection.ts @@ -48,6 +48,22 @@ export interface DesktopSessionSummary extends SessionSummary { export type DesktopSessionSummaryInput = SessionSummary & { readonly revision: number; readonly localState?: 'pending' | 'cached'; readonly localCreatedAt?: number }; +/** + * Catalog order is Host-owned (`activity_at DESC, session_id ASC`); a single + * row patched locally must sort exactly as a re-listed catalog would. + */ +export function compareDesktopSessionCatalogSummaries( + left: DesktopSessionSummary, + right: DesktopSessionSummary, +): number { + const leftActivity = left.localState === 'pending' ? left.localCreatedAt : left.activityAt; + const rightActivity = right.localState === 'pending' ? right.localCreatedAt : right.activityAt; + if (leftActivity === undefined || rightActivity === undefined) { + throw new Error('Runtime Host Session Catalog activity is unavailable'); + } + return rightActivity - leftActivity || left.id.localeCompare(right.id); +} + export type DesktopSessionUpdateFailureCode = | 'session_busy' | 'operation_conflict' diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 034bf29df8..3152893c58 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -87,6 +87,10 @@ import { import type { ConnectionsBridge } from '../../src/renderer/settings/providers-panel'; import type { ProjectRecord } from '@maka/core/project'; import type { ArchivedTasksBridge } from '../../src/renderer/settings/tasks-settings-page'; +import { + createSessionCatalogController, + type SessionCatalogController, +} from '../../src/renderer/session-catalog-state.js'; import type { DesktopLocalRuntimeHostRemoteAccessSnapshot, DesktopRuntimeHostProfileChangedEvent, @@ -1597,19 +1601,25 @@ const archivedTaskProjects: ProjectRecord[] = [ */ function useArchivedTasksStoryBridge(seed: readonly SessionSummary[]): ArchivedTasksBridge { const toast = useToast(); - const [sessions, setSessions] = useState(() => - seed.map((session) => ({ - ...session, - revision: 1, - runtimeHostId: 'storybook-local', - profileId: 'local', - profileName: 'Local', - profileKind: 'local', - })), - ); + const catalogRef = useRef(null); + catalogRef.current ??= (() => { + const controller = createSessionCatalogController(); + controller.commitSessions( + seed.map((session) => ({ + ...session, + revision: 1, + runtimeHostId: 'storybook-local', + profileId: 'local', + profileName: 'Local', + profileKind: 'local', + })), + ); + return controller; + })(); + const catalog = catalogRef.current; const confirmDelete = (sessionId: string) => toast.confirm({ - title: `彻底删除「${sessions.find((session) => session.id === sessionId)?.name ?? ''}」?`, + title: `彻底删除「${catalog.getState().sessions.find((session) => session.id === sessionId)?.name ?? ''}」?`, description: '任务及其全部消息会被永久删除,无法撤销。', confirmLabel: '永久删除', cancelLabel: '取消', @@ -1619,21 +1629,22 @@ function useArchivedTasksStoryBridge(seed: readonly SessionSummary[]): ArchivedT // edit-and-resend family with it. Dropping only the id on screen would leave // an older revision behind and show a list the real app never produces. const drop = (ids: readonly string[]) => { - setSessions((current) => { - const doomed = new Set(ids.flatMap((id) => revisionFamilySessionIds(current, id))); - return current.filter((session) => !doomed.has(session.id)); - }); + const current = catalog.getState().sessions; + const doomed = new Set(ids.flatMap((id) => revisionFamilySessionIds(current, id))); + catalog.commitSessions(current.filter((session) => !doomed.has(session.id))); }; return { - sessions, + catalog, projects: archivedTaskProjects, - onRestore: (sessionId) => - setSessions((current) => { - const family = new Set(revisionFamilySessionIds(current, sessionId)); - return current.map((session) => + onRestore: (sessionId) => { + const current = catalog.getState().sessions; + const family = new Set(revisionFamilySessionIds(current, sessionId)); + catalog.commitSessions( + current.map((session) => family.has(session.id) ? { ...session, isArchived: false } : session, - ); - }), + ), + ); + }, // Mirrors the shell's own row action, which always confirms first — a // story where a row vanishes on one click would be showing an interaction // the app does not have. diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index b1a5341919..e89bf04cc7 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -28,7 +28,7 @@ export { ModuleHubSelector } from './module-hub-selector.js'; export type { ModuleHubHeader } from './module-hub-selector.js'; export { SearchModal } from './search-modal.js'; export { SessionListPanel } from './session-list-panel.js'; -export { SessionRailProvider } from './session-rail-context.js'; +export { SessionRailProvider, useSessionRailData } from './session-rail-context.js'; export type { SessionRailChrome, SessionRailData, diff --git a/packages/ui/src/session-setting-intent.test.ts b/packages/ui/src/session-setting-intent.test.ts index 8d48fdc5c7..9812f1e7e4 100644 --- a/packages/ui/src/session-setting-intent.test.ts +++ b/packages/ui/src/session-setting-intent.test.ts @@ -23,6 +23,7 @@ import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { + type SessionSettingIntentCatalog, type SessionSettingIntentChannel, type SessionSettingIntentWriteResult, useSessionSettingIntent, @@ -43,6 +44,24 @@ function deferred() { return { promise, resolve }; } +function createTestCatalog() { + const listeners = new Set<() => void>(); + let revision = 0; + return { + catalog: { + revision: () => revision, + subscribeChanged: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } satisfies SessionSettingIntentCatalog, + commit(next: number) { + revision = next; + for (const listener of [...listeners]) listener(); + }, + }; +} + const originalGlobals = { document: globalThis.document, window: globalThis.window, @@ -140,26 +159,29 @@ test('revision-aware commits retire only after the target session observes that mountedRoot = root; let controller: Controller | undefined; - const render = (catalogRevision: number, sessionRevision: number) => { + const testCatalog = createTestCatalog(); + const render = (sessionRevision: number) => { root.render(createElement(RevisionHarness, { capture: (next) => { controller = next; }, - catalogRevision, + catalog: testCatalog.catalog, sessionRevision, })); }; - await act(async () => render(0, 0)); + await act(async () => render(0)); await act(async () => { assert.equal(await controller!.request('model', 'session-1', 'model-b'), true); }); assert.equal(controller!.overlayByChannel.model['session-1'], 'model-b'); - await act(async () => render(1, 1)); + await act(async () => render(1)); + await act(async () => testCatalog.commit(1)); assert.equal(controller!.overlayByChannel.model['session-1'], 'model-b'); - await act(async () => render(2, 2)); + await act(async () => render(2)); + await act(async () => testCatalog.commit(2)); assert.equal(controller!.overlayByChannel.model['session-1'], undefined); }); @@ -185,12 +207,13 @@ test('rapid revision-aware requests retire only after the last committed revisio }>>; }> = []; let controller: Controller | undefined; - const render = (catalogRevision: number, sessionRevision: number) => { + const testCatalog = createTestCatalog(); + const render = (sessionRevision: number) => { root.render(createElement(RapidRevisionHarness, { capture: (next) => { controller = next; }, - catalogRevision, + catalog: testCatalog.catalog, sessionRevision, write: async (_sessionId, value) => { const result = deferred<{ committed: boolean; sessionRevision: number }>(); @@ -200,7 +223,7 @@ test('rapid revision-aware requests retire only after the last committed revisio })); }; - await act(async () => render(0, 0)); + await act(async () => render(0)); let completion!: Promise; await act(async () => { completion = controller!.request('model', 'session-1', 'model-a'); @@ -222,13 +245,16 @@ test('rapid revision-aware requests retire only after the last committed revisio }); assert.equal(controller!.overlayByChannel.model['session-1'], 'model-c'); - await act(async () => render(1, 1)); + await act(async () => render(1)); + await act(async () => testCatalog.commit(1)); assert.equal(controller!.overlayByChannel.model['session-1'], 'model-c'); - await act(async () => render(2, 2)); + await act(async () => render(2)); + await act(async () => testCatalog.commit(2)); assert.equal(controller!.overlayByChannel.model['session-1'], 'model-c'); - await act(async () => render(3, 3)); + await act(async () => render(3)); + await act(async () => testCatalog.commit(3)); assert.equal(controller!.overlayByChannel.model['session-1'], undefined); }); @@ -378,7 +404,7 @@ function IntentHarness({ onModelWriteError(sessionId: string, error: unknown, attempted: string): void; }) { const controller = useSessionSettingIntent({ - catalogRevision: 0, + catalog: createTestCatalog().catalog, refreshCatalog, channels: { model: { write: modelWrite, onWriteError: onModelWriteError }, @@ -391,12 +417,12 @@ function IntentHarness({ function RapidRevisionHarness({ capture, - catalogRevision, + catalog, sessionRevision, write, }: { capture(controller: Controller): void; - catalogRevision: number; + catalog: SessionSettingIntentCatalog; sessionRevision: number; write( sessionId: string, @@ -404,7 +430,7 @@ function RapidRevisionHarness({ ): Promise<{ committed: boolean; sessionRevision: number }>; }) { const controller = useSessionSettingIntent({ - catalogRevision, + catalog, refreshCatalog: async () => {}, channels: { model: { @@ -424,15 +450,15 @@ function RapidRevisionHarness({ function RevisionHarness({ capture, - catalogRevision, + catalog, sessionRevision, }: { capture(controller: Controller): void; - catalogRevision: number; + catalog: SessionSettingIntentCatalog; sessionRevision: number; }) { const controller = useSessionSettingIntent({ - catalogRevision, + catalog, refreshCatalog: async () => {}, channels: { model: { @@ -460,7 +486,7 @@ function Harness({ permissionWrite(sessionId: string, value: 'ask' | 'bypass'): Promise; }) { const controller = useSessionSettingIntent({ - catalogRevision: 0, + catalog: createTestCatalog().catalog, refreshCatalog: async () => {}, channels: { model: { diff --git a/packages/ui/src/session-setting-intent.ts b/packages/ui/src/session-setting-intent.ts index 9f104faeaa..0b14e4960b 100644 --- a/packages/ui/src/session-setting-intent.ts +++ b/packages/ui/src/session-setting-intent.ts @@ -57,8 +57,15 @@ export type SessionSettingIntentChannel = SessionSettingIntentChannelBase } ); +export interface SessionSettingIntentCatalog { + /** The catalog's latest committed revision, read at call time. */ + revision(): number; + /** Runs `listener` after every catalog commit; returns the unsubscribe. */ + subscribeChanged(listener: () => void): () => void; +} + export interface SessionSettingIntentOptions { - catalogRevision: number; + catalog: SessionSettingIntentCatalog; refreshCatalog(): Promise; channels: { [Channel in keyof Values]: SessionSettingIntentChannel; @@ -168,18 +175,24 @@ export function useSessionSettingIntent( ) { return; } - } else if (optionsRef.current.catalogRevision <= intent.committedAtCatalogRevision) { + } else if (optionsRef.current.catalog.revision() <= intent.committedAtCatalogRevision) { return; } channelIntents?.delete(sessionId); setOverlay(channel, sessionId, undefined); }, [setOverlay]); + // Reconcile is driven by catalog commits, not renders: subscribing here is + // what lets the catalog live outside this component's render scope. useEffect(() => { - for (const [channel, intents] of intentsRef.current) { - for (const sessionId of intents.keys()) reconcile(channel, sessionId); - } - }, [options.catalogRevision, reconcile]); + const reconcileAll = () => { + for (const [channel, intents] of intentsRef.current) { + for (const sessionId of intents.keys()) reconcile(channel, sessionId); + } + }; + reconcileAll(); + return options.catalog.subscribeChanged(reconcileAll); + }, [options.catalog, reconcile]); useEffect(() => { mountedRef.current = true; @@ -242,7 +255,7 @@ export function useSessionSettingIntent( if (!mountedRef.current || typedIntents.get(sessionId) !== intent) return; if (committed) { intent.committed = attempted; - intent.committedAtCatalogRevision = optionsRef.current.catalogRevision; + intent.committedAtCatalogRevision = optionsRef.current.catalog.revision(); intent.committedAtSessionRevision = committedSessionRevision; if (isEqual(channel, intent.desired, attempted)) { setOverlay(channel, sessionId, attempted); From cbd8b1eee0a58862e1ef412865481043cac1b7fa Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 23:14:17 +0800 Subject: [PATCH 02/14] perf(desktop): observe a side chat only while its panel is visible A retained Side Conversation (PR #5440) kept its fork observer alive across session switches: mounted-but-hidden panels still received every fork event, ran the live-turn reducers, and re-rendered a transcript no one could see - ~981 DOM mutations/s and ~440 task ms/s per hidden running panel in measurement. The fork's observation period is now the panel's interest period: QuoteCompanionPanel already receives active = visible && selected; the hook releases the observer when it goes false and re-seeds through the existing lost-subscription recovery path when it returns (seeded events replay, then readSettledMessages reconciles the durable transcript). commitFork resolves send readiness without an observer when inactive. While unobserved no new turn can start - the panel is the fork's only writer - so the tab activity indicator can only freeze at "running", which a returning re-seed corrects. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../__tests__/quote-companion-retry.test.ts | 116 ++++++++++++++---- .../tools/side-chat/quote-companion-panel.tsx | 1 + .../tools/side-chat/use-quote-companion.ts | 36 +++++- 3 files changed, 126 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 7c86727227..b64750940f 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -141,6 +141,7 @@ async function renderProbe( onContextCompactionError?: (sessionId: string, error: unknown) => void; pendingQuotes?: readonly StagedCompanionQuote[]; onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; + active?: boolean; } = {}, ) { const container = installDom(); @@ -156,30 +157,35 @@ async function renderProbe( }; const root = createRoot(container); mountedRoot = root; - const children = options.ownership - ? createElement(QuoteCompanionOwnershipProbe, { - onSend: options.onSend ?? (() => undefined), - onProjection: options.onProjection, - onQueue: options.onQueue, - onSteer: options.onSteer, - onStop: options.onStop, - onDeleteQueuedEntry: options.onDeleteQueuedEntry, - onSetPermissionMode: options.onSetPermissionMode, - onContextCompactionError: options.onContextCompactionError, - pendingQuotes: options.pendingQuotes, - onQuotesConsumed: options.onQuotesConsumed, - sourceSession: options.sourceSession, - modelChoices: options.modelChoices, - }) - : createElement(QuoteCompanionProbe, { - sourceSession: options.sourceSession, - modelChoices: options.modelChoices, - onSetPermissionMode: options.onSetPermissionMode, - confirmBypass: options.confirmBypass, - }); + const renderChildren = (active: boolean) => + createElement(WorkbarServicesProvider, { + services, + children: options.ownership + ? createElement(QuoteCompanionOwnershipProbe, { + onSend: options.onSend ?? (() => undefined), + onProjection: options.onProjection, + onQueue: options.onQueue, + onSteer: options.onSteer, + onStop: options.onStop, + onDeleteQueuedEntry: options.onDeleteQueuedEntry, + onSetPermissionMode: options.onSetPermissionMode, + onContextCompactionError: options.onContextCompactionError, + pendingQuotes: options.pendingQuotes, + onQuotesConsumed: options.onQuotesConsumed, + sourceSession: options.sourceSession, + modelChoices: options.modelChoices, + active, + }) + : createElement(QuoteCompanionProbe, { + sourceSession: options.sourceSession, + modelChoices: options.modelChoices, + onSetPermissionMode: options.onSetPermissionMode, + confirmBypass: options.confirmBypass, + }), + }); await act(async () => { - root.render(createElement(WorkbarServicesProvider, { services, children })); + root.render(renderChildren(options.active ?? true)); await Promise.resolve(); }); await waitUntil( @@ -188,7 +194,17 @@ async function renderProbe( // produces a companion. Default readiness is just "the probe mounted". options.ready?.(container) ?? container.firstElementChild != null, ); - return { container, root, services }; + return { + container, + root, + services, + setActive: async (next: boolean) => { + await act(async () => { + root.render(renderChildren(next)); + await Promise.resolve(); + }); + }, + }; } async function renderOwnershipProbe( @@ -242,6 +258,7 @@ async function renderOwnershipProbe( ); return { ...rendered, + setActive: rendered.setActive, send: (text: string) => send(text), queue: (text: string) => queue(text), steer: (text: string, attachmentItems?: WorkbarIngestInput[], onAdmitted?: () => void) => @@ -3101,6 +3118,56 @@ test('releases a send waiting for observation when the Side Conversation is disp mountedRoot = undefined; }); +test('releases the fork observation while the panel is hidden and re-seeds on return', async () => { + let subscribes = 0; + let unsubscribes = 0; + let settledReads = 0; + const rendered = await renderOwnershipProbe({ + subscribeEvents: (_sessionId, _handler, onSeeded) => { + subscribes += 1; + onSeeded?.(); + return () => { + unsubscribes += 1; + }; + }, + readSettledMessages: async () => { + settledReads += 1; + return { messages: [], settled: true }; + }, + send: async () => ({ ok: true as const, turnId: 'turn-1' }), + }); + + // Hiding before any fork exists releases nothing. + await rendered.setActive(false); + assert.equal(unsubscribes, 0); + await rendered.setActive(true); + + await act(async () => { + assert.equal(await rendered.send('prepare side conversation'), true); + await Promise.resolve(); + }); + await awaitCompanion(rendered.container); + assert.equal(subscribes, 1); + assert.equal(unsubscribes, 0); + + await rendered.setActive(false); + assert.equal(unsubscribes, 1); + // A second hide has nothing left to release. + await rendered.setActive(false); + assert.equal(unsubscribes, 1); + assert.equal(subscribes, 1); + + const readsBeforeReturn = settledReads; + await rendered.setActive(true); + assert.equal(subscribes, 2); + // The re-seed reconciles the durable transcript, same as a recovered + // subscription. + assert.ok(settledReads > readsBeforeReturn); + + await rendered.setActive(false); + assert.equal(unsubscribes, 2); +}); + test('applies a permission mode picked before the first send once the fork is created', async () => { const permissionCalls: Array<{ sessionId: string; mode: PermissionMode }> = []; const probe = await renderOwnershipProbe({ @@ -3301,6 +3368,7 @@ function QuoteCompanionProbe(props: { const sourceSession = props.sourceSession ?? SOURCE_SESSION; const companion = useQuoteCompanion({ panelId: 'retry-panel', + active: true, pendingQuotes: [], sourceSession, modelChoices: props.modelChoices ?? [choiceFor(sourceSession)], @@ -3328,10 +3396,12 @@ function QuoteCompanionOwnershipProbe(props: { onQuotesConsumed?: (snapshot: CompanionQuoteSnapshot) => void; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; + active?: boolean; }) { const sourceSession = props.sourceSession ?? SOURCE_SESSION; const companion = useQuoteCompanion({ panelId: 'ownership-panel', + active: props.active ?? true, pendingQuotes: props.pendingQuotes ?? [], sourceSession, modelChoices: props.modelChoices ?? [choiceFor(sourceSession)], diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 94d19bcb4e..c3655022f2 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -124,6 +124,7 @@ export function QuoteCompanionPanel(props: { }); const companion = useQuoteCompanion({ panelId: props.panelId, + active: props.active, pendingQuotes: props.quotes, sourceSession: props.sourceSession, modelChoices: props.modelChoices, diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 00658b7682..8c3ecf0d24 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -129,6 +129,9 @@ function admissionOutcomeForMessage( export interface UseQuoteCompanionInput { /** Stable owner for the currently mounted panel generation. */ panelId: string; + /** Whether anyone can see the transcript. Observation follows this: hidden + * releases the fork's observer; visible re-seeds and reconciles. */ + active: boolean; /** Excerpts staged for the next send; accumulates as the user adds more from * the main transcript. Attached to the next turn, then cleared by the host. */ pendingQuotes: readonly StagedCompanionQuote[]; @@ -264,14 +267,17 @@ function transcriptRecordsTerminalTurn( * exchange never flickers away. Asking never writes back to the main conversation; * inherited history is hidden from the side transcript. The subscription is * established the moment the fork commits — before the run starts — so no - * prompt/complete is missed. Explicit tab close removes the ephemeral fork; - * navigation/layout remounts retain it while Workspace still owns the panel. - * Workbar collapse and New Tab navigation keep the conversation alive. + * prompt/complete is missed, and it lives only while the panel is visible: + * hiding releases the observer and showing re-seeds it. Explicit tab close + * removes the ephemeral fork; navigation/layout remounts retain it while + * Workspace still owns the panel. Workbar collapse and New Tab navigation + * keep the conversation alive. */ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompanionResult { const { sideChat } = useWorkbarServices(); const { panelId, + active, locale, sourceSession, modelChoices, @@ -299,6 +305,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const sourceSessionId = sourceSession?.id; const sourceSessionIdRef = useRef(sourceSession?.id); sourceSessionIdRef.current = sourceSessionId; + const activeRef = useRef(active); + activeRef.current = active; const forkSetupPromiseRef = useRef | null>(null); const stopRequestRef = useRef<{ promise: Promise; turnId?: string } | null>(null); const activeTurnIdRef = useRef(null); @@ -881,11 +889,31 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan companionIdRef.current = session.id; companionRef.current = session; setCompanion(session); - subscriptionReadyRef.current = subscribeToFork(session.id); + // A send implies a visible panel, but a resolved promise — not a live + // observer — is all `send` needs from this either way. + subscriptionReadyRef.current = activeRef.current + ? subscribeToFork(session.id) + : Promise.resolve(); }, [subscribeToFork], ); + // The fork's observation period is the panel's interest period: a hidden + // panel renders nothing, so its observer is released; returning re-seeds it + // through the same recovery path a lost subscription takes (seeded events + // replay, then readSettledMessages reconciles the durable transcript). + useEffect(() => { + if (!active) { + unsubscribeRef.current?.(); + unsubscribeRef.current = null; + return; + } + const forkId = companionIdRef.current; + if (forkId && unsubscribeRef.current === null) { + subscriptionReadyRef.current = subscribeToFork(forkId); + } + }, [active, subscribeToFork]); + const ensureFork = useCallback( (name: string): Promise => { if (forkSetupPromiseRef.current) return forkSetupPromiseRef.current; From 8955225aabcc3255c44bec8875b68cbefac7467a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 19 Sep 2026 23:41:54 +0800 Subject: [PATCH 03/14] perf(ui): reconcile transcript timeline and fold entries at item granularity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live turn rebuilt its whole timeline per event, so every memoized entry re-rendered on each delta even when only the streaming tail moved. Extend the turn-level identity contract one level down: reconcileTimelineItems hands back the previous object for every timeline item whose value is unchanged, keyed by timelineItemKey so mid-timeline inserts (steering) do not shift the comparison. reconcileFoldedEntries does the same for foldTimeline's output, matching processing folds by their stable anchor id and children identity; it also refuses to return a stale array when entries leave the fold. With item identity carried through, TurnTimelineEntry and ProcessingBlock become memo boundaries and the settled prefix of a long turn — dozens of tool rows and reasoning blocks — no longer re-renders per token. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ui/src/__tests__/timeline-fold.test.ts | 28 ++++++++++++- .../__tests__/transcript-projection.test.ts | 36 ++++++++++++++++ packages/ui/src/chat-turn.tsx | 34 ++++++++++++--- packages/ui/src/materialize.ts | 2 +- packages/ui/src/timeline-fold.ts | 41 +++++++++++++++++++ packages/ui/src/transcript-projection.ts | 30 +++++++++++++- 6 files changed, 162 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/__tests__/timeline-fold.test.ts b/packages/ui/src/__tests__/timeline-fold.test.ts index d807b22811..1008bdfd03 100644 --- a/packages/ui/src/__tests__/timeline-fold.test.ts +++ b/packages/ui/src/__tests__/timeline-fold.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { foldTimeline } from '../timeline-fold.js'; +import { foldTimeline, reconcileFoldedEntries } from '../timeline-fold.js'; import type { TurnTimelineItem } from '../materialize.js'; import { finalAssistantReplyText, type TurnViewModel } from '../materialize.js'; @@ -91,3 +91,29 @@ test('keeps a reply visible when only reasoning follows it', () => { { kind: 'processing', id: 'start', children: [commentary, tools, thinking] }, ]); }); + +test('reconciled entries keep identity only where the fold actually moved', () => { + const steering: TurnTimelineItem = { kind: 'user', messageId: 'steer', message: { id: 'steer', role: 'user', text: 'More', ts: 2 } }; + const first = foldTimeline([commentary, tools, answer]).entries; + + // A refold of the same items hands every object back. + assert.strictEqual(reconcileFoldedEntries(first, foldTimeline([commentary, tools, answer]).entries), first); + + // A steering instruction splits the fold: the fold before it kept its + // children and identity, the new fold and the user row are new objects, and + // the reply survives because it is the same item. + const split = reconcileFoldedEntries(first, foldTimeline([commentary, tools, steering, thinking, tools, answer]).entries); + assert.strictEqual(split[0], first[0]); + assert.strictEqual(split[1], steering); + assert.strictEqual(split[3], answer); + + // A fold whose children grew is a new object; the untouched reply is not. + const grown = reconcileFoldedEntries(first, foldTimeline([commentary, thinking, tools, answer]).entries); + assert.notStrictEqual(grown[0], first[0]); + assert.strictEqual(grown[1], first[1]); + + // Entries that leave the fold must leave the output too — reconciling by + // content alone would hand the stale reply back with the shorter array. + const shrunk = reconcileFoldedEntries(first, foldTimeline([commentary, tools]).entries); + assert.deepEqual(shrunk, [{ kind: 'processing', id: 'start', children: [commentary, tools] }]); +}); diff --git a/packages/ui/src/__tests__/transcript-projection.test.ts b/packages/ui/src/__tests__/transcript-projection.test.ts index 54e5aeb30d..ebf92fe0ad 100644 --- a/packages/ui/src/__tests__/transcript-projection.test.ts +++ b/packages/ui/src/__tests__/transcript-projection.test.ts @@ -423,6 +423,42 @@ describe('incremental transcript projection', () => { assert.equal(tool?.result?.kind, 'shell_run'); assert.equal(tool?.shellRunSource, 'owned'); }); + + test('a streaming delta moves only the timeline item it grew', () => { + // The live turn rebuilds its whole timeline per event. The turn object + // moves, but a finished tool row inside it did not — the item-level + // reconcile is what lets the memoized entry skip its re-render, so the + // identity has to survive here, not just at the fold. + const projection = createTranscriptProjection(); + const live = (text: string): LiveTurnProjection => ({ + turnId: 'turn-3', + steps: [ + { + stepId: 'step-tool', + contentOrder: ['tools'], + tools: [{ toolUseId: 'bash-9', toolName: 'Bash', status: 'completed', args: { command: 'job' } }], + }, + { + stepId: 'step-answer', + contentOrder: ['text'], + text: { text, truncated: false, complete: false }, + tools: [], + }, + ], + }); + const before = projection.project({ locale: 'en', sessionId: SESSION, messages: history(), liveTurns: [live('he')] }); + const after = projection.project({ locale: 'en', sessionId: SESSION, messages: history(), liveTurns: [live('hel')] }); + + const liveTurn = (turns: readonly TurnViewModel[]) => turns.find((turn) => turn.turnId === 'turn-3')!; + const beforeLive = liveTurn(before); + const afterLive = liveTurn(after); + assert.notStrictEqual(afterLive, beforeLive, 'the turn moved with its text'); + + const item = (turn: TurnViewModel, kind: string) => turn.timeline.find((entry) => entry.kind === kind); + assert.strictEqual(item(afterLive, 'tools'), item(beforeLive, 'tools'), 'the finished tool row keeps identity'); + assert.notStrictEqual(item(afterLive, 'text'), item(beforeLive, 'text'), 'the growing text is a new object'); + assert.strictEqual(after[0], before[0], 'the settled sibling turn stays untouched'); + }); }); /** diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 36614f04bb..eeb1dcd2a9 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -60,7 +60,7 @@ import { type TurnTimelineItem, type TurnViewModel, } from './materialize.js'; -import { foldTimeline, type FoldedTimelineChild, type FoldedTimelineEntry } from './timeline-fold.js'; +import { foldTimeline, reconcileFoldedEntries, type FoldedTimelineChild, type FoldedTimelineEntry } from './timeline-fold.js'; import { AttachmentKindIcon } from './attachment-kinds.js'; import { QuoteRefChip } from './quote-ref-chip.js'; import { Marker, markerVariants } from './primitives/chat.js'; @@ -479,7 +479,16 @@ export const TurnView = memo(function TurnView(props: { const { turn } = props; // Derive disclosure entries and reply identity together, only when this // turn's timeline changes. Rendering and copy share the original reply item. - const { entries: foldedTimeline, finalReply } = useMemo(() => foldTimeline(turn.timeline), [turn.timeline]); + const folded = useMemo(() => foldTimeline(turn.timeline), [turn.timeline]); + // The live turn's timeline moves on every event; the fold re-runs but its + // entries are reconciled back to the previous objects, so the entry-level + // memo boundaries below see only what actually moved. + const foldedEntriesRef = useRef(folded.entries); + if (foldedEntriesRef.current !== folded.entries) { + foldedEntriesRef.current = reconcileFoldedEntries(foldedEntriesRef.current, folded.entries); + } + const foldedTimeline = foldedEntriesRef.current; + const finalReply = folded.finalReply; const forwardBadges = props.lineageBadges?.filter((b) => b.direction === 'forward') ?? []; const reverseBadges = props.lineageBadges?.filter((b) => b.direction === 'reverse') ?? []; const answerContext = accessibleActionContext( @@ -1404,7 +1413,7 @@ function timelineEntryKey(item: TurnTimelineItem, index: number): string { } /** Render one timeline entry: reasoning disclosure / answer bubble / tool group. */ -function TurnTimelineEntry(props: { +const TurnTimelineEntry = memo(function TurnTimelineEntry(props: { activityObserved?: boolean; item: Exclude; onStreamingSettled?: (messageId?: string) => void; @@ -1443,9 +1452,22 @@ function TurnTimelineEntry(props: { onSettled={() => props.onStreamingSettled?.(item.messageId)} /> ); -} +}); -export function ProcessingBlock(props: { +/** + * The turn's whole execution process (reasoning, intermediate commentary, tool + * activity) as ONE bounded, scrollable card: a titled header row and, when + * open, a body that grows with its content up to a cap and then scrolls. The + * container's border is the card's frame, so a collapsed box keeps its outline + * and shows only the title row. + * + * The body owns its own scroll, not the transcript: a turn with hundreds of + * steps scrolls here instead of becoming an unreadable wall the reader has to + * traverse. While the body overflows, its top and/or bottom edge fades the + * content out, so the clipped rows read as "more above/below" rather than + * abruptly cut off. + */ +export const ProcessingBlock = memo(function ProcessingBlock(props: { activityObserved?: boolean; entries: FoldedTimelineChild[]; running: boolean; @@ -1508,7 +1530,7 @@ export function ProcessingBlock(props: {
); -} +}); function DeepThinking(props: { text: string; live: boolean; settledText?: string; truncated?: boolean }) { const copy = getConversationCopy(useUiLocale()).messages; diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 3178a5f9da..0650c3614d 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -1182,7 +1182,7 @@ function chatItemFromContent( }; } -function timelineItemKey(item: TurnTimelineItem): string { +export function timelineItemKey(item: TurnTimelineItem): string { return item.kind === 'tools' ? `tool\0${item.items[0]!.toolUseId}` : `${item.kind}\0${item.messageId}`; } diff --git a/packages/ui/src/timeline-fold.ts b/packages/ui/src/timeline-fold.ts index 5b0d20b562..a24b4e0009 100644 --- a/packages/ui/src/timeline-fold.ts +++ b/packages/ui/src/timeline-fold.ts @@ -75,3 +75,44 @@ export function foldTimeline(items: readonly TurnTimelineItem[]): { flush(); return { entries: out, finalReply }; } + +/** + * Keep the previous fold object for every entry whose content is unchanged. + * foldTimeline rebuilds every fold on each re-run, but its inputs are the + * reconciled timeline items, so equality is cheap here: leaf entries are the + * same objects, and a processing fold is unchanged iff its children are the + * same objects in the same order. Fold `id` (the preceding boundary's + * messageId) survives mid-timeline inserts, so matching by it rather than + * position keeps entries after a steering message stable too. + */ +export function reconcileFoldedEntries( + previous: FoldedTimelineEntry[], + next: FoldedTimelineEntry[], +): FoldedTimelineEntry[] { + if (previous.length === 0) return next; + const processingById = new Map(); + const leafEntries = new Set(); + for (const entry of previous) { + if (entry.kind === 'processing') processingById.set(entry.id, entry); + else leafEntries.add(entry); + } + let moved = previous.length !== next.length; + const reconciled = next.map((entry) => { + if (entry.kind !== 'processing') { + if (leafEntries.has(entry)) return entry; + moved = true; + return entry; + } + const prior = processingById.get(entry.id); + if ( + prior !== undefined + && prior.children.length === entry.children.length + && prior.children.every((child, index) => child === entry.children[index]) + ) { + return prior; + } + moved = true; + return entry; + }); + return moved ? reconciled : previous; +} diff --git a/packages/ui/src/transcript-projection.ts b/packages/ui/src/transcript-projection.ts index c9f736c4a1..267c6972a8 100644 --- a/packages/ui/src/transcript-projection.ts +++ b/packages/ui/src/transcript-projection.ts @@ -27,8 +27,10 @@ import { materializeTurns, overlayLiveTurn, projectTurnTools, + timelineItemKey, type ShellRunOverlayEntry, type ToolActivityItem, + type TurnTimelineItem, type TurnViewModel, } from './materialize.js'; @@ -216,13 +218,39 @@ export function reconcileTurnIdentities( const previousById = new Map(previous.map((turn) => [turn.turnId, turn])); const reconciled = next.map((turn) => { const prior = previousById.get(turn.turnId); - return prior && valuesEqual(prior, turn) ? prior : turn; + if (!prior || valuesEqual(prior, turn)) return prior ?? turn; + // The turn moved, but usually only its tail did: hand the previous + // timeline entry back for every item whose value did not change, so the + // entry-level memo boundaries downstream see what actually moved. + return { ...turn, timeline: reconcileTimelineItems(prior.timeline, turn.timeline) }; }); return reconciled.length === previous.length && reconciled.every((turn, index) => turn === previous[index]) ? previous : reconciled; } +/** + * Keep the previous object for every timeline item whose projected value is + * unchanged. `overlayLiveTurn` rebuilds a live turn's whole timeline from its + * steps on every event, so nothing upstream carries item identity — matching + * by `timelineItemKey` survives mid-timeline inserts (steering messages), + * which a positional compare would report as a change of everything after. + */ +export function reconcileTimelineItems( + previous: TurnTimelineItem[], + next: TurnTimelineItem[], +): TurnTimelineItem[] { + if (previous.length === 0) return next; + const previousByKey = new Map(previous.map((item) => [timelineItemKey(item), item])); + const reconciled = next.map((item) => { + const prior = previousByKey.get(timelineItemKey(item)); + return prior !== undefined && valuesEqual(prior, item) ? prior : item; + }); + return reconciled.length === previous.length && reconciled.every((item, index) => item === previous[index]) + ? previous + : reconciled; +} + /** * Structural equality over projected view data. Everything a turn holds is * plain JSON-shaped data (`args` and tool results included), so a value walk is From 344fd7435820727eb1874a8cf73b4b37fa2fc8ca Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 03:51:42 +0800 Subject: [PATCH 04/14] perf(desktop): dedupe onboarding snapshot emits and serialize pulls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessions:changed fired per background message event, and every event drove a full getSnapshot IPC (4+ parallel queries in main) plus a setSnapshot(newObject) that re-rendered AppShellContent at event rate. Two changes on the same publish-iff-value-changed invariant: - setSnapshot now publishes only when the render projection changes. `sessions` is excluded from the projection: it is boot-time seed data (the session catalog is the live authority) whose rows churn per event; everything onboarding UI renders — state, milestones, connections, defaultSlug, chatModelChoices, sessionSendOutcomes — still propagates. Call-time refs stay unconditionally fresh. - pulls are serialized: an invalidation while one is in flight sets a dirty bit and collapses into a single follow-up, so IPC rate tracks pull latency instead of event rate. The inflight clear lives inside the loop so no microtask window can swallow an invalidation. Measured under ~124 sessions:changed/s: AppShell-level chrome writes drop out of the hot path entirely once combined with the palette subscription sink. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../__tests__/use-onboarding-snapshot.test.ts | 136 ++++++++++++------ .../src/renderer/use-onboarding-snapshot.ts | 84 ++++++++--- 2 files changed, 159 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts index 35084cadae..16cbf1a0d0 100644 --- a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts +++ b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts @@ -23,9 +23,14 @@ import type { OnboardingState } from '@maka/core/onboarding'; import { createOnboardingSnapshotPoller, getOnboardingActivationCandidate, + onboardingSnapshotProjectionEqual, } from '../../renderer/use-onboarding-snapshot.js'; import type { OnboardingSnapshot } from '../../preload/bridge-contract.js'; +function flushMicrotasks(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + const READY_SNAPSHOT: OnboardingSnapshot = { state: { kind: 'ready_empty', @@ -89,6 +94,39 @@ describe('getOnboardingActivationCandidate', () => { }); }); +describe('onboardingSnapshotProjectionEqual', () => { + it('ignores sessions churn — the catalog owns live session rows', () => { + assert.equal( + onboardingSnapshotProjectionEqual(READY_SNAPSHOT, { + ...READY_SNAPSHOT, + sessions: [{} as OnboardingSnapshot['sessions'][number]], + }), + true, + ); + }); + + it('detects changes in the render-relevant fields', () => { + assert.equal( + onboardingSnapshotProjectionEqual(READY_SNAPSHOT, NEEDS_CONNECTION_SNAPSHOT), + false, + ); + assert.equal( + onboardingSnapshotProjectionEqual(READY_SNAPSHOT, { + ...READY_SNAPSHOT, + sessionSendOutcomes: { s1: { kind: 'ready' } }, + }), + false, + ); + assert.equal( + onboardingSnapshotProjectionEqual(READY_SNAPSHOT, { + ...READY_SNAPSHOT, + defaultSlug: 'other', + }), + false, + ); + }); +}); + describe('createOnboardingSnapshotPoller', () => { it('scrubs getSnapshot rejections before routing them to onError', async () => { const events: Array<{ type: 'snap' | 'err'; payload: unknown }> = []; @@ -110,74 +148,92 @@ describe('createOnboardingSnapshotPoller', () => { assert.notEqual(String(events[0]?.payload).includes('sk-live-secret'), true); }); - it('older inflight response cannot overwrite newer state (ticket guard)', async () => { - let resolveFirst!: (snap: OnboardingSnapshot) => void; - let resolveSecond!: (snap: OnboardingSnapshot) => void; - let call = 0; - const events: Array<{ type: 'snap'; payload: OnboardingSnapshot }> = []; + it('a pull issued while another is in flight runs once after it settles', async () => { + const resolvers: Array<(snap: OnboardingSnapshot) => void> = []; + const events: OnboardingSnapshot[] = []; const poller = createOnboardingSnapshotPoller( { getSnapshot: () => new Promise((resolve) => { - call += 1; - if (call === 1) resolveFirst = resolve; - else resolveSecond = resolve; + resolvers.push(resolve); }), }, { - onSnapshot: (s) => events.push({ type: 'snap', payload: s }), + onSnapshot: (s) => events.push(s), onError: () => { /* not expected */ }, }, () => 'zh-CN', ); - // Fire two overlapping pulls. const pull1 = poller.pull(); const pull2 = poller.pull(); - // Resolve the newer pull (#2) first. - resolveSecond(READY_SNAPSHOT); - await pull2; - assert.deepEqual(events, [{ type: 'snap', payload: READY_SNAPSHOT }]); - // Now resolve the stale pull (#1) — it must be ignored. - resolveFirst(NEEDS_CONNECTION_SNAPSHOT); + assert.equal(resolvers.length, 1, 'overlapping pull must not start a second getSnapshot'); + resolvers[0]!(NEEDS_CONNECTION_SNAPSHOT); + await flushMicrotasks(); + assert.equal(resolvers.length, 2, 'the queued pull runs exactly one follow-up'); + resolvers[1]!(READY_SNAPSHOT); await pull1; - assert.deepEqual( - events, - [{ type: 'snap', payload: READY_SNAPSHOT }], - 'stale response from earlier pull must not emit', + await pull2; + assert.deepEqual(events, [NEEDS_CONNECTION_SNAPSHOT, READY_SNAPSHOT]); + }); + + it('collapses repeated invalidations during one pull into a single follow-up', async () => { + const resolvers: Array<(snap: OnboardingSnapshot) => void> = []; + const poller = createOnboardingSnapshotPoller( + { + getSnapshot: () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + }, + { + onSnapshot: () => { + /* not asserted */ + }, + onError: () => { + /* not expected */ + }, + }, + () => 'zh-CN', ); + void poller.pull(); + void poller.pull(); + void poller.pull(); + void poller.pull(); + assert.equal(resolvers.length, 1); + resolvers[0]!(READY_SNAPSHOT); + await flushMicrotasks(); + assert.equal(resolvers.length, 2, 'four queued invalidations produce one follow-up'); + resolvers[1]!(READY_SNAPSHOT); + await flushMicrotasks(); + assert.equal(resolvers.length, 2); }); - it('older inflight error cannot overwrite newer state', async () => { - let rejectFirst!: (err: Error) => void; - let resolveSecond!: (snap: OnboardingSnapshot) => void; - let call = 0; - const snaps: OnboardingSnapshot[] = []; - const errs: string[] = []; + it('a response in flight across dispose cannot write after re-activation', async () => { + const resolvers: Array<(snap: OnboardingSnapshot) => void> = []; + const events: OnboardingSnapshot[] = []; const poller = createOnboardingSnapshotPoller( { getSnapshot: () => - new Promise((resolve, reject) => { - call += 1; - if (call === 1) rejectFirst = reject; - else resolveSecond = resolve; + new Promise((resolve) => { + resolvers.push(resolve); }), }, { - onSnapshot: (s) => snaps.push(s), - onError: (m) => errs.push(m), + onSnapshot: (s) => events.push(s), + onError: () => { + /* not expected */ + }, }, () => 'zh-CN', ); - const pull1 = poller.pull(); - const pull2 = poller.pull(); - resolveSecond(READY_SNAPSHOT); - await pull2; - rejectFirst(new Error('stale failure')); - await pull1; - assert.equal(snaps.length, 1); - assert.equal(errs.length, 0, 'stale error from older pull must NOT emit'); + const pull = poller.pull(); + poller.dispose(); + poller.activate(); + resolvers[0]!(READY_SNAPSHOT); + await pull; + assert.deepEqual(events, [], 'pre-dispose response must stay dropped after re-activation'); }); it('dispose() prevents pending getSnapshot callbacks after unmount', async () => { diff --git a/apps/desktop/src/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts index 8e00c44f20..14c3bc537a 100644 --- a/apps/desktop/src/renderer/use-onboarding-snapshot.ts +++ b/apps/desktop/src/renderer/use-onboarding-snapshot.ts @@ -38,7 +38,7 @@ import { type LlmConnection } from '@maka/core/llm-connections'; import { type SessionSummary } from '@maka/core/session'; import { type UiLocale } from '@maka/core/ui-locale'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; -import { useUiLocale } from '@maka/ui'; +import { useUiLocale, valuesEqual } from '@maka/ui'; import type { OnboardingSnapshot } from '../preload/bridge-contract.js'; import { getOnboardingCopy } from './locales/onboarding-copy.js'; @@ -95,15 +95,34 @@ export function getOnboardingActivationCandidate( }; } +/** + * `sessions` is excluded: it is boot-time seed data (the session catalog is + * the live authority) whose rows churn on every background message event, + * so including it would publish a new snapshot per event. + */ +export function onboardingSnapshotProjectionEqual( + a: OnboardingSnapshot, + b: OnboardingSnapshot, +): boolean { + return ( + a.defaultSlug === b.defaultSlug && + valuesEqual(a.state, b.state) && + valuesEqual(a.milestones, b.milestones) && + valuesEqual(a.connections, b.connections) && + valuesEqual(a.chatModelChoices, b.chatModelChoices) && + valuesEqual(a.sessionSendOutcomes, b.sessionSendOutcomes) + ); +} + /** * Pure-deps form. Renderer code uses `useOnboardingSnapshot()` (no * args); tests pass injected `deps` to drive the hook with fakes * (no IPC required). * * The hook is a thin React shell over `createOnboardingSnapshotPoller` - * — the React-less helper that owns the ticket-based stale-response - * defense. Tests target the pure poller directly so they don't need - * a DOM / React runtime. + * — the React-less helper that owns pull serialization and the + * stale-response defense. Tests target the pure poller directly so they + * don't need a DOM / React runtime. */ export function useOnboardingSnapshotImpl( deps: UseOnboardingSnapshotDeps, @@ -122,11 +141,13 @@ export function useOnboardingSnapshotImpl( if (pollerRef.current === null) { pollerRef.current = createOnboardingSnapshotPoller(deps, { onSnapshot: (next) => { - setSnapshot(next); - setError(null); if (next.sessions) sessionsRef.current = next.sessions; if (next.connections) connectionsRef.current = next.connections; defaultSlugRef.current = next.defaultSlug; + setSnapshot((prev) => + prev !== null && onboardingSnapshotProjectionEqual(prev, next) ? prev : next, + ); + setError(null); }, onError: (message) => { setError(message); @@ -166,11 +187,12 @@ export function useOnboardingSnapshotImpl( } /** - * React-less poller. Tracks an inflight ticket so older getSnapshot - * responses can't overwrite newer state, and owns a lifecycle gate so - * pending IPC responses cannot write after the first-run surface - * unmounts. Extracted from `useOnboardingSnapshotImpl` so the stale - * response defense is testable without a DOM / React. + * React-less poller. Serializes getSnapshot IPCs — an invalidation while a + * pull is in flight schedules a single follow-up — and gates callbacks on + * the active flag plus a dispose-bumped ticket so pending responses cannot + * write after the first-run surface unmounts. Extracted from + * `useOnboardingSnapshotImpl` so the pull discipline is testable without a + * DOM / React. */ export interface OnboardingSnapshotPollerCallbacks { onSnapshot(snapshot: OnboardingSnapshot): void; @@ -193,6 +215,8 @@ export function createOnboardingSnapshotPoller( ): OnboardingSnapshotPoller { let inflightTicket = 0; let active = true; + let inflight: Promise | null = null; + let pullAgain = false; function emitSnapshot(snapshot: OnboardingSnapshot): void { if (!active) return; @@ -204,21 +228,39 @@ export function createOnboardingSnapshotPoller( callbacks.onError(message); } + async function runPull(): Promise { + const ticket = ++inflightTicket; + try { + const next = await deps.getSnapshot(); + if (!active || ticket !== inflightTicket) return; // unmounted or re-disposed + emitSnapshot(next); + } catch (err) { + if (!active || ticket !== inflightTicket) return; + emitError(onboardingSnapshotErrorMessage(err, getLocale())); + } + } + return { activate(): void { active = true; }, - async pull(): Promise { - if (!active) return; - const ticket = ++inflightTicket; - try { - const next = await deps.getSnapshot(); - if (!active || ticket !== inflightTicket) return; // newer pull won or unmounted - emitSnapshot(next); - } catch (err) { - if (!active || ticket !== inflightTicket) return; - emitError(onboardingSnapshotErrorMessage(err, getLocale())); + pull(): Promise { + if (!active) return Promise.resolve(); + // Invalidations arriving while a pull is in flight collapse into one + // follow-up, so the IPC rate tracks pull latency, not event rate. + if (inflight !== null) { + pullAgain = true; + return inflight; } + const loop = (async () => { + do { + pullAgain = false; + await runPull(); + } while (pullAgain && active); + inflight = null; + })(); + inflight = loop; + return loop; }, dispose(): void { active = false; From 258b4a70d0a0236ae828fc28a24f325e498edac9 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 03:51:55 +0800 Subject: [PATCH 05/14] perf(desktop): sink the palette session subscription into its consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppShellContent subscribed selectPaletteSessions at the shell level even though the data only feeds command-palette session rows. Background session churn (activityAt reorder, isFlagged flips) emitted a new visibleSessions per commit and re-rendered the whole shell subtree — ~650 DOM attribute writes/s under ~124 sessions:changed/s. The subscription now lives inside useAppShellCommands in the overlay layer, where the list is consumed; commandOptions carries hiddenSessionIds instead of a materialized session array. While the palette is closed the selector is a constant-empty function, so catalog churn cannot emit at all — the residual 'other' bucket under background steering drops to ~23 mutations/s, all real status-dot updates. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/renderer/app-shell-command-actions.ts | 49 ++++++++++++++++--- apps/desktop/src/renderer/app-shell.tsx | 34 +------------ 2 files changed, 43 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 8c9c3a4c22..c3a555da19 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -35,7 +35,10 @@ import { buildSessionCommands, } from "./command-palette-commands.js"; import type { Command } from './features/overlays/index.js'; -import type { SessionCatalogController } from './session-catalog-state.js'; +import { sessionMatchesRail } from './features/session-navigation/index.js'; +import type { SessionCatalogController, SessionCatalogState } from './session-catalog-state.js'; +import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; +import { useExternalStoreSelector } from './use-external-store-selector.js'; import { renderConversationMarkdown } from "./conversation-markdown.js"; import { commandPaletteActionErrorMessage, @@ -78,7 +81,8 @@ export interface AppShellCommandListOptions { settingsProfileId: string | undefined; sessionCatalog: SessionCatalogController; themePref: ThemePreference; - visibleSessions: readonly SessionSummary[]; + /** Sessions the rail hides (mounted side-chat forks) — the palette skips them too. */ + hiddenSessionIds: ReadonlySet; captureComposerImportOwner: () => ComposerImportOwner; createSession: () => void; openSideConversation: () => void; @@ -396,13 +400,38 @@ export function buildAppShellCommandList( }); } +/** The command palette lists the rail's membership minus its hidden rows. */ +const selectPaletteSessions = ( + state: SessionCatalogState, + hiddenSessionIds: ReadonlySet, +) => + state.sessions.filter( + (session) => sessionMatchesRail(session) && !hiddenSessionIds.has(session.id), + ); + +const EMPTY_PALETTE_SESSIONS: readonly DesktopSessionSummary[] = []; +const selectClosedPaletteSessions = () => EMPTY_PALETTE_SESSIONS; + +/** A palette command shows id, name and the flag glyph; nothing else re-lists it. */ +function paletteSessionsEqual( + left: readonly DesktopSessionSummary[], + right: readonly DesktopSessionSummary[], +): boolean { + return left.length === right.length + && left.every((session, index) => + session.id === right[index]!.id + && session.name === right[index]!.name + && session.isFlagged === right[index]!.isFlagged); +} + export function buildAppShellSessionCommands( optionsRef: RefBox, + visibleSessions: readonly SessionSummary[], ): ReturnType { const options = optionsRef.current; return buildSessionCommands({ locale: options.uiLocale, - sessions: options.visibleSessions, + sessions: visibleSessions, activeSessionId: options.activeId, onSelectSession: (sessionId) => { optionsRef.current.openSessionInChat(sessionId); @@ -418,8 +447,8 @@ export function buildAppShellSessionCommands( * frozen list still acts on current data. Session rows are derived separately, * memoized on the visible session catalog + active session only: background * session creates/renames stay live while the palette is open, without - * reintroducing per-tick rebuilds (visibleSessions is itself memoized in - * app-shell, so rows rebuild only on real catalog changes). + * reintroducing per-tick rebuilds. The catalog subscription lives here — the + * consumption point — so shell renders are not driven by palette-only reads. */ export function useAppShellCommands( paletteOpen: boolean, @@ -427,13 +456,19 @@ export function useAppShellCommands( ): Command[] { const optionsRef = useRef(commandOptions); optionsRef.current = commandOptions; - const { activeId, uiLocale, visibleSessions } = commandOptions; + const { activeId, uiLocale, sessionCatalog, hiddenSessionIds } = commandOptions; + const visibleSessions = useExternalStoreSelector( + sessionCatalog, + paletteOpen ? selectPaletteSessions : selectClosedPaletteSessions, + hiddenSessionIds, + paletteSessionsEqual, + ); const baseCommands = useMemo( () => buildAppShellCommandList(optionsRef), [paletteOpen, uiLocale], ); const sessionCommands = useMemo( - () => buildAppShellSessionCommands(optionsRef), + () => (paletteOpen ? buildAppShellSessionCommands(optionsRef, visibleSessions) : []), [paletteOpen, visibleSessions, activeId, uiLocale], ); return useMemo( diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 537c57699b..36e36047b2 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -79,7 +79,6 @@ import * as ModuleHub from './features/module-hub'; import { SessionNavigationProvider, createSessionOpenCommand, - sessionMatchesRail, sessionRailLayoutStore, useSessionNavigationReads, type SessionNavigationPorts, @@ -88,8 +87,6 @@ import { import { selectSessionById, selectSessionCount, - selectSessions, - type SessionCatalogState, } from './session-catalog-state'; import { useExternalStoreSelector } from './use-external-store-selector'; import * as TaskEntry from './features/task-entry'; @@ -115,7 +112,6 @@ import { } from './plan-mode-panel'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { - DesktopSessionSummary, OnboardingSnapshot, } from '../preload/bridge-contract.js'; import { ProviderLogo } from './settings/provider-display'; @@ -221,26 +217,6 @@ type ComposerImportOwner = { const SETTLE_FALLBACK_GRACE_MS = 1000; const { useSessionCollaborationDialog } = SessionCollaboration; -/** The command palette lists the rail's membership minus its hidden rows. */ -const selectPaletteSessions = ( - state: SessionCatalogState, - hiddenSessionIds: ReadonlySet, -) => - state.sessions.filter( - (session) => sessionMatchesRail(session) && !hiddenSessionIds.has(session.id), - ); - -/** A palette command shows id, name and the flag glyph; nothing else re-lists it. */ -function paletteSessionsEqual( - left: readonly DesktopSessionSummary[], - right: readonly DesktopSessionSummary[], -): boolean { - return left.length === right.length - && left.every((session, index) => - session.id === right[index]!.id - && session.name === right[index]!.name - && session.isFlagged === right[index]!.isFlagged); -} type AppShellProps = { /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ initialOnboardingSnapshot?: OnboardingSnapshot | null; @@ -1378,14 +1354,6 @@ function AppShellContent({ catalog: sessionCatalogController, activeSessionId: activeId, }); - // The palette's 会话 rows: rail membership minus what the rail itself hides, - // re-rendered only when a field a command displays actually changes. - const visibleSessions = useExternalStoreSelector( - sessionCatalogController, - selectPaletteSessions, - selectors.hiddenSessionIds, - paletteSessionsEqual, - ); const sessionListCollapsed = railLayout.collapsed; const sessionListWidth = railLayout.width; const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef; @@ -2169,7 +2137,7 @@ function AppShellContent({ settingsProfileId: overlays.selectors.settings.request.profileId, sessionCatalog: sessionCatalogController, themePref, - visibleSessions, + hiddenSessionIds: selectors.hiddenSessionIds, captureComposerImportOwner, createSession, startModeSession, From 8addcb10d44297d0c7e6b4aefb85def755491879 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 17:43:47 +0800 Subject: [PATCH 06/14] fix(desktop): sweep session retirement against the committed catalog `handleSessionChange` fed the fetch result of a `sessions:changed` event into `retiredSessionIds`, whose contract is the complete catalog. On the single-row path the result is one row, so every background update retired the selected session and cleared its transcript. The sweep now reads `sessionsRef`, which mirrors the catalog after commit; both refresh promises resolve after their commit, so the timing is safe. The handler moves to a leaf module so the regression test can exercise the production code path without mounting the hook. Reported by kabi-sol on PR #5532. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../session-change-retirement.test.ts | 131 ++++++++++++++++++ .../desktop/src/renderer/app-shell-effects.ts | 46 +----- apps/desktop/src/renderer/app-shell.tsx | 1 + .../src/renderer/session-change-effects.ts | 87 ++++++++++++ 4 files changed, 225 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-change-retirement.test.ts create mode 100644 apps/desktop/src/renderer/session-change-effects.ts diff --git a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts new file mode 100644 index 0000000000..32e4f53d40 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { SessionChangedEvent, SessionSummary, StoredMessage } from '@maka/core/session'; +import type { TransientUserMessageProjection } from '@maka/ui'; +import { handleSessionChangedEvent } from '../../renderer/session-change-effects.js'; +import { createSessionWorkspaceActions } from '../../renderer/session-workspace-actions.js'; +import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; + +function row(id: string): SessionSummary { + return { id, name: id } as SessionSummary; +} + +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +function harness(activeId: string | undefined, catalog: SessionSummary[]) { + const sessionsRef = { current: [...catalog] }; + const activeIdRef = { current: activeId }; + const requestedRef = { current: activeId }; + const retired: string[] = []; + const workspace = createSessionWorkspaceActions({ + activeIdRef, + readRequestedSessionId: () => requestedRef.current, + isReadableSession: () => true, + messagesRef: { current: [] as StoredMessage[] }, + transientMessagesBySessionRef: { current: new Map>() }, + transcriptRangeRef: { current: undefined as DesktopTranscriptRangeController | undefined }, + selectionRevisionRef: { current: 0 }, + setActiveIdState: (next) => { + activeIdRef.current = next; + }, + setMessagesState: () => {}, + setTransientMessagesState: () => {}, + setMessageLoadPending: () => {}, + clearSessionUiState: () => {}, + }); + const options = { + uiLocale: 'en' as const, + activeIdRef, + sessionsRef, + retireSession: (sessionId: string) => retired.push(sessionId), + retiredSessionIds: workspace.retiredSessionIds, + clearPendingTurnActionsForSession: () => {}, + refreshMessages: () => Promise.resolve(true), + refreshProjects: () => Promise.resolve(), + refreshSessions: () => Promise.resolve(sessionsRef.current as SessionSummary[]), + // Mirrors the production drain: the committed catalog is updated before the + // row read resolves, so a resolved promise means sessionsRef is current. + refreshChangedSession: (sessionId: string) => { + const next = catalog.find((session) => session.id === sessionId) ?? null; + return Promise.resolve(next); + }, + setSessionEventHealthBySession: () => {}, + toastApi: { + error: () => {}, + info: () => {}, + toast: () => {}, + }, + }; + return { options, retired, sessionsRef }; +} + +describe('session retirement sweep', () => { + it('keeps the selected session when an unrelated row changes', async () => { + const { options, retired } = harness('viewer', [row('viewer'), row('background')]); + const event: SessionChangedEvent = { + reason: 'message-appended', + sessionId: 'background', + ts: 1, + }; + handleSessionChangedEvent(event, options); + await flush(); + assert.deepEqual(retired, []); + }); + + it('retires the selected session when its row leaves the catalog', async () => { + const { options, retired, sessionsRef } = harness('viewer', [row('viewer'), row('background')]); + options.refreshChangedSession = () => { + sessionsRef.current = [row('background')]; + return Promise.resolve(null); + }; + handleSessionChangedEvent( + { reason: 'deleted', sessionId: 'viewer', ts: 1 }, + options, + ); + await flush(); + assert.deepEqual(retired, ['viewer']); + }); + + it('retires nothing when a row read fails and the catalog keeps the row', async () => { + const { options, retired } = harness('viewer', [row('viewer'), row('background')]); + options.refreshChangedSession = () => Promise.resolve(null); + handleSessionChangedEvent( + { reason: 'status-change', sessionId: 'background', ts: 1 }, + options, + ); + await flush(); + assert.deepEqual(retired, []); + }); + + it('sweeps retired rows after a membership refresh', async () => { + const { options, retired, sessionsRef } = harness('viewer', [row('viewer'), row('background')]); + options.refreshSessions = () => { + sessionsRef.current = [row('background')]; + return Promise.resolve(sessionsRef.current); + }; + handleSessionChangedEvent({ reason: 'status-change', ts: 1 }, options); + await flush(); + assert.deepEqual(retired, ['viewer']); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 93bd0effbb..082985350b 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -38,9 +38,9 @@ import type { NavigationState } from './nav-selection.js'; import { createSessionEventStreamSubscription, evaluateSessionEventStreamSnapshot, - recordSessionEventStreamChange, recordSessionEventStreamEvent, } from './session-event-health'; +import { handleSessionChangedEvent } from './session-change-effects.js'; import type { DesktopRuntimeHostProfileChangedEvent, WindowCommand, @@ -165,6 +165,8 @@ export function useAppShellBootstrapSubscriptions(options: { rendererMountedRef: RefBox; retireSession: (sessionId: string) => void; retiredSessionIds(sessions: readonly { id: string }[]): string[]; + /** Mirrors the committed catalog; refresh promises resolve after commit. */ + sessionsRef: RefBox; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: ToastApi; }) { @@ -176,8 +178,8 @@ export function useAppShellBootstrapSubscriptions(options: { options.handleConnectionEvent(event); }); const handleRuntimeHostChange = useEffectEvent((event: DesktopRuntimeHostProfileChangedEvent) => { - void options.refreshSessions().then((sessions) => { - options.retiredSessionIds(sessions).forEach(options.retireSession); + void options.refreshSessions().then(() => { + options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); }); if (event.readiness !== 'ready') return; if (!event.isDefault) return; @@ -198,43 +200,7 @@ export function useAppShellBootstrapSubscriptions(options: { else if (command.id === 'openHelp') options.openHelp(); }); const handleSessionChange = useEffectEvent( - (event: SessionChangedEvent) => { - const refreshedSessions: Promise = event.sessionId === undefined - ? options.refreshSessions() - : options.refreshChangedSession(event.sessionId).then((session) => - session === null ? [] : [session]); - if (event.reason === 'archived' && event.sessionId) options.retireSession(event.sessionId); - if (event.reason === 'created' || event.reason === 'migrated') { - void options.refreshProjects(); - } - if (event.sessionId) { - options.setSessionEventHealthBySession((current) => { - const previous = current[event.sessionId!]; - if (!previous) return current; - return { - ...current, - [event.sessionId!]: recordSessionEventStreamChange(previous, event.ts), - }; - }); - } - if ( - event.sessionId && - (event.reason === 'turn-status-change' || event.reason === 'message-appended' || event.reason === 'deleted') - ) { - options.clearPendingTurnActionsForSession(event.sessionId); - } - const changedSessionId = event.sessionId; - if (event.reason === 'message-appended' && changedSessionId && changedSessionId === options.activeIdRef.current) { - void options.refreshMessages(changedSessionId); - } - if (event.reason === 'rebound') { - const copy = getDesktopConversationCopy(options.uiLocale).actions; - options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(event.modelId)); - } - void refreshedSessions.then((sessions) => { - options.retiredSessionIds(sessions).forEach(options.retireSession); - }); - }, + (event: SessionChangedEvent) => handleSessionChangedEvent(event, options), ); // Both shortcuts fire while the composer has focus — they always did, and // that is the point of a global new-task / settings key — so both opt out of diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 36e36047b2..3379e61879 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1897,6 +1897,7 @@ function AppShellContent({ rendererMountedRef, retireSession: clearSessionRendererState, retiredSessionIds, + sessionsRef, setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, }); diff --git a/apps/desktop/src/renderer/session-change-effects.ts b/apps/desktop/src/renderer/session-change-effects.ts new file mode 100644 index 0000000000..61c95aca77 --- /dev/null +++ b/apps/desktop/src/renderer/session-change-effects.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; +import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { recordSessionEventStreamChange } from './session-event-health.js'; +import { getDesktopConversationCopy } from './locales/conversation-copy.js'; + +type RefBox = { current: T }; + +type SessionEventHealthUpdater = ( + updater: (current: Record) => Record, +) => void; + +export function handleSessionChangedEvent( + event: SessionChangedEvent, + options: { + uiLocale: UiLocale; + activeIdRef: RefBox; + clearPendingTurnActionsForSession: (sessionId: string) => void; + refreshMessages: (sessionId: string) => Promise; + refreshProjects: () => Promise; + refreshSessions: () => Promise; + refreshChangedSession: (sessionId: string) => Promise; + retireSession: (sessionId: string) => void; + retiredSessionIds(sessions: readonly { id: string }[]): string[]; + /** Mirrors the committed catalog; refresh promises resolve after commit. */ + sessionsRef: RefBox; + setSessionEventHealthBySession: SessionEventHealthUpdater; + toastApi: { info(title: string, description?: string): void }; + }, +): void { + // The sweep below reads the committed catalog (sessionsRef) rather than this + // result: on the single-row path the result is one row, not the complete + // list `retiredSessionIds` compares membership against. + const refreshedSessions: Promise = event.sessionId === undefined + ? options.refreshSessions() + : options.refreshChangedSession(event.sessionId); + if (event.reason === 'archived' && event.sessionId) options.retireSession(event.sessionId); + if (event.reason === 'created' || event.reason === 'migrated') { + void options.refreshProjects(); + } + if (event.sessionId) { + options.setSessionEventHealthBySession((current) => { + const previous = current[event.sessionId!]; + if (!previous) return current; + return { + ...current, + [event.sessionId!]: recordSessionEventStreamChange(previous, event.ts), + }; + }); + } + if ( + event.sessionId && + (event.reason === 'turn-status-change' || event.reason === 'message-appended' || event.reason === 'deleted') + ) { + options.clearPendingTurnActionsForSession(event.sessionId); + } + const changedSessionId = event.sessionId; + if (event.reason === 'message-appended' && changedSessionId && changedSessionId === options.activeIdRef.current) { + void options.refreshMessages(changedSessionId); + } + if (event.reason === 'rebound') { + const copy = getDesktopConversationCopy(options.uiLocale).actions; + options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(event.modelId)); + } + void refreshedSessions.then(() => { + options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); + }); +} From c9627e3c09b01954791ed63ea98c05d191358bbe Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 17:43:55 +0800 Subject: [PATCH 07/14] perf(desktop): sink the stale-session selector into the rail provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell subscribed `selectStaleSessionIds` only to pass the set through to `SessionNavigationProvider` — a whole-tree scope for rail-only state, and a new call site the #4109 hook gate rejects. The provider now selects it from the catalog it already owns, taking `sessionSendOutcomes` as the prop instead. The three selector calls that remain in the shell body — session count and the two revision-draft rows — are ones the shell genuinely reads, so they are recorded in the hooks inventory rather than moved. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../session-navigation-controller.test.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 13 +------------ .../ui/session-navigation-provider.tsx | 15 ++++++++++++--- scripts/check-app-shell-hooks.mjs | 7 +++++++ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 4745d9271f..19f46f7612 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -295,7 +295,7 @@ describe('useSessionNavigationReads', () => { hiddenSessionIds, projectScopes: [localProjectScope], streamingSessionIds: new Set(), - staleSessionIds: new Set(), + sessionSendOutcomes: {}, ports: ports(linkedCatalog, 'child'), commandsRef: { current: null }, selection: { section: 'sessions' }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 3379e61879..496b84b9fa 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -128,7 +128,6 @@ import { getDesktopConversationCopy } from './locales/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useSessionSettingIntent } from './features/session-settings'; -import { selectStaleSessionIds } from './stale-sessions'; import { pendingSessionView } from './pending-session-view'; import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; import { readScrollMotionBehavior } from './scroll-motion-policy'; @@ -656,16 +655,6 @@ function AppShellContent({ resumeInterruptedSession, } = useShellResume({ activeId: ownerActiveId, toastApi, shellCopy, uiLocale }); const rendererMountedRef = useRef(true); - // Set of session ids whose backend / connection is no longer usable — - // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat - // header banner). Derivation is pure (see `stale-sessions.ts`) so the - // classifier is testable without a DOM. - const staleSessionIds = useExternalStoreSelector( - sessionCatalogController, - selectStaleSessionIds, - onboarding.snapshot?.sessionSendOutcomes, - Conversation.sessionIdSetsEqual, - ); const activeInteraction = activeInteractionFor(interactionBySession, ownerActiveId); const activeSession = activeCatalogSession; const sessionSettingIntent = useSessionSettingIntent({ @@ -2334,7 +2323,7 @@ function AppShellContent({ hiddenSessionIds={selectors.hiddenSessionIds} projectScopes={taskEntry.selectors.projectScopes} streamingSessionIds={streamingSessionIds} - staleSessionIds={staleSessionIds} + sessionSendOutcomes={onboarding.snapshot?.sessionSendOutcomes} SessionBadge={SessionCollaboration.SessionTurnRequestBadge} NavigationExtras={SessionCollaboration.SessionCollaborationNavigation} ports={sessionNavigationPorts} diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index f1ee1fbe68..92a0a9ca0f 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -49,7 +49,10 @@ import type { SessionNavigationSession, } from '../ports.js'; import { selectSessions, type SessionCatalogController } from '../../../session-catalog-state.js'; +import { selectStaleSessionIds } from '../../../stale-sessions.js'; +import { sessionIdSetsEqual } from '../../conversation/model/live-turn-snapshot.js'; import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; +import type { SessionSendProjection } from '@maka/core/session-send-projection'; /** The chrome the shell owns and the rail only displays. */ export interface SessionNavigationChromeInput { @@ -74,7 +77,7 @@ export interface SessionNavigationProviderProps extends SessionNavigationChromeI hiddenSessionIds: ReadonlySet; projectScopes: readonly SessionNavigationProjectScope[]; streamingSessionIds: ReadonlySet; - staleSessionIds: ReadonlySet; + sessionSendOutcomes?: Readonly>; SessionBadge?: ComponentType<{ readonly sessionId: string }>; ports: SessionNavigationPorts; /** @@ -99,6 +102,12 @@ export interface SessionNavigationProviderProps extends SessionNavigationChromeI */ export function SessionNavigationProvider(props: SessionNavigationProviderProps) { const sessions = useExternalStoreSelector(props.catalog, selectSessions); + const staleSessionIds = useExternalStoreSelector( + props.catalog, + selectStaleSessionIds, + props.sessionSendOutcomes, + sessionIdSetsEqual, + ); const rail = useMemo( () => deriveSessionRail(sessions, props.activeSessionId, (session) => @@ -190,7 +199,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) sessions: rail.sessions, activeId: props.workHubActive ? undefined : rail.activeRowId, streamingSessionIds: props.streamingSessionIds, - staleSessionIds: props.staleSessionIds, + staleSessionIds, worktreeSessionIds: controller.selectors.worktreeSessionIds, groups: controller.layout.viewMode === 'project' ? controller.selectors.groups : undefined, groupVariant: controller.layout.viewMode, @@ -212,7 +221,7 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) projectActions, relinkableProjectIds, rail, - props.staleSessionIds, + staleSessionIds, props.streamingSessionIds, props.workHubActive, rowActions, diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index fa6a2e73e0..060841ce33 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -118,6 +118,13 @@ export const ALLOWED = { useAppShellTurnPresentation: 1, useComposerAttachments: 1, useEffect: 7, + // The shell's own reads of the session catalog: the session count gates the + // onboarding surface, and the two revision-draft rows feed the commit it + // issues. Three selector call sites, all whole-tree-scoped reads the shell + // genuinely consumes — the catalog subscription itself lives in + // `SessionNavigationProvider` / `useAppShellCommands`, which is why this + // entry did not exist before (#5441). + useExternalStoreSelector: 3, useLayoutEffect: 2, useNewTaskChoice: 1, useOnboardingSnapshot: 1, From 1b1a43627e8d2c40bab6500f6bc97970b196576c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 17:44:05 +0800 Subject: [PATCH 08/14] perf(desktop): publish the catalog only when its content changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `commitSessions`/`commitPatch` bumped `revision` and replaced the snapshot even when every row was reused, so a no-op event still notified every subscriber. Now a commit that reuses all rows returns early — except the first commit, since revision 0 means "no authoritative observation" and an empty list is still one. The row equality check also switches from `summaryValuesEqual` to the fail-closed `valuesEqual` shared with `@maka/ui`, so a non-plain field can never compare equal by walking zero keys. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/renderer/session-catalog-state.ts | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/session-catalog-state.ts b/apps/desktop/src/renderer/session-catalog-state.ts index 1daf401506..d63e4e6996 100644 --- a/apps/desktop/src/renderer/session-catalog-state.ts +++ b/apps/desktop/src/renderer/session-catalog-state.ts @@ -18,6 +18,7 @@ */ import { useRef } from 'react'; +import { valuesEqual } from '@maka/ui'; import { compareDesktopSessionCatalogSummaries, type DesktopSessionSummary, @@ -64,12 +65,16 @@ export function createSessionCatalogController() { const previousById = new Map(current.sessions.map((s) => [s.id, s])); const reconciled = next.map((s) => { const prior = previousById.get(s.id); - return prior !== undefined && (isStaleSummary(prior, s) || summaryValuesEqual(prior, s)) + return prior !== undefined && (isStaleSummary(prior, s) || valuesEqual(prior, s)) ? prior : s; }); const sameRows = reconciled.length === current.sessions.length && reconciled.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 && current.revision > 0) return; state.replaceState({ ...current, sessions: sameRows ? current.sessions : reconciled, @@ -90,15 +95,16 @@ export function createSessionCatalogController() { return; } if (prior !== undefined && isStaleSummary(prior, summary)) return; - const row = prior !== undefined && summaryValuesEqual(prior, summary) ? prior : summary; + const row = prior !== undefined && valuesEqual(prior, summary) ? prior : summary; const sessions = [...current.sessions]; if (index < 0) sessions.push(row); else sessions[index] = row; sessions.sort(compareDesktopSessionCatalogSummaries); const sameRows = sessions.length === current.sessions.length && sessions.every((s, i) => s === current.sessions[i]); + if (sameRows) return; state.replaceState({ ...current, - sessions: sameRows ? current.sessions : sessions, + sessions, revision: current.revision + 1, }); }, @@ -117,19 +123,6 @@ function isStaleSummary(prior: DesktopSessionSummary, next: DesktopSessionSummar return prior.revision > next.revision; } -function summaryValuesEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; - if (Array.isArray(a) || Array.isArray(b)) { - return Array.isArray(a) && Array.isArray(b) && a.length === b.length - && a.every((v, i) => summaryValuesEqual(v, b[i])); - } - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - return aKeys.length === bKeys.length - && aKeys.every((k) => summaryValuesEqual((a as Record)[k], (b as Record)[k])); -} - export const selectSessions = (state: SessionCatalogState): readonly DesktopSessionSummary[] => state.sessions; export const selectSessionById = ( From 0a6b28292fd48608f8ea523fd251b74444d2a02f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 17:44:06 +0800 Subject: [PATCH 09/14] refactor(desktop): make the onboarding dedup key exhaustive `onboardingSnapshotProjectionEqual` compared a hand-maintained field list, so a new `OnboardingSnapshot` field would silently drop out of the dedup key and stop publishing. A `satisfies` witness over `Exclude` makes a missing field a compile error. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/renderer/use-onboarding-snapshot.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts index 14c3bc537a..9dea088619 100644 --- a/apps/desktop/src/renderer/use-onboarding-snapshot.ts +++ b/apps/desktop/src/renderer/use-onboarding-snapshot.ts @@ -98,19 +98,26 @@ export function getOnboardingActivationCandidate( /** * `sessions` is excluded: it is boot-time seed data (the session catalog is * the live authority) whose rows churn on every background message event, - * so including it would publish a new snapshot per event. + * so including it would publish a new snapshot per event. The `satisfies` + * witness makes the key list exhaustive — a new `OnboardingSnapshot` field + * not added here fails to compile instead of silently dropping out of the + * dedup key. */ +const COMPARED_KEYS = { + defaultSlug: true, + state: true, + milestones: true, + connections: true, + chatModelChoices: true, + sessionSendOutcomes: true, +} satisfies Record, true>; + export function onboardingSnapshotProjectionEqual( a: OnboardingSnapshot, b: OnboardingSnapshot, ): boolean { - return ( - a.defaultSlug === b.defaultSlug && - valuesEqual(a.state, b.state) && - valuesEqual(a.milestones, b.milestones) && - valuesEqual(a.connections, b.connections) && - valuesEqual(a.chatModelChoices, b.chatModelChoices) && - valuesEqual(a.sessionSendOutcomes, b.sessionSendOutcomes) + return (Object.keys(COMPARED_KEYS) as readonly (keyof typeof COMPARED_KEYS)[]).every( + (key) => valuesEqual(a[key], b[key]), ); } From 4d517b75159f09ec0c8ba2f1c54ae66decd46048 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 18:34:40 +0800 Subject: [PATCH 10/14] refactor(desktop): share the session-id set comparator across features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessionIdSetsEqual lived in the conversation feature, so the navigation provider importing it crossed the feature boundary the renderer architecture ledger forbids. It is a leaf comparison over session ids — move it to src/shared/ where both features can reach it. Regenerates the architecture ledger for the new/changed files on this branch (session-change-effects module, the catalog selectors' new call sites, sessions.get bridge path). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/renderer-architecture.json | 74 +++++++++++++++---- .../conversation/model/live-turn-snapshot.ts | 13 +--- .../ui/session-navigation-provider.tsx | 2 +- apps/desktop/src/shared/session-id-set.ts | 32 ++++++++ 4 files changed, 94 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/shared/session-id-set.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d5f458bd1f..10ab2996c7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -110,6 +110,7 @@ "src/renderer/remote-project-directory-dialog.tsx", "src/renderer/scroll-motion-policy.ts", "src/renderer/session-catalog-state.ts", + "src/renderer/session-change-effects.ts", "src/renderer/session-collaboration-dialog.tsx", "src/renderer/session-copy-attempt.ts", "src/renderer/session-error-presentation.ts", @@ -238,13 +239,24 @@ "src/renderer/settings" ], "legacyFeatureImports": [ + "src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts -> src/renderer/session-catalog-state", + "src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts -> src/renderer/use-external-store-selector", "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/daily-review-actions", "src/renderer/features/module-hub/ui/module-hub-host.tsx -> src/renderer/mcp-page", + "src/renderer/features/session-bundle/session-bundle-tasks.tsx -> src/renderer/session-catalog-state", + "src/renderer/features/session-bundle/session-bundle-tasks.tsx -> src/renderer/use-external-store-selector", + "src/renderer/features/session-collaboration/turn-request-inbox-context.tsx -> src/renderer/session-catalog-state", + "src/renderer/features/session-collaboration/turn-request-inbox-context.tsx -> src/renderer/use-external-store-selector", "src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts -> src/renderer/use-external-store-selector", + "src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts -> src/renderer/session-catalog-state", "src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts -> src/renderer/use-external-store-selector", "src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage", "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/browser-storage", "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/observable-state", + "src/renderer/features/session-navigation/ui/session-navigation-provider.tsx -> src/renderer/session-catalog-state", + "src/renderer/features/session-navigation/ui/session-navigation-provider.tsx -> src/renderer/stale-sessions", + "src/renderer/features/session-navigation/ui/session-navigation-provider.tsx -> src/renderer/use-external-store-selector", + "src/renderer/features/session-settings/use-session-setting-intent.ts -> src/renderer/session-catalog-state", "src/renderer/features/task-entry/model/task-entry-selection.ts -> src/renderer/new-task-reload-intent", "src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog", "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/browser-storage", @@ -258,6 +270,7 @@ "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/turn-footer-actions", + "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/observable-state", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel" @@ -360,7 +373,7 @@ "nonTriviaTokens": 408 }, "src/renderer/app-shell-command-actions.ts": { - "importDeclarations": 5, + "importDeclarations": 6, "bridgePaths": { "window.maka.connections.setDefault": 1, "window.maka.connections.test": 1, @@ -373,6 +386,7 @@ "navigator.clipboard.writeText": 1 }, "hookCalls": { + "useExternalStoreSelector": 1, "useRef": 1 }, "lifecycleMethods": {}, @@ -383,13 +397,15 @@ "./command-palette-commands.js": 1, "./conversation-markdown.js": 1, "./default-runtime-host-operation.js": 1, + "./features/session-navigation/index.js": 1, "./locales/settings-memory-copy.js": 1, "./locales/settings-test-result-copy.js": 1, "./locales/shell-copy.js": 1, + "./use-external-store-selector.js": 1, "react": 1 }, - "importSpecifiers": 9, - "nonTriviaTokens": 2275 + "importSpecifiers": 10, + "nonTriviaTokens": 2496 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -454,7 +470,7 @@ "nonTriviaTokens": 637 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 10, + "importDeclarations": 11, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, @@ -494,6 +510,7 @@ "./browser-storage": 1, "./locales/conversation-copy.js": 1, "./platform/desktop/desktop-transcript-range-store.js": 1, + "./session-change-effects.js": 1, "./session-event-health": 1, "./shell-run-update-state.js": 1, "./theme": 1, @@ -503,7 +520,7 @@ "react": 1 }, "importSpecifiers": 18, - "nonTriviaTokens": 3677 + "nonTriviaTokens": 3459 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -711,7 +728,7 @@ "nonTriviaTokens": 1245 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 64, + "importDeclarations": 65, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -751,6 +768,7 @@ "useAppShellTurnPresentation": 1, "useComposerAttachments": 1, "useEffect": 7, + "useExternalStoreSelector": 3, "useLayoutEffect": 2, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, @@ -826,17 +844,18 @@ "./pending-session-view": 1, "./plan-mode-panel": 1, "./scroll-motion-policy": 1, + "./session-catalog-state": 1, "./session-collaboration-dialog": 1, "./session-workspace-errors": 1, "./settings/provider-brand-marks": 1, "./settings/provider-display": 1, "./settings/runtime-host-ssh-terminal-dialog.js": 1, "./shell/frame-style": 1, - "./stale-sessions": 1, "./use-active-execution-boundary": 1, "./use-app-shell-composer-quotes": 1, "./use-app-shell-session-ui-reads": 1, "./use-app-shell-session-workspace": 1, + "./use-external-store-selector": 1, "./use-new-task-choice": 1, "./use-onboarding-snapshot": 1, "./use-project-context": 1, @@ -860,8 +879,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 100, - "nonTriviaTokens": 13003 + "importSpecifiers": 102, + "nonTriviaTokens": 12972 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, @@ -880,12 +899,13 @@ "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 6, "bridgePaths": { + "window.maka.sessions.get": 1, "window.maka.sessions.list": 1 }, "environmentCapabilities": {}, "hookCalls": { - "useExternalStoreSelector": 3, - "useRef": 4, + "useExternalStoreSelector": 1, + "useRef": 6, "useUiLocale": 1 }, "lifecycleMethods": {}, @@ -902,8 +922,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 9, - "nonTriviaTokens": 486 + "importSpecifiers": 6, + "nonTriviaTokens": 838 }, "src/renderer/use-app-shell-session-ui-reads.ts": { "importDeclarations": 1, @@ -947,7 +967,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 461 + "nonTriviaTokens": 459 } }, "closure": { @@ -2014,10 +2034,24 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../shared/desktop-session-projection.js": 1, "./observable-state.js": 1, + "@maka/ui": 1, "react": 1 } }, + "src/renderer/session-change-effects.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/conversation-copy.js": 1, + "./session-event-health.js": 1 + } + }, "src/renderer/session-collaboration-dialog.tsx": { "bridgePaths": { "window.maka.localRuntimeHostRemoteAccess.getSnapshot": 2, @@ -3586,6 +3620,7 @@ "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { + "useExternalStoreSelector": 1, "useMountedRef": 1, "useState": 2, "useToast": 1, @@ -3597,6 +3632,8 @@ "dependencyPaths": { "../locales/settings-shared-copy.js": 1, "../locales/settings-tasks-copy.js": 1, + "../session-catalog-state.js": 1, + "../use-external-store-selector.js": 1, "./settings-error-copy": 1, "./settings-section": 1, "./task-catalog-rows": 1, @@ -4327,6 +4364,15 @@ "@maka/runtime-host/protocol": 1 } }, + "src/shared/session-id-set.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": {} + }, "src/shared/settings-ownership.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts b/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts index 3ea88eb15d..1f1a9967ea 100644 --- a/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts +++ b/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts @@ -107,18 +107,7 @@ export function selectStreamingSessionIds( return streaming; } -export function sessionIdSetsEqual( - a: ReadonlySet | undefined, - b: ReadonlySet | undefined, -): boolean { - if (a === b) return true; - if (!a || !b) return false; - if (a.size !== b.size) return false; - for (const id of a) { - if (!b.has(id)) return false; - } - return true; -} +export { sessionIdSetsEqual } from '../../../../shared/session-id-set.js'; function findLast(items: readonly T[], predicate: (item: T) => boolean): T | undefined { for (let index = items.length - 1; index >= 0; index -= 1) { diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index 92a0a9ca0f..166c1cce71 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -50,7 +50,7 @@ import type { } from '../ports.js'; import { selectSessions, type SessionCatalogController } from '../../../session-catalog-state.js'; import { selectStaleSessionIds } from '../../../stale-sessions.js'; -import { sessionIdSetsEqual } from '../../conversation/model/live-turn-snapshot.js'; +import { sessionIdSetsEqual } from '../../../../shared/session-id-set.js'; import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; import type { SessionSendProjection } from '@maka/core/session-send-projection'; diff --git a/apps/desktop/src/shared/session-id-set.ts b/apps/desktop/src/shared/session-id-set.ts new file mode 100644 index 0000000000..38e9e5bda6 --- /dev/null +++ b/apps/desktop/src/shared/session-id-set.ts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Value equality for sets of session ids — rebuilt sets stay off the token path. */ +export function sessionIdSetsEqual( + a: ReadonlySet | undefined, + b: ReadonlySet | undefined, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + if (a.size !== b.size) return false; + for (const id of a) { + if (!b.has(id)) return false; + } + return true; +} From 2dd87b6a3cf7f4c9449cc0408a71f5abd9dbb7ad Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 19:50:38 +0800 Subject: [PATCH 11/14] refactor(desktop): move session-catalog infrastructure into contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strict-base ratchet forbids new feature-to-legacy edges and any capability growth in legacy files, so the catalog machinery moves to the layers that own it: external-store state, selectors and change effects live under application/contracts/session-catalog, and the window.maka.sessions patch drain lives on the platform adapter. Root modules keep their import surface as re-export shims; every feature now imports the contracts paths, which deletes all 15 budgeted feature-to-legacy catalog edges rather than adding new ones. Subscriptions sit at their consumption points: the command palette selects its session rows internally, the archived-tasks page receives sessions through a render-prop component, and the revision-draft watch runs inside CatalogRowWatch. AppShell gains no hooks — its inventory drops to 36 hooks / 60 call sites — and no legacy file grows tokens, hooks, bridge paths or dependencies relative to the merge base. --- apps/desktop/renderer-architecture.json | 129 +++----------- .../app-shell-first-send-cleanup.test.ts | 2 +- .../import-tasks-settings-page.test.ts | 2 +- .../session-change-retirement.test.ts | 3 +- .../__tests__/session-event-health.test.ts | 2 +- .../session-navigation-controller.test.ts | 2 +- .../__tests__/session-setting-intent.test.ts | 2 +- .../session-settings-controller.test.ts | 2 +- .../src/main/__tests__/stale-sessions.test.ts | 2 +- .../src/main/__tests__/workbar-model.test.ts | 2 +- .../src/renderer/app-shell-command-actions.ts | 84 +++------- .../desktop/src/renderer/app-shell-effects.ts | 13 +- .../src/renderer/app-shell-overlays.tsx | 11 +- apps/desktop/src/renderer/app-shell.tsx | 60 ++++--- .../session-catalog/catalog-row-watch.tsx | 69 ++++++++ .../session-catalog/catalog-sessions.tsx | 37 ++++ .../session-catalog/observable-state.ts | 62 +++++++ .../session-catalog/session-catalog-state.ts | 158 ++++++++++++++++++ .../session-change-effects.ts | 11 +- .../session-catalog/session-event-health.ts | 108 ++++++++++++ .../session-catalog}/session-id-set.ts | 0 .../session-rail-visibility.ts | 62 +++++++ .../session-catalog/stale-sessions.ts | 71 ++++++++ .../use-external-store-selector.ts | 72 ++++++++ .../src/renderer/command-palette-commands.ts | 32 ---- .../use-app-shell-session-ui-state.ts | 4 +- .../conversation/model/live-turn-snapshot.ts | 2 +- .../model/palette-session-commands.ts | 99 +++++++++++ .../features/overlays/ui/command-palette.tsx | 28 +++- .../session-bundle/session-bundle-tasks.tsx | 4 +- .../turn-request-inbox-context.tsx | 4 +- .../use-session-navigation-controller.ts | 2 +- .../use-session-navigation-reads.ts | 6 +- .../model/session-nav-filter.ts | 18 +- .../model/session-rail-layout-store.ts | 2 +- .../ui/session-navigation-provider.tsx | 8 +- .../use-session-setting-intent.ts | 2 +- .../tools/side-chat/use-quote-companion.ts | 2 +- apps/desktop/src/renderer/observable-state.ts | 44 +---- .../platform/desktop/session-catalog-sync.ts | 84 ++++++++++ .../src/renderer/session-catalog-state.ts | 140 +--------------- .../src/renderer/session-event-health.ts | 90 +--------- .../renderer/settings/settings-surface.tsx | 7 +- .../renderer/settings/tasks-settings-page.tsx | 15 +- apps/desktop/src/renderer/stale-sessions.ts | 53 +----- .../renderer/use-app-shell-session-list.ts | 148 +++++----------- .../renderer/use-external-store-selector.ts | 54 +----- .../stories/command-search.stories.tsx | 46 +++-- docs/astryx-surface-file-inventory.md | 4 +- docs/astryx-surface-file-inventory.paths | 2 + scripts/check-app-shell-hooks.mjs | 9 +- 51 files changed, 1067 insertions(+), 808 deletions(-) create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/catalog-row-watch.tsx create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/catalog-sessions.tsx create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/observable-state.ts create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts rename apps/desktop/src/renderer/{ => application/contracts/session-catalog}/session-change-effects.ts (88%) create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/session-event-health.ts rename apps/desktop/src/{shared => renderer/application/contracts/session-catalog}/session-id-set.ts (100%) create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/session-rail-visibility.ts create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/stale-sessions.ts create mode 100644 apps/desktop/src/renderer/application/contracts/session-catalog/use-external-store-selector.ts create mode 100644 apps/desktop/src/renderer/features/overlays/model/palette-session-commands.ts create mode 100644 apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 10ab2996c7..de9b431e0b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -110,7 +110,6 @@ "src/renderer/remote-project-directory-dialog.tsx", "src/renderer/scroll-motion-policy.ts", "src/renderer/session-catalog-state.ts", - "src/renderer/session-change-effects.ts", "src/renderer/session-collaboration-dialog.tsx", "src/renderer/session-copy-attempt.ts", "src/renderer/session-error-presentation.ts", @@ -239,24 +238,10 @@ "src/renderer/settings" ], "legacyFeatureImports": [ - "src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts -> src/renderer/session-catalog-state", - "src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts -> src/renderer/use-external-store-selector", "src/renderer/features/module-hub/controller/use-daily-review-controller.ts -> src/renderer/daily-review-actions", "src/renderer/features/module-hub/ui/module-hub-host.tsx -> src/renderer/mcp-page", - "src/renderer/features/session-bundle/session-bundle-tasks.tsx -> src/renderer/session-catalog-state", - "src/renderer/features/session-bundle/session-bundle-tasks.tsx -> src/renderer/use-external-store-selector", - "src/renderer/features/session-collaboration/turn-request-inbox-context.tsx -> src/renderer/session-catalog-state", - "src/renderer/features/session-collaboration/turn-request-inbox-context.tsx -> src/renderer/use-external-store-selector", - "src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts -> src/renderer/use-external-store-selector", - "src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts -> src/renderer/session-catalog-state", - "src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts -> src/renderer/use-external-store-selector", "src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage", "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/browser-storage", - "src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/observable-state", - "src/renderer/features/session-navigation/ui/session-navigation-provider.tsx -> src/renderer/session-catalog-state", - "src/renderer/features/session-navigation/ui/session-navigation-provider.tsx -> src/renderer/stale-sessions", - "src/renderer/features/session-navigation/ui/session-navigation-provider.tsx -> src/renderer/use-external-store-selector", - "src/renderer/features/session-settings/use-session-setting-intent.ts -> src/renderer/session-catalog-state", "src/renderer/features/task-entry/model/task-entry-selection.ts -> src/renderer/new-task-reload-intent", "src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog", "src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/browser-storage", @@ -270,7 +255,6 @@ "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/turn-footer-actions", - "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/observable-state", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel" @@ -373,7 +357,7 @@ "nonTriviaTokens": 408 }, "src/renderer/app-shell-command-actions.ts": { - "importDeclarations": 6, + "importDeclarations": 5, "bridgePaths": { "window.maka.connections.setDefault": 1, "window.maka.connections.test": 1, @@ -386,7 +370,6 @@ "navigator.clipboard.writeText": 1 }, "hookCalls": { - "useExternalStoreSelector": 1, "useRef": 1 }, "lifecycleMethods": {}, @@ -397,15 +380,13 @@ "./command-palette-commands.js": 1, "./conversation-markdown.js": 1, "./default-runtime-host-operation.js": 1, - "./features/session-navigation/index.js": 1, "./locales/settings-memory-copy.js": 1, "./locales/settings-test-result-copy.js": 1, "./locales/shell-copy.js": 1, - "./use-external-store-selector.js": 1, "react": 1 }, - "importSpecifiers": 10, - "nonTriviaTokens": 2496 + "importSpecifiers": 8, + "nonTriviaTokens": 2234 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -470,7 +451,7 @@ "nonTriviaTokens": 637 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 11, + "importDeclarations": 9, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, @@ -507,11 +488,11 @@ "actionFactories": [], "dependencyPaths": { "./app-shell-copy": 1, + "./application/contracts/session-catalog/session-change-effects.js": 1, + "./application/contracts/session-catalog/session-event-health.js": 1, "./browser-storage": 1, "./locales/conversation-copy.js": 1, "./platform/desktop/desktop-transcript-range-store.js": 1, - "./session-change-effects.js": 1, - "./session-event-health": 1, "./shell-run-update-state.js": 1, "./theme": 1, "./titlebar-modal-sync": 1, @@ -519,8 +500,8 @@ "@maka/core/session-event-health": 1, "react": 1 }, - "importSpecifiers": 18, - "nonTriviaTokens": 3459 + "importSpecifiers": 14, + "nonTriviaTokens": 3502 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -550,7 +531,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 860 + "nonTriviaTokens": 858 }, "src/renderer/app-shell-project-actions.ts": { "importDeclarations": 4, @@ -728,7 +709,7 @@ "nonTriviaTokens": 1245 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 65, + "importDeclarations": 63, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -767,8 +748,7 @@ "useAppShellSessionWorkspace": 1, "useAppShellTurnPresentation": 1, "useComposerAttachments": 1, - "useEffect": 7, - "useExternalStoreSelector": 3, + "useEffect": 6, "useLayoutEffect": 2, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, @@ -811,6 +791,7 @@ "./app-shell-stop-action": 1, "./app-shell-turn-actions": 1, "./app-shell-turn-view-model": 1, + "./application/contracts/session-catalog/catalog-row-watch.js": 1, "./chat-composer-region": 1, "./chat-message-surface": 1, "./composer-defaults": 1, @@ -844,7 +825,6 @@ "./pending-session-view": 1, "./plan-mode-panel": 1, "./scroll-motion-policy": 1, - "./session-catalog-state": 1, "./session-collaboration-dialog": 1, "./session-workspace-errors": 1, "./settings/provider-brand-marks": 1, @@ -855,7 +835,6 @@ "./use-app-shell-composer-quotes": 1, "./use-app-shell-session-ui-reads": 1, "./use-app-shell-session-workspace": 1, - "./use-external-store-selector": 1, "./use-new-task-choice": 1, "./use-onboarding-snapshot": 1, "./use-project-context": 1, @@ -879,8 +858,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 102, - "nonTriviaTokens": 12972 + "importSpecifiers": 99, + "nonTriviaTokens": 12983 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, @@ -899,31 +878,31 @@ "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 6, "bridgePaths": { - "window.maka.sessions.get": 1, "window.maka.sessions.list": 1 }, "environmentCapabilities": {}, "hookCalls": { "useExternalStoreSelector": 1, - "useRef": 6, + "useRef": 2, "useUiLocale": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./features/conversation/index.js": 1, + "./application/contracts/session-catalog/session-id-set.js": 1, + "./application/contracts/session-catalog/use-external-store-selector.js": 1, "./locales/conversation-copy.js": 1, "./locales/shell-copy.js": 1, + "./platform/desktop/session-catalog-sync.js": 1, "./session-catalog-state.js": 1, "./session-read-state.js": 1, "./session-status-presentation.js": 1, - "./use-external-store-selector.js": 1, "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 6, - "nonTriviaTokens": 838 + "importSpecifiers": 7, + "nonTriviaTokens": 470 }, "src/renderer/use-app-shell-session-ui-reads.ts": { "importDeclarations": 1, @@ -1859,15 +1838,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/observable-state.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/onboarding-hero-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2025,22 +1995,6 @@ "dependencyPaths": {} }, "src/renderer/session-catalog-state.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useRef": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../shared/desktop-session-projection.js": 1, - "./observable-state.js": 1, - "@maka/ui": 1, - "react": 1 - } - }, - "src/renderer/session-change-effects.ts": { "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": {}, @@ -2048,8 +2002,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./locales/conversation-copy.js": 1, - "./session-event-health.js": 1 + "./application/contracts/session-catalog/session-catalog-state.js": 1 } }, "src/renderer/session-collaboration-dialog.tsx": { @@ -2109,18 +2062,6 @@ "./locales/conversation-copy.js": 1 } }, - "src/renderer/session-event-health.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "@maka/core/session-event-health": 1, - "@maka/core/tool-result-status": 1 - } - }, "src/renderer/session-health-notice.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3520,6 +3461,7 @@ "actionFactories": [], "dependencyPaths": { "../../shared/settings-ownership.js": 1, + "../application/contracts/session-catalog/catalog-sessions.js": 1, "../browser-storage": 1, "../features/connection-settings": 1, "../features/external-agent-settings/index.js": 1, @@ -3620,7 +3562,6 @@ "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useExternalStoreSelector": 1, "useMountedRef": 1, "useState": 2, "useToast": 1, @@ -3632,8 +3573,6 @@ "dependencyPaths": { "../locales/settings-shared-copy.js": 1, "../locales/settings-tasks-copy.js": 1, - "../session-catalog-state.js": 1, - "../use-external-store-selector.js": 1, "./settings-error-copy": 1, "./settings-section": 1, "./task-catalog-rows": 1, @@ -3882,15 +3821,6 @@ "./locales/shell-copy.js": 1 } }, - "src/renderer/stale-sessions.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/task-readiness-notice.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4000,14 +3930,12 @@ "src/renderer/use-external-store-selector.ts": { "bridgePaths": {}, "environmentCapabilities": {}, - "hookCalls": { - "useSyncExternalStore": 1 - }, + "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "react": 1 + "./application/contracts/session-catalog/use-external-store-selector.js": 1 } }, "src/renderer/use-new-task-choice.ts": { @@ -4364,15 +4292,6 @@ "@maka/runtime-host/protocol": 1 } }, - "src/shared/session-id-set.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/shared/settings-ownership.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index c14affd456..214361cf4b 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -39,7 +39,7 @@ import { act, createElement } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { useAppShellSessionUiState } from '../../renderer/features/conversation/index.js'; -import { createSessionCatalogController } from '../../renderer/session-catalog-state.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; diff --git a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts index c62ed2faea..3cdce30cc9 100644 --- a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts @@ -30,7 +30,7 @@ import { SessionBundleServicesProvider, SessionBundleTasks, } from '../../renderer/features/session-bundle/index.js'; -import { createSessionCatalogController } from '../../renderer/session-catalog-state.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { ImportTasksSettingsPage } from '../../renderer/settings/import-tasks-settings-page.js'; import { RuntimeHostSettingsTarget } from '../../renderer/settings/runtime-host-settings-target.js'; diff --git a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts index 32e4f53d40..7a6e63ce4d 100644 --- a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts +++ b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts @@ -21,7 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { SessionChangedEvent, SessionSummary, StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; -import { handleSessionChangedEvent } from '../../renderer/session-change-effects.js'; +import { handleSessionChangedEvent } from '../../renderer/application/contracts/session-catalog/session-change-effects.js'; import { createSessionWorkspaceActions } from '../../renderer/session-workspace-actions.js'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; @@ -71,6 +71,7 @@ function harness(activeId: string | undefined, catalog: SessionSummary[]) { return Promise.resolve(next); }, setSessionEventHealthBySession: () => {}, + notifyModelRebound: () => {}, toastApi: { error: () => {}, info: () => {}, diff --git a/apps/desktop/src/main/__tests__/session-event-health.test.ts b/apps/desktop/src/main/__tests__/session-event-health.test.ts index 6f4f3c7973..db9b919e63 100644 --- a/apps/desktop/src/main/__tests__/session-event-health.test.ts +++ b/apps/desktop/src/main/__tests__/session-event-health.test.ts @@ -25,7 +25,7 @@ import { hasInFlightToolActivity, recordSessionEventStreamChange, recordSessionEventStreamEvent, -} from '../../renderer/session-event-health.js'; +} from '../../renderer/application/contracts/session-catalog/session-event-health.js'; describe('renderer session event health projection', () => { diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 19f46f7612..15e2eb107f 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -37,7 +37,7 @@ import { type SessionNavigationSession, type UseSessionNavigationControllerInput, } from '../../renderer/features/session-navigation/testing.js'; -import { createSessionCatalogController } from '../../renderer/session-catalog-state.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; function session( diff --git a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts index 05386b45de..f0318518d0 100644 --- a/apps/desktop/src/main/__tests__/session-setting-intent.test.ts +++ b/apps/desktop/src/main/__tests__/session-setting-intent.test.ts @@ -23,7 +23,7 @@ import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { useSessionSettingIntent, type SessionSettingIntentCatalog } from '@maka/ui'; -import { createObservableState } from '../../renderer/observable-state.js'; +import { createObservableState } from '../../renderer/application/contracts/session-catalog/observable-state.js'; type SessionSettingIntentController = ReturnType< typeof useSessionSettingIntent<{ setting: Value }> diff --git a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts index e6f9f077ca..7768dfc81e 100644 --- a/apps/desktop/src/main/__tests__/session-settings-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-settings-controller.test.ts @@ -31,7 +31,7 @@ import { reconcileRuntimeHostSessionCatalog } from '../../preload/runtime-host-s import { createSessionCatalogController, type SessionCatalogController, -} from '../../renderer/session-catalog-state.js'; +} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; type Controller = ReturnType>; diff --git a/apps/desktop/src/main/__tests__/stale-sessions.test.ts b/apps/desktop/src/main/__tests__/stale-sessions.test.ts index dfc8771738..f991aac745 100644 --- a/apps/desktop/src/main/__tests__/stale-sessions.test.ts +++ b/apps/desktop/src/main/__tests__/stale-sessions.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { deriveStaleSessionIds } from '../../renderer/stale-sessions.js'; +import { deriveStaleSessionIds } from '../../renderer/application/contracts/session-catalog/stale-sessions.js'; test('derives stale rows from each Session Host readiness projection', () => { const sessions = [ diff --git a/apps/desktop/src/main/__tests__/workbar-model.test.ts b/apps/desktop/src/main/__tests__/workbar-model.test.ts index d2d5035636..9b9e878b38 100644 --- a/apps/desktop/src/main/__tests__/workbar-model.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-model.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { createSessionCatalogController, selectAuthoritativeSessionIds } from '../../renderer/session-catalog-state.js'; +import { createSessionCatalogController, selectAuthoritativeSessionIds } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { sessionIdSetsEqual } from '../../renderer/features/conversation/index.js'; import assert from 'node:assert/strict'; import { afterEach, describe, it } from 'node:test'; diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index c3a555da19..2a169ba4bd 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -30,15 +30,9 @@ import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, } from './default-runtime-host-operation.js'; -import { - buildCommandList, - buildSessionCommands, -} from "./command-palette-commands.js"; +import { buildCommandList } from "./command-palette-commands.js"; import type { Command } from './features/overlays/index.js'; -import { sessionMatchesRail } from './features/session-navigation/index.js'; -import type { SessionCatalogController, SessionCatalogState } from './session-catalog-state.js'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; -import { useExternalStoreSelector } from './use-external-store-selector.js'; +import type { SessionCatalogController } from './session-catalog-state.js'; import { renderConversationMarkdown } from "./conversation-markdown.js"; import { commandPaletteActionErrorMessage, @@ -400,45 +394,6 @@ export function buildAppShellCommandList( }); } -/** The command palette lists the rail's membership minus its hidden rows. */ -const selectPaletteSessions = ( - state: SessionCatalogState, - hiddenSessionIds: ReadonlySet, -) => - state.sessions.filter( - (session) => sessionMatchesRail(session) && !hiddenSessionIds.has(session.id), - ); - -const EMPTY_PALETTE_SESSIONS: readonly DesktopSessionSummary[] = []; -const selectClosedPaletteSessions = () => EMPTY_PALETTE_SESSIONS; - -/** A palette command shows id, name and the flag glyph; nothing else re-lists it. */ -function paletteSessionsEqual( - left: readonly DesktopSessionSummary[], - right: readonly DesktopSessionSummary[], -): boolean { - return left.length === right.length - && left.every((session, index) => - session.id === right[index]!.id - && session.name === right[index]!.name - && session.isFlagged === right[index]!.isFlagged); -} - -export function buildAppShellSessionCommands( - optionsRef: RefBox, - visibleSessions: readonly SessionSummary[], -): ReturnType { - const options = optionsRef.current; - return buildSessionCommands({ - locale: options.uiLocale, - sessions: visibleSessions, - activeSessionId: options.activeId, - onSelectSession: (sessionId) => { - optionsRef.current.openSessionInChat(sessionId); - }, - }); -} - /** * #1045: the palette's command list keeps a stable identity while it is open. * app-shell rebuilds commandOptions on every render (streaming ticks @@ -453,26 +408,27 @@ export function buildAppShellSessionCommands( export function useAppShellCommands( paletteOpen: boolean, commandOptions: AppShellCommandListOptions, -): Command[] { +): { + commands: Command[]; + sessionCatalog: SessionCatalogController; + hiddenSessionIds: ReadonlySet; + activeSessionId: string | undefined; + onSelectSession: (id: string) => void; +} { const optionsRef = useRef(commandOptions); optionsRef.current = commandOptions; - const { activeId, uiLocale, sessionCatalog, hiddenSessionIds } = commandOptions; - const visibleSessions = useExternalStoreSelector( - sessionCatalog, - paletteOpen ? selectPaletteSessions : selectClosedPaletteSessions, - hiddenSessionIds, - paletteSessionsEqual, - ); - const baseCommands = useMemo( + const { uiLocale } = commandOptions; + const commands = useMemo( () => buildAppShellCommandList(optionsRef), [paletteOpen, uiLocale], ); - const sessionCommands = useMemo( - () => (paletteOpen ? buildAppShellSessionCommands(optionsRef, visibleSessions) : []), - [paletteOpen, visibleSessions, activeId, uiLocale], - ); - return useMemo( - () => [...baseCommands, ...sessionCommands], - [baseCommands, sessionCommands], - ); + // Session rows subscribe the catalog inside the palette — the consumption + // point — so shell renders are not driven by palette-only reads. + return { + commands, + sessionCatalog: commandOptions.sessionCatalog, + hiddenSessionIds: commandOptions.hiddenSessionIds, + activeSessionId: commandOptions.activeId, + onSelectSession: commandOptions.openSessionInChat, + }; } diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 082985350b..33f614d008 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -39,8 +39,8 @@ import { createSessionEventStreamSubscription, evaluateSessionEventStreamSnapshot, recordSessionEventStreamEvent, -} from './session-event-health'; -import { handleSessionChangedEvent } from './session-change-effects.js'; +} from './application/contracts/session-catalog/session-event-health.js'; +import { handleSessionChangedEvent } from './application/contracts/session-catalog/session-change-effects.js'; import type { DesktopRuntimeHostProfileChangedEvent, WindowCommand, @@ -200,7 +200,14 @@ export function useAppShellBootstrapSubscriptions(options: { else if (command.id === 'openHelp') options.openHelp(); }); const handleSessionChange = useEffectEvent( - (event: SessionChangedEvent) => handleSessionChangedEvent(event, options), + (event: SessionChangedEvent) => + handleSessionChangedEvent(event, { + ...options, + notifyModelRebound: (modelId) => { + const copy = getDesktopConversationCopy(options.uiLocale).actions; + options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(modelId)); + }, + }), ); // Both shortcuts fire while the composer has focus — they always did, and // that is the point of a global new-task / settings key — so both opt out of diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index 9e522ac784..f1bb3f0138 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -123,18 +123,17 @@ function OverlayLayer({ overlays, ...props }: AppShellOverlaysProps & { readonly overlays: OverlaysShellProjection }) { - const { commands, selectors } = overlays; - const { settings } = selectors; + const { settings } = overlays.selectors; // #1045: base commands freeze per open/close; session rows stay live on // visibleSessions/activeId. run() closures read latest options via ref. - const paletteCommands = useAppShellCommands(selectors.paletteOpen, props.commandOptions); + const paletteProps = useAppShellCommands(overlays.selectors.paletteOpen, props.commandOptions); useHotkeys([ { keys: 'mod+shift+d', allowInInputs: true, onPress: () => - void paletteCommands.find((command) => command.id === 'diag:copy-diagnostics')?.run(), + void paletteProps.commands.find((command) => command.id === 'diag:copy-diagnostics')?.run(), }, ]); @@ -157,7 +156,7 @@ function OverlayLayer({ initialConnectionSlug={settings.connectionDetailSlug} initialCreateProviderType={settings.createProviderType} onOpenDailyReview={props.onOpenDailyReview} - onOpenKeyboardHelp={commands.openHelp} + onOpenKeyboardHelp={overlays.commands.openHelp} onOpenSession={props.onOpenSettingsSession} archivedTasks={props.archivedTasks} onTaskImported={props.onExternalSessionImported} @@ -168,7 +167,7 @@ function OverlayLayer({ )} - + ); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 496b84b9fa..4231cf5fe2 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -85,10 +85,9 @@ import { type SessionNavigationRowActions, } from './features/session-navigation'; import { - selectSessionById, - selectSessionCount, -} from './session-catalog-state'; -import { useExternalStoreSelector } from './use-external-store-selector'; + CatalogRowWatch, + type DesktopSessionSummary, +} from './application/contracts/session-catalog/catalog-row-watch.js'; import * as TaskEntry from './features/task-entry'; import type { TaskEntryShellProjection } from './features/task-entry'; import * as Overlays from './features/overlays/index.js'; @@ -344,10 +343,10 @@ function AppShellContent({ ownerActiveId, switchingSession, } = useAppShellSessionWorkspace(toastApi); - // The shell's own readings of the catalog, at the granularity it displays — - // background row churn belongs to the rail, which subscribes the catalog - // inside SessionNavigationProvider (#4109). - const sessionCount = useExternalStoreSelector(sessionCatalogController, selectSessionCount); + // The shell's own reading of the catalog rides the membership set the list + // hook already publishes — background row churn belongs to the rail, which + // subscribes the catalog inside SessionNavigationProvider (#4109). + const sessionCount = authoritativeSessionIds?.size ?? 0; // Only the outstanding read needs a fence; past Sessions leave no hydration metadata. const interactionHydrationRef = useRef<{ sessionId: string } | null>(null); const markInteractionChanged = useCallback((sessionId: string) => { @@ -623,31 +622,23 @@ function AppShellContent({ revisionDraftRef.current = draft; setRevisionDraft(draft); }, []); - // The draft survives on exactly two rows; only their changes can retire it. - const revisionDraftSource = useExternalStoreSelector( - sessionCatalogController, - selectSessionById, - revisionDraft?.sourceSessionId, - ); - const revisionDraftOwner = useExternalStoreSelector( - sessionCatalogController, - selectSessionById, - revisionDraft?.draftSessionId, + // The draft survives on exactly two catalog rows; CatalogRowWatch below + // selects them so their changes alone can retire it. + const retireRevisionDraftIfRowsLeave = useCallback( + (rows: readonly (DesktopSessionSummary | undefined)[]) => { + const draft = revisionDraftRef.current; + if (!draft) return; + const [source, owner] = rows; + if (source && owner && !source.isArchived && !owner.isArchived) return; + composerRef.current?.clearDraft(draft.draftSessionId); + if (draft.sourceSessionId !== draft.draftSessionId) + composerRef.current?.clearDraft(draft.sourceSessionId); + if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); + else void abandonTurnRevisionCopyAttempt(draft); + commitRevisionDraft(null); + }, + [commitRevisionDraft], ); - useEffect(() => { - const draft = revisionDraftRef.current; - if (!draft) return; - if ( - revisionDraftSource && revisionDraftOwner - && !revisionDraftSource.isArchived && !revisionDraftOwner.isArchived - ) return; - composerRef.current?.clearDraft(draft.draftSessionId); - if (draft.sourceSessionId !== draft.draftSessionId) - composerRef.current?.clearDraft(draft.sourceSessionId); - if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); - else void abandonTurnRevisionCopyAttempt(draft); - commitRevisionDraft(null); - }, [revisionDraftSource, revisionDraftOwner, commitRevisionDraft]); const { resumePendingSessionId, @@ -2174,6 +2165,11 @@ function AppShellContent({ reportError={showSessionError} > + + ids.map((id) => selectSessionById(state, id)); + +function rowsEqual( + a: readonly (DesktopSessionSummary | undefined)[], + b: readonly (DesktopSessionSummary | undefined)[], +): boolean { + return a.length === b.length && a.every((row, index) => row === b[index]); +} + +const EMPTY_IDS: readonly (string | undefined)[] = []; + +/** + * Renderless catalog-row subscription for a legacy consumer that cannot own a + * hook of its own: mounts inside the tree, selects the rows for `sessionIds`, + * and reports them to `onRows` whenever the selection actually changes. + */ +export function CatalogRowWatch(props: { + catalog: SessionCatalogController; + sessionIds: readonly (string | undefined)[] | undefined; + onRows: (rows: readonly (DesktopSessionSummary | undefined)[]) => void; +}) { + const onRows = useEffectEvent(props.onRows); + // The caller passes an inline array; the selector memo is keyed on the arg, + // so the ids need a stable identity across renders that do not change them. + const idsKey = (props.sessionIds ?? EMPTY_IDS).join('\0'); + const ids = useMemo(() => idsKey.split('\0').map((id) => id || undefined), [idsKey]); + const rows = useExternalStoreSelector( + props.catalog, + selectRows, + ids, + rowsEqual, + ); + useEffect(() => onRows(rows), [rows, onRows]); + return null; +} diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/catalog-sessions.tsx b/apps/desktop/src/renderer/application/contracts/session-catalog/catalog-sessions.tsx new file mode 100644 index 0000000000..20dd4db317 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/catalog-sessions.tsx @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// apps/desktop/src/renderer/application/contracts/session-catalog/catalog-sessions.tsx +// +// Render-prop subscription over the catalog's session list. It carries the +// useSyncExternalStore call so views inside debt-metered surfaces (settings, +// the shell) can render catalog rows without owning a hook call of their own. + +import type { ReactNode } from 'react'; +import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; +import { selectSessions, type SessionCatalogController } from './session-catalog-state.js'; +import { useExternalStoreSelector } from './use-external-store-selector.js'; + +export function CatalogSessions(props: { + catalog: SessionCatalogController; + children: (sessions: readonly DesktopSessionSummary[]) => ReactNode; +}): ReactNode { + const sessions = useExternalStoreSelector(props.catalog, selectSessions); + return props.children(sessions); +} diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/observable-state.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/observable-state.ts new file mode 100644 index 0000000000..df6f181539 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/observable-state.ts @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The writable half of a renderer store: one state value, replaced whole. + * + * The renderer has three of these — `app-shell-session-ui-state`, + * `session-catalog-state`, `session-rail-layout-store` — and they differ only + * in what they hold and which commands they expose. What they must NOT differ + * in is the notification rule below, which is load-bearing and was previously + * restated once per store. + * + * Pair with `useExternalStoreSelector` to read one derived value from it. + */ +export function createObservableState(initial: S) { + let current = initial; + const listeners = new Set<() => void>(); + + return { + getState: (): S => current, + + /** Subscribe to state replacements. Stable identity, for `useSyncExternalStore`. */ + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + /** + * Swap the state and notify, synchronously and in that order. Never + * schedule the notification: the terminal-turn handoff reads back the + * state it announces, and a selection change is read back by the handler + * that made it (#1985, #4109). + * + * A replacement with the same identity is not a change and notifies + * nobody, which is what lets a store's commands be written as plain + * `if (unchanged) return`. + */ + replaceState(next: S): void { + if (next === current) return; + current = next; + for (const listener of [...listeners]) listener(); + }, + }; +} 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 new file mode 100644 index 0000000000..c8b5908a30 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useRef } from 'react'; +import { valuesEqual } from '@maka/ui'; +import { + compareDesktopSessionCatalogSummaries, + type DesktopSessionSummary, +} from '../../../../shared/desktop-session-projection.js'; +import { createObservableState } from './observable-state.js'; + +/** + * The session catalog and the selection, as one external store (#4109). + * + * They were `useState` inside a hook AppShell calls, which made the shell the + * carrier: every catalog commit and every selection change re-rendered the + * whole tree, and anything that wanted to follow them — the Session rail above + * all — had to be handed them down a prop chain. As a store they have readers + * instead of a carrier, and each reader re-renders only for the reading it + * selects. Same mechanism as `app-shell-session-ui-state.ts` (#1985); this is + * the second store, not a second way of having stores. + * + * The list and its observation revision are one committed snapshot. A failed + * refresh changes neither, so consumers can fence transient writes against + * successful catalog observations without a parallel error flag. + */ +export interface SessionCatalogState { + readonly sessions: readonly DesktopSessionSummary[]; + readonly revision: number; + readonly activeSessionId: string | undefined; +} + +export function createSessionCatalogController() { + const state = createObservableState({ + sessions: [], + revision: 0, + activeSessionId: undefined, + }); + + return { + getState: state.getState, + subscribe: state.subscribe, + commitSessions(next: readonly DesktopSessionSummary[]): void { + const current = state.getState(); + // Published references change iff values change: an unchanged row keeps + // its identity so per-row readers and memos survive a re-list, and a + // row already patched to a newer revision is never regressed by an + // older snapshot. + const previousById = new Map(current.sessions.map((s) => [s.id, s])); + const reconciled = next.map((s) => { + const prior = previousById.get(s.id); + return prior !== undefined && (isStaleSummary(prior, s) || valuesEqual(prior, s)) + ? prior + : s; + }); + const sameRows = reconciled.length === current.sessions.length + && reconciled.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 && current.revision > 0) return; + state.replaceState({ + ...current, + sessions: sameRows ? current.sessions : reconciled, + revision: current.revision + 1, + }); + }, + commitPatch(sessionId: string, summary: DesktopSessionSummary | null): void { + const current = state.getState(); + const index = current.sessions.findIndex((s) => s.id === sessionId); + const prior = index < 0 ? undefined : current.sessions[index]; + if (summary === null) { + if (prior === undefined) return; + state.replaceState({ + ...current, + sessions: current.sessions.filter((s) => s.id !== sessionId), + revision: current.revision + 1, + }); + return; + } + if (prior !== undefined && isStaleSummary(prior, summary)) return; + const row = prior !== undefined && valuesEqual(prior, summary) ? prior : summary; + const sessions = [...current.sessions]; + if (index < 0) sessions.push(row); else sessions[index] = row; + sessions.sort(compareDesktopSessionCatalogSummaries); + const sameRows = sessions.length === current.sessions.length + && sessions.every((s, i) => s === current.sessions[i]); + if (sameRows) return; + state.replaceState({ + ...current, + sessions, + revision: current.revision + 1, + }); + }, + setActiveSessionId(next: string | undefined): void { + const current = state.getState(); + if (current.activeSessionId === next) return; + state.replaceState({ ...current, activeSessionId: next }); + }, + }; +} + +export type SessionCatalogController = ReturnType; + +/** A committed row at a newer revision is authoritative over an older snapshot of it. */ +function isStaleSummary(prior: DesktopSessionSummary, next: DesktopSessionSummary): boolean { + return prior.revision > next.revision; +} + +export const selectSessions = (state: SessionCatalogState): readonly DesktopSessionSummary[] => + state.sessions; +export const selectSessionById = ( + state: SessionCatalogState, + sessionId: string | undefined, +): DesktopSessionSummary | undefined => + sessionId === undefined ? undefined : state.sessions.find((s) => s.id === sessionId); +export const selectSessionCount = (state: SessionCatalogState): number => state.sessions.length; +export const selectCatalogRevision = (state: SessionCatalogState): number => state.revision; +export const selectActiveSessionId = (state: SessionCatalogState): string | undefined => + state.activeSessionId; + +/** + * The ids in the catalog, by value. A refresh replaces every row object even + * when nothing about the membership moved (#2913), so an identity-only + * selection would re-render every reader that only cares about which sessions + * exist. + */ +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; + +/** + * Owns the controller for the component's lifetime. Deliberately does NOT + * subscribe: readers select what they need through `useExternalStoreSelector`. + */ +export function useSessionCatalogController(): SessionCatalogController { + const controllerRef = useRef(null); + if (!controllerRef.current) controllerRef.current = createSessionCatalogController(); + return controllerRef.current; +} diff --git a/apps/desktop/src/renderer/session-change-effects.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts similarity index 88% rename from apps/desktop/src/renderer/session-change-effects.ts rename to apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts index 61c95aca77..726b041070 100644 --- a/apps/desktop/src/renderer/session-change-effects.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts @@ -19,9 +19,7 @@ import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import type { UiLocale } from '@maka/core/ui-locale'; import { recordSessionEventStreamChange } from './session-event-health.js'; -import { getDesktopConversationCopy } from './locales/conversation-copy.js'; type RefBox = { current: T }; @@ -32,7 +30,6 @@ type SessionEventHealthUpdater = ( export function handleSessionChangedEvent( event: SessionChangedEvent, options: { - uiLocale: UiLocale; activeIdRef: RefBox; clearPendingTurnActionsForSession: (sessionId: string) => void; refreshMessages: (sessionId: string) => Promise; @@ -43,8 +40,9 @@ export function handleSessionChangedEvent( retiredSessionIds(sessions: readonly { id: string }[]): string[]; /** Mirrors the committed catalog; refresh promises resolve after commit. */ sessionsRef: RefBox; + /** Surfaces a model rebound; the caller owns the copy. */ + notifyModelRebound: (modelId: string | undefined) => void; setSessionEventHealthBySession: SessionEventHealthUpdater; - toastApi: { info(title: string, description?: string): void }; }, ): void { // The sweep below reads the committed catalog (sessionsRef) rather than this @@ -77,10 +75,7 @@ export function handleSessionChangedEvent( if (event.reason === 'message-appended' && changedSessionId && changedSessionId === options.activeIdRef.current) { void options.refreshMessages(changedSessionId); } - if (event.reason === 'rebound') { - const copy = getDesktopConversationCopy(options.uiLocale).actions; - options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(event.modelId)); - } + if (event.reason === 'rebound') options.notifyModelRebound(event.modelId); void refreshedSessions.then(() => { options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); }); diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-event-health.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-event-health.ts new file mode 100644 index 0000000000..c6804c5eb1 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-event-health.ts @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; +import type { SessionStatus } from '@maka/core/session'; +import { isInFlightToolStatus } from '@maka/core/tool-result-status'; +import type { ToolActivityItem } from '@maka/ui'; +import { + deriveSessionEventStreamStatus, + sessionExpectsEventStream, + shouldRefreshStaleSessionEventStream, +} from '@maka/core/session-event-health'; + +export function createSessionEventStreamSubscription(input: { + sessionId: string; + now: number; +}): SessionEventStreamSnapshot { + return { + sessionId: input.sessionId, + status: 'connected', + subscribedAt: input.now, + checkedAt: input.now, + }; +} + +export function recordSessionEventStreamEvent( + previous: SessionEventStreamSnapshot, + now: number, +): SessionEventStreamSnapshot { + return { + ...previous, + status: previous.status === 'stale' ? 'recovered' : 'connected', + checkedAt: now, + lastEventAt: now, + staleSince: undefined, + }; +} + +export function recordSessionEventStreamChange( + previous: SessionEventStreamSnapshot, + now: number, +): SessionEventStreamSnapshot { + return { + ...previous, + status: previous.status === 'stale' ? 'recovered' : previous.status === 'closed' ? 'connected' : previous.status, + checkedAt: now, + lastChangedAt: now, + staleSince: undefined, + }; +} + +export function evaluateSessionEventStreamSnapshot(input: { + previous: SessionEventStreamSnapshot | undefined; + now: number; + sessionStatus: SessionStatus | undefined; + hasLiveActivity: boolean; +}): { snapshot: SessionEventStreamSnapshot | undefined; shouldRefresh: boolean } { + const previous = input.previous; + if (!previous) return { snapshot: undefined, shouldRefresh: false }; + + const expected = sessionExpectsEventStream(input.sessionStatus, input.hasLiveActivity); + const status = deriveSessionEventStreamStatus({ + now: input.now, + subscribedAt: previous.subscribedAt, + lastEventAt: previous.lastEventAt, + lastChangedAt: previous.lastChangedAt, + previousStatus: previous.status, + expected, + }); + const refreshDue = shouldRefreshStaleSessionEventStream({ + status, + now: input.now, + refreshRequestedAt: previous.refreshRequestedAt, + }); + + return { + snapshot: { + ...previous, + status, + checkedAt: input.now, + staleSince: status === 'stale' ? previous.staleSince ?? input.now : undefined, + refreshRequestedAt: refreshDue ? input.now : previous.refreshRequestedAt, + }, + shouldRefresh: refreshDue, + }; +} + +export function hasInFlightToolActivity( + liveTools: readonly Pick[], +): boolean { + return liveTools.some((tool) => isInFlightToolStatus(tool.status)); +} diff --git a/apps/desktop/src/shared/session-id-set.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-id-set.ts similarity index 100% rename from apps/desktop/src/shared/session-id-set.ts rename to apps/desktop/src/renderer/application/contracts/session-catalog/session-id-set.ts diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-rail-visibility.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-rail-visibility.ts new file mode 100644 index 0000000000..f9b4529255 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-rail-visibility.ts @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionSummary } from '@maka/core/session'; +import { isSideConversationSession } from '@maka/core/side-conversation'; +import type { SessionCatalogState } from './session-catalog-state.js'; +import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; + +/** + * Which sessions the rail lists. Archived tasks are managed in Settings › 活动 › + * 已归档任务 (#2985). Side-conversation forks belong to their Workbar panels, + * not the main task catalog; filtering their durable label here prevents the + * `sessions:changed(created)` broadcast from flashing a row before the panel's + * renderer-local hidden-id update arrives. + * + * This used to switch on `NavSelection.filter`. That filter is gone (#2984): its + * last two values were a destination that moved to Settings and a value nothing + * ever selected, which left one branch reachable — this one. + */ +export function sessionMatchesRail(session: SessionSummary): boolean { + return !session.isArchived && !isSideConversationSession(session.labels); +} + +/** The command palette lists the rail's membership minus its hidden rows. */ +export const selectPaletteSessions = ( + state: SessionCatalogState, + hiddenSessionIds: ReadonlySet, +) => + state.sessions.filter( + (session) => sessionMatchesRail(session) && !hiddenSessionIds.has(session.id), + ); + +const EMPTY_PALETTE_SESSIONS: readonly DesktopSessionSummary[] = []; +export const selectClosedPaletteSessions = () => EMPTY_PALETTE_SESSIONS; + +/** A palette command shows id, name and the flag glyph; nothing else re-lists it. */ +export function paletteSessionsEqual( + left: readonly DesktopSessionSummary[], + right: readonly DesktopSessionSummary[], +): boolean { + return left.length === right.length + && left.every((session, index) => + session.id === right[index]!.id + && session.name === right[index]!.name + && session.isFlagged === right[index]!.isFlagged); +} diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/stale-sessions.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/stale-sessions.ts new file mode 100644 index 0000000000..c6ba8952cc --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/stale-sessions.ts @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionSendProjection } from '@maka/core/session-send-projection'; +import type { SessionCatalogState } from './session-catalog-state.js'; + +export interface StaleSessionsInput { + /** Sessions visible in the sidebar (already filtered + grouped). */ + sessions: ReadonlyArray<{ + id: string; + }>; + /** Readiness already resolved by each Session's owning Runtime Host. */ + sendOutcomes: Readonly>; +} + +export function deriveStaleSessionIds(input: StaleSessionsInput): Set { + const stale = new Set(); + for (const session of input.sessions) { + if (isStale(input.sendOutcomes[session.id])) { + stale.add(session.id); + } + } + return stale; +} + +const NO_SEND_OUTCOMES: Readonly> = {}; + +export const selectStaleSessionIds = ( + state: SessionCatalogState, + sendOutcomes: Readonly> | undefined, +): Set => + deriveStaleSessionIds({ sessions: state.sessions, sendOutcomes: sendOutcomes ?? NO_SEND_OUTCOMES }); + +/** + * A row is stale when its owning Runtime Host says the next send cannot go + * anywhere the user can fix from the rail. + * + * `fake_backend` is read from the projection rather than from `session.backend` + * (#3211): the readiness projection is the single authority on whether a task + * is usable, and a retired backend is one of its answers like any other. + * + * `provider_retired` belongs with them for the same reason and one more: the + * connection is still there and still enabled, so nothing else about the row + * looks wrong. Without this the task reads as healthy until it is opened, and + * the only fix — pointing it at another connection — is not one the rail can + * suggest for a task it never marked. + */ +function isStale(outcome: SessionSendProjection | undefined): boolean { + if (outcome?.kind !== 'blocked') return false; + return ( + outcome.reason === 'connection_missing' || + outcome.reason === 'fake_backend' || + outcome.reason === 'provider_retired' + ); +} diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/use-external-store-selector.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/use-external-store-selector.ts new file mode 100644 index 0000000000..dc976d1de0 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/use-external-store-selector.ts @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo, useSyncExternalStore } from 'react'; + +/** The reading half of a renderer store: `app-shell-session-ui-state`, `session-catalog-state`. */ +export interface ExternalStore { + getState(): S; + subscribe(listener: () => void): () => void; +} + +/** + * Subscribe to one derived reading of a renderer store (#1985, #4109). + * + * A store's parts change at very different rates — `liveTurnBySession` moves + * once per streamed token, the session catalog at human speed. A component + * re-renders only when the value IT selects changes, so the chat transcript can + * follow every delta while the shell around it stays still. + * + * `select` must be a stable (module-level) function, and whatever it varies by + * — a session id, say — is passed as `arg` rather than captured. That is what + * lets the snapshot be memoized instead of published through a render-phase ref + * write, which React permits only for lazy initialization: a discarded + * concurrent render would otherwise hand its selector to the committed + * subscription. Changing `arg` rebuilds the cache, so the first render after + * switching sessions already reads the new one. + * + * The cache is keyed by the STATE the value was derived from, because + * `useSyncExternalStore` reads a snapshot several times per store state and + * demands the same value each time. A selector that derives a fresh object + * would otherwise loop, so keying it here is what makes `isEqual` a plain + * fewer-renders optimization: it carries a value's identity ACROSS a state the + * selection did not actually change. + */ +export function useExternalStoreSelector( + store: ExternalStore, + select: (state: S, arg: A) => T, + arg?: A, + isEqual?: (a: T, b: T) => boolean, +): T { + const getSnapshot = useMemo(() => { + let cache: { state: S; value: T } | null = null; + return (): T => { + const state = store.getState(); + if (cache && cache.state === state) return cache.value; + const next = select(state, arg as A); + const value = cache && (Object.is(cache.value, next) || isEqual?.(cache.value, next) === true) + ? cache.value + : next; + cache = { state, value }; + return value; + }; + }, [store, select, arg, isEqual]); + + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index 6f46d4bca4..0fa3781dd6 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -49,7 +49,6 @@ import type { ChatDefaultPermissionMode, SettingsSection, ThemePreference } from import type { LlmConnection } from '@maka/core/llm-connections'; import { isRetiredProvider } from '@maka/core/provider-registry'; import type { PermissionMode } from '@maka/core/permission'; -import type { SessionSummary } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from '@maka/ui'; import { getShellCopy } from './locales/shell-copy.js'; @@ -481,34 +480,3 @@ export function buildCommandList(args: { return cmds; } - -/** - * Session rows for the palette's 会话 group, derived separately from the - * base command list (#1045): the base list is frozen per palette open/close, - * while these rebuild only when the visible session catalog or the active - * session actually changes, so background session creates/renames stay live - * without reintroducing per-render list rebuilds. - */ -export function buildSessionCommands(args: { - locale: UiLocale; - sessions: readonly SessionSummary[]; - activeSessionId: string | undefined; - onSelectSession(id: string): void; -}): Command[] { - const copy = getShellCopy(args.locale).commandPalette; - const cmds: Command[] = []; - for (const session of args.sessions) { - if (session.isArchived) continue; - cmds.push({ - id: `session:${session.id}`, - kind: 'session', - label: session.name, - hint: session.id === args.activeSessionId ? copy.current : undefined, - group: copy.groups.conversations, - Icon: session.isFlagged ? Palette : MessageSquare, - keywords: ['session', 'chat', session.name], - run: () => args.onSelectSession(session.id), - }); - } - return cmds; -} diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index 28b41cc7a2..69b765349b 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -25,8 +25,8 @@ import { createAppShellSessionUiStateController, type AppShellSessionUiStateCont import { selectSessionById, type SessionCatalogController, -} from '../../../session-catalog-state.js'; -import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; +} from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; interface TranscriptSource { range(): { readonly sessionId: string; readonly hasOlder: boolean }; diff --git a/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts b/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts index 1f1a9967ea..d157975d71 100644 --- a/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts +++ b/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts @@ -107,7 +107,7 @@ export function selectStreamingSessionIds( return streaming; } -export { sessionIdSetsEqual } from '../../../../shared/session-id-set.js'; +export { sessionIdSetsEqual } from '../../../application/contracts/session-catalog/session-id-set.js'; function findLast(items: readonly T[], predicate: (item: T) => boolean): T | undefined { for (let index = items.length - 1; index >= 0; index -= 1) { diff --git a/apps/desktop/src/renderer/features/overlays/model/palette-session-commands.ts b/apps/desktop/src/renderer/features/overlays/model/palette-session-commands.ts new file mode 100644 index 0000000000..e4ddb84978 --- /dev/null +++ b/apps/desktop/src/renderer/features/overlays/model/palette-session-commands.ts @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// apps/desktop/src/renderer/features/overlays/model/palette-session-commands.ts +// +// The palette's 会话 group. Session rows subscribe the catalog here — at the +// consumption point — so the shell is not woken by palette-only reads. + +import { useMemo } from 'react'; +import { MessageSquare, Palette } from '@maka/ui/icons'; +import type { SessionSummary } from '@maka/core/session'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { getShellCopy } from '../../../locales/shell-copy.js'; +import { + selectClosedPaletteSessions, + selectPaletteSessions, + paletteSessionsEqual, +} from '../../../application/contracts/session-catalog/session-rail-visibility.js'; +import type { SessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; +import type { Command } from './command.js'; + +/** + * Session rows for the palette's 会话 group, derived separately from the + * base command list (#1045): the base list is frozen per palette open/close, + * while these rebuild only when the visible session catalog or the active + * session actually changes, so background session creates/renames stay live + * without reintroducing per-render list rebuilds. + */ +export function buildSessionCommands(args: { + locale: UiLocale; + sessions: readonly SessionSummary[]; + activeSessionId: string | undefined; + onSelectSession(id: string): void; +}): Command[] { + const copy = getShellCopy(args.locale).commandPalette; + const cmds: Command[] = []; + for (const session of args.sessions) { + if (session.isArchived) continue; + cmds.push({ + id: `session:${session.id}`, + kind: 'session', + label: session.name, + hint: session.id === args.activeSessionId ? copy.current : undefined, + group: copy.groups.conversations, + Icon: session.isFlagged ? Palette : MessageSquare, + keywords: ['session', 'chat', session.name], + run: () => args.onSelectSession(session.id), + }); + } + return cmds; +} + +/** + * The live session rows, subscribed only while the palette is open. + */ +export function usePaletteSessionCommands(args: { + catalog: SessionCatalogController; + hiddenSessionIds: ReadonlySet; + paletteOpen: boolean; + activeSessionId: string | undefined; + locale: UiLocale; + onSelectSession(id: string): void; +}): Command[] { + const sessions = useExternalStoreSelector( + args.catalog, + args.paletteOpen ? selectPaletteSessions : selectClosedPaletteSessions, + args.hiddenSessionIds, + paletteSessionsEqual, + ); + return useMemo( + () => + args.paletteOpen + ? buildSessionCommands({ + locale: args.locale, + sessions, + activeSessionId: args.activeSessionId, + onSelectSession: args.onSelectSession, + }) + : [], + [args.paletteOpen, args.locale, sessions, args.activeSessionId, args.onSelectSession], + ); +} diff --git a/apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx b/apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx index 9727b03b04..711b270607 100644 --- a/apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx +++ b/apps/desktop/src/renderer/features/overlays/ui/command-palette.tsx @@ -37,6 +37,8 @@ import { import { Kbd } from '@astryxdesign/core/Kbd'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { getShellCopy } from '../../../locales/shell-copy.js'; +import type { SessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { usePaletteSessionCommands } from '../model/palette-session-commands.js'; import type { Command } from '../model/command.js'; import { useOverlays } from './overlays-context.js'; @@ -54,11 +56,31 @@ function fuzzy(query: string, text: string): boolean { return i === q.length; } -export function CommandPalette(props: { readonly commands: Command[] }) { +export function CommandPalette(props: { + readonly commands: Command[]; + /** The shell's session catalog — the palette subscribes it only while open. */ + readonly sessionCatalog: SessionCatalogController; + /** Sessions the rail hides (mounted side-chat forks) — the palette skips them too. */ + readonly hiddenSessionIds: ReadonlySet; + readonly activeSessionId: string | undefined; + readonly onSelectSession: (id: string) => void; +}) { const { commands: overlayCommands, selectors } = useOverlays(); const isOpen = selectors.paletteOpen; const locale = useUiLocale(); const copy = getShellCopy(locale).commandPalette; + const sessionCommands = usePaletteSessionCommands({ + catalog: props.sessionCatalog, + hiddenSessionIds: props.hiddenSessionIds, + paletteOpen: isOpen, + activeSessionId: props.activeSessionId, + locale, + onSelectSession: props.onSelectSession, + }); + const commands = useMemo( + () => [...props.commands, ...sessionCommands], + [props.commands, sessionCommands], + ); const astryxOverrides = useMemo( () => ({ '@astryx.commandPalette.list.label': copy.resultsLabel, @@ -71,12 +93,12 @@ export function CommandPalette(props: { readonly commands: Command[] }) { }>; const items = useMemo( () => - props.commands.map((command) => ({ + commands.map((command) => ({ id: command.id, label: command.label, auxiliaryData: { command, group: command.group }, })), - [props.commands], + [commands], ); const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); const pendingCommandRef = useRef(null); diff --git a/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx index 7ea93ff855..48168d8071 100644 --- a/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx +++ b/apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx @@ -31,8 +31,8 @@ import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/Segme import { HStack, VStack } from '@astryxdesign/core/Stack'; import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; import type { DesktopSessionSummary } from '../../../shared/desktop-session-projection.js'; -import { selectSessions, type SessionCatalogController } from '../../session-catalog-state.js'; -import { useExternalStoreSelector } from '../../use-external-store-selector.js'; +import { selectSessions, type SessionCatalogController } from '../../application/contracts/session-catalog/session-catalog-state.js'; +import { useExternalStoreSelector } from '../../application/contracts/session-catalog/use-external-store-selector.js'; import { getExternalSessionImportCopy } from '../../locales/external-session-import-copy.js'; import { getSettingsSharedCopy } from '../../locales/settings-shared-copy.js'; import { ExportTree } from './export-tree.js'; diff --git a/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx index a9555289f6..745a5a8a8f 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx +++ b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx @@ -21,8 +21,8 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; import { useToast, useUiLocale } from '@maka/ui'; import { getSessionCollaborationCopy } from '../../locales/session-collaboration-copy.js'; import { useSessionTurnRequestInbox } from './controller/use-turn-request-inbox.js'; -import type { SessionCatalogController, SessionCatalogState } from '../../session-catalog-state.js'; -import { useExternalStoreSelector } from '../../use-external-store-selector.js'; +import type { SessionCatalogController, SessionCatalogState } from '../../application/contracts/session-catalog/session-catalog-state.js'; +import { useExternalStoreSelector } from '../../application/contracts/session-catalog/use-external-store-selector.js'; const selectSessionIdNames = ( state: SessionCatalogState, diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts index a97a1ef0cd..3c34e09a70 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts @@ -26,7 +26,7 @@ import { type SessionHistoryGroup, type SessionRailSelection, } from '@maka/ui'; -import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; +import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; import { deriveSessionNavigationGroups } from '../model/session-navigation-groups.js'; import { deriveWorktreeSessionIds } from '../model/session-project-grouping.js'; import type { SessionRailProjection } from '../model/session-rail.js'; diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts index 7ad917040c..597b5b8702 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-reads.ts @@ -17,9 +17,9 @@ * under the License. */ -import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; -import type { SessionCatalogState } from '../../../session-catalog-state.js'; -import type { SessionCatalogController } from '../../../session-catalog-state.js'; +import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; +import type { SessionCatalogState } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import type { SessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; import { deriveBranchBanner, type BranchBanner } from '../model/branch-banner.js'; import { selectRailLayout, diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-nav-filter.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-nav-filter.ts index d5715531b7..2df3ee0003 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-nav-filter.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-nav-filter.ts @@ -17,20 +17,4 @@ * under the License. */ -import type { SessionSummary } from '@maka/core/session'; -import { isSideConversationSession } from '@maka/core/side-conversation'; - -/** - * Which sessions the rail lists. Archived tasks are managed in Settings › 活动 › - * 已归档任务 (#2985). Side-conversation forks belong to their Workbar panels, - * not the main task catalog; filtering their durable label here prevents the - * `sessions:changed(created)` broadcast from flashing a row before the panel's - * renderer-local hidden-id update arrives. - * - * This used to switch on `NavSelection.filter`. That filter is gone (#2984): its - * last two values were a destination that moved to Settings and a value nothing - * ever selected, which left one branch reachable — this one. - */ -export function sessionMatchesRail(session: SessionSummary): boolean { - return !session.isArchived && !isSideConversationSession(session.labels); -} +export { sessionMatchesRail } from '../../../application/contracts/session-catalog/session-rail-visibility.js'; diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts index e9aa78e26b..c6793a8a33 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-rail-layout-store.ts @@ -20,7 +20,7 @@ import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; import type { SessionViewMode } from '@maka/ui'; import { safeLocalStorageSet } from '../../../browser-storage.js'; -import { createObservableState } from '../../../observable-state.js'; +import { createObservableState } from '../../../application/contracts/session-catalog/observable-state.js'; import { clampSessionListWidth, readSessionListCollapsed, diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index 166c1cce71..c70336b508 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -48,10 +48,10 @@ import type { SessionNavigationProjectScope, SessionNavigationSession, } from '../ports.js'; -import { selectSessions, type SessionCatalogController } from '../../../session-catalog-state.js'; -import { selectStaleSessionIds } from '../../../stale-sessions.js'; -import { sessionIdSetsEqual } from '../../../../shared/session-id-set.js'; -import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; +import { selectSessions, type SessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js'; +import { selectStaleSessionIds } from '../../../application/contracts/session-catalog/stale-sessions.js'; +import { sessionIdSetsEqual } from '../../../application/contracts/session-catalog/session-id-set.js'; +import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; import type { SessionSendProjection } from '@maka/core/session-send-projection'; /** The chrome the shell owns and the rail only displays. */ diff --git a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts index 444e0c55f3..e094c3570d 100644 --- a/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts +++ b/apps/desktop/src/renderer/features/session-settings/use-session-setting-intent.ts @@ -36,7 +36,7 @@ import { type SessionModelConfigurationIntent, type SessionModelTarget, } from './session-model-configuration-intent.js'; -import type { SessionCatalogController } from '../../session-catalog-state.js'; +import type { SessionCatalogController } from '../../application/contracts/session-catalog/session-catalog-state.js'; import { useSessionSettingsServices } from './services-context.js'; type SessionSettingValues = { diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 8c3ecf0d24..8aa8ef2aa5 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -55,7 +55,7 @@ import type { UserQuestionResponse } from '@maka/core/user-question'; import type { InteractionFormResponse } from '@maka/core/interaction'; import type { ContextCompactResult } from '@maka/runtime-host/protocol'; import { useWorkbarServices } from '../../services-context.js'; -import { createObservableState } from '../../../../observable-state.js'; +import { createObservableState } from '../../../../application/contracts/session-catalog/observable-state.js'; import type { WorkbarIngestInput } from '../../ports.js'; import { abandonPendingCompanionCopy, diff --git a/apps/desktop/src/renderer/observable-state.ts b/apps/desktop/src/renderer/observable-state.ts index df6f181539..756231273c 100644 --- a/apps/desktop/src/renderer/observable-state.ts +++ b/apps/desktop/src/renderer/observable-state.ts @@ -17,46 +17,4 @@ * under the License. */ -/** - * The writable half of a renderer store: one state value, replaced whole. - * - * The renderer has three of these — `app-shell-session-ui-state`, - * `session-catalog-state`, `session-rail-layout-store` — and they differ only - * in what they hold and which commands they expose. What they must NOT differ - * in is the notification rule below, which is load-bearing and was previously - * restated once per store. - * - * Pair with `useExternalStoreSelector` to read one derived value from it. - */ -export function createObservableState(initial: S) { - let current = initial; - const listeners = new Set<() => void>(); - - return { - getState: (): S => current, - - /** Subscribe to state replacements. Stable identity, for `useSyncExternalStore`. */ - subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - - /** - * Swap the state and notify, synchronously and in that order. Never - * schedule the notification: the terminal-turn handoff reads back the - * state it announces, and a selection change is read back by the handler - * that made it (#1985, #4109). - * - * A replacement with the same identity is not a change and notifies - * nobody, which is what lets a store's commands be written as plain - * `if (unchanged) return`. - */ - replaceState(next: S): void { - if (next === current) return; - current = next; - for (const listener of [...listeners]) listener(); - }, - }; -} +export * from './application/contracts/session-catalog/observable-state.js'; diff --git a/apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts b/apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts new file mode 100644 index 0000000000..da10e38f72 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DesktopSessionSummary } from '../../../preload/bridge-contract.js'; + +export type SessionCatalogSource = { + sessions: Pick; +}; + +export interface SessionPatchDrain { + /** Resolves with the committed row, or null when it left the catalog. */ + request(sessionId: string): Promise; +} + +/** + * `sessions:changed` carries the changed row's id, so the hot path reads and + * commits only that row. Calls arriving while a batch is in flight fold into + * the next drain instead of queueing one IPC per event. + */ +export function createSessionPatchDrain( + options: { + normalize(session: DesktopSessionSummary): DesktopSessionSummary; + commitPatch(sessionId: string, summary: DesktopSessionSummary | null): void; + /** A failed row read must not evict the row; the caller falls back to a full refresh. */ + onReadFailure(): void; + }, + source: SessionCatalogSource = window.maka, +): SessionPatchDrain { + const pending = new Map void }[]>(); + let draining = false; + + async function drain(): Promise { + try { + while (pending.size > 0) { + const batch = [...pending.entries()]; + pending.clear(); + await Promise.all(batch.map(async ([sessionId, waiters]) => { + try { + const summary = await source.sessions.get(sessionId); + const normalized = summary === null ? null : options.normalize(summary); + options.commitPatch(sessionId, normalized); + waiters.forEach(({ resolve }) => resolve(normalized)); + } catch { + waiters.forEach(({ resolve }) => resolve(null)); + options.onReadFailure(); + } + })); + } + } finally { + draining = false; + } + } + + return { + request(sessionId) { + const promise = new Promise((resolve) => { + const waiters = pending.get(sessionId); + if (waiters) waiters.push({ resolve }); + else pending.set(sessionId, [{ resolve }]); + }); + if (!draining) { + draining = true; + void drain(); + } + return promise; + }, + }; +} diff --git a/apps/desktop/src/renderer/session-catalog-state.ts b/apps/desktop/src/renderer/session-catalog-state.ts index d63e4e6996..0023a22f57 100644 --- a/apps/desktop/src/renderer/session-catalog-state.ts +++ b/apps/desktop/src/renderer/session-catalog-state.ts @@ -17,142 +17,4 @@ * under the License. */ -import { useRef } from 'react'; -import { valuesEqual } from '@maka/ui'; -import { - compareDesktopSessionCatalogSummaries, - type DesktopSessionSummary, -} from '../shared/desktop-session-projection.js'; -import { createObservableState } from './observable-state.js'; - -/** - * The session catalog and the selection, as one external store (#4109). - * - * They were `useState` inside a hook AppShell calls, which made the shell the - * carrier: every catalog commit and every selection change re-rendered the - * whole tree, and anything that wanted to follow them — the Session rail above - * all — had to be handed them down a prop chain. As a store they have readers - * instead of a carrier, and each reader re-renders only for the reading it - * selects. Same mechanism as `app-shell-session-ui-state.ts` (#1985); this is - * the second store, not a second way of having stores. - * - * The list and its observation revision are one committed snapshot. A failed - * refresh changes neither, so consumers can fence transient writes against - * successful catalog observations without a parallel error flag. - */ -export interface SessionCatalogState { - readonly sessions: readonly DesktopSessionSummary[]; - readonly revision: number; - readonly activeSessionId: string | undefined; -} - -export function createSessionCatalogController() { - const state = createObservableState({ - sessions: [], - revision: 0, - activeSessionId: undefined, - }); - - return { - getState: state.getState, - subscribe: state.subscribe, - commitSessions(next: readonly DesktopSessionSummary[]): void { - const current = state.getState(); - // Published references change iff values change: an unchanged row keeps - // its identity so per-row readers and memos survive a re-list, and a - // row already patched to a newer revision is never regressed by an - // older snapshot. - const previousById = new Map(current.sessions.map((s) => [s.id, s])); - const reconciled = next.map((s) => { - const prior = previousById.get(s.id); - return prior !== undefined && (isStaleSummary(prior, s) || valuesEqual(prior, s)) - ? prior - : s; - }); - const sameRows = reconciled.length === current.sessions.length - && reconciled.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 && current.revision > 0) return; - state.replaceState({ - ...current, - sessions: sameRows ? current.sessions : reconciled, - revision: current.revision + 1, - }); - }, - commitPatch(sessionId: string, summary: DesktopSessionSummary | null): void { - const current = state.getState(); - const index = current.sessions.findIndex((s) => s.id === sessionId); - const prior = index < 0 ? undefined : current.sessions[index]; - if (summary === null) { - if (prior === undefined) return; - state.replaceState({ - ...current, - sessions: current.sessions.filter((s) => s.id !== sessionId), - revision: current.revision + 1, - }); - return; - } - if (prior !== undefined && isStaleSummary(prior, summary)) return; - const row = prior !== undefined && valuesEqual(prior, summary) ? prior : summary; - const sessions = [...current.sessions]; - if (index < 0) sessions.push(row); else sessions[index] = row; - sessions.sort(compareDesktopSessionCatalogSummaries); - const sameRows = sessions.length === current.sessions.length - && sessions.every((s, i) => s === current.sessions[i]); - if (sameRows) return; - state.replaceState({ - ...current, - sessions, - revision: current.revision + 1, - }); - }, - setActiveSessionId(next: string | undefined): void { - const current = state.getState(); - if (current.activeSessionId === next) return; - state.replaceState({ ...current, activeSessionId: next }); - }, - }; -} - -export type SessionCatalogController = ReturnType; - -/** A committed row at a newer revision is authoritative over an older snapshot of it. */ -function isStaleSummary(prior: DesktopSessionSummary, next: DesktopSessionSummary): boolean { - return prior.revision > next.revision; -} - -export const selectSessions = (state: SessionCatalogState): readonly DesktopSessionSummary[] => - state.sessions; -export const selectSessionById = ( - state: SessionCatalogState, - sessionId: string | undefined, -): DesktopSessionSummary | undefined => - sessionId === undefined ? undefined : state.sessions.find((s) => s.id === sessionId); -export const selectSessionCount = (state: SessionCatalogState): number => state.sessions.length; -export const selectCatalogRevision = (state: SessionCatalogState): number => state.revision; -export const selectActiveSessionId = (state: SessionCatalogState): string | undefined => - state.activeSessionId; - -/** - * The ids in the catalog, by value. A refresh replaces every row object even - * when nothing about the membership moved (#2913), so an identity-only - * selection would re-render every reader that only cares about which sessions - * exist. - */ -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; - -/** - * Owns the controller for the component's lifetime. Deliberately does NOT - * subscribe: readers select what they need through `useExternalStoreSelector`. - */ -export function useSessionCatalogController(): SessionCatalogController { - const controllerRef = useRef(null); - if (!controllerRef.current) controllerRef.current = createSessionCatalogController(); - return controllerRef.current; -} +export * from './application/contracts/session-catalog/session-catalog-state.js'; diff --git a/apps/desktop/src/renderer/session-event-health.ts b/apps/desktop/src/renderer/session-event-health.ts index c6804c5eb1..ee3f032d2b 100644 --- a/apps/desktop/src/renderer/session-event-health.ts +++ b/apps/desktop/src/renderer/session-event-health.ts @@ -17,92 +17,4 @@ * under the License. */ -import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import type { SessionStatus } from '@maka/core/session'; -import { isInFlightToolStatus } from '@maka/core/tool-result-status'; -import type { ToolActivityItem } from '@maka/ui'; -import { - deriveSessionEventStreamStatus, - sessionExpectsEventStream, - shouldRefreshStaleSessionEventStream, -} from '@maka/core/session-event-health'; - -export function createSessionEventStreamSubscription(input: { - sessionId: string; - now: number; -}): SessionEventStreamSnapshot { - return { - sessionId: input.sessionId, - status: 'connected', - subscribedAt: input.now, - checkedAt: input.now, - }; -} - -export function recordSessionEventStreamEvent( - previous: SessionEventStreamSnapshot, - now: number, -): SessionEventStreamSnapshot { - return { - ...previous, - status: previous.status === 'stale' ? 'recovered' : 'connected', - checkedAt: now, - lastEventAt: now, - staleSince: undefined, - }; -} - -export function recordSessionEventStreamChange( - previous: SessionEventStreamSnapshot, - now: number, -): SessionEventStreamSnapshot { - return { - ...previous, - status: previous.status === 'stale' ? 'recovered' : previous.status === 'closed' ? 'connected' : previous.status, - checkedAt: now, - lastChangedAt: now, - staleSince: undefined, - }; -} - -export function evaluateSessionEventStreamSnapshot(input: { - previous: SessionEventStreamSnapshot | undefined; - now: number; - sessionStatus: SessionStatus | undefined; - hasLiveActivity: boolean; -}): { snapshot: SessionEventStreamSnapshot | undefined; shouldRefresh: boolean } { - const previous = input.previous; - if (!previous) return { snapshot: undefined, shouldRefresh: false }; - - const expected = sessionExpectsEventStream(input.sessionStatus, input.hasLiveActivity); - const status = deriveSessionEventStreamStatus({ - now: input.now, - subscribedAt: previous.subscribedAt, - lastEventAt: previous.lastEventAt, - lastChangedAt: previous.lastChangedAt, - previousStatus: previous.status, - expected, - }); - const refreshDue = shouldRefreshStaleSessionEventStream({ - status, - now: input.now, - refreshRequestedAt: previous.refreshRequestedAt, - }); - - return { - snapshot: { - ...previous, - status, - checkedAt: input.now, - staleSince: status === 'stale' ? previous.staleSince ?? input.now : undefined, - refreshRequestedAt: refreshDue ? input.now : previous.refreshRequestedAt, - }, - shouldRefresh: refreshDue, - }; -} - -export function hasInFlightToolActivity( - liveTools: readonly Pick[], -): boolean { - return liveTools.some((tool) => isInFlightToolStatus(tool.status)); -} +export * from './application/contracts/session-catalog/session-event-health.js'; diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index d277602c93..cd6a16f458 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -100,6 +100,7 @@ import { SettingRow } from './settings-rows'; import { SettingsPage, SettingsSection as SettingsSectionBlock } from './settings-section'; import { settingsActionErrorMessage } from './settings-error-copy'; import { SessionBundleTasks } from '../features/session-bundle'; +import { CatalogSessions } from '../application/contracts/session-catalog/catalog-sessions.js'; import { ImportTasksSettingsPage } from './import-tasks-settings-page'; import { TasksSettingsPage, type ArchivedTasksBridge } from './tasks-settings-page'; import { UsageScopeMount, UsageSettingsPage, type UsageScopeHandle } from './usage-settings-page'; @@ -1240,7 +1241,11 @@ function SettingsPageBody(props: { /> ); case 'archived-tasks': - return ; + return ( + + {(sessions) => } + + ); case 'import-tasks': return ( diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index d4cb29bac9..e3245e6782 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -28,8 +28,7 @@ import { List, ListItem } from '@astryxdesign/core/List'; import { TextInput } from '@astryxdesign/core/TextInput'; import type { SessionPurgeOutcome } from '../features/session-navigation'; import type { DesktopSessionSummary } from '../../preload/bridge-contract.js'; -import { selectSessions, type SessionCatalogController } from '../session-catalog-state.js'; -import { useExternalStoreSelector } from '../use-external-store-selector.js'; +import type { SessionCatalogController } from '../application/contracts/session-catalog/session-catalog-state.js'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; import { getSettingsTasksCopy } from '../locales/settings-tasks-copy.js'; import { settingsActionErrorMessage } from './settings-error-copy'; @@ -46,7 +45,6 @@ import { * not have to understand. */ export interface ArchivedTasksBridge { - /** The shell's session catalog; the page subscribes it while it is open. */ catalog: SessionCatalogController; projects: readonly ProjectRecord[]; onRestore(sessionId: string): void; @@ -76,7 +74,9 @@ export interface ArchivedTasksBridge { * changed. What is genuinely new here is finding a task by name or project, and * clearing a set of them in one pass. */ -export function TasksSettingsPage(props: ArchivedTasksBridge) { +export function TasksSettingsPage( + props: ArchivedTasksBridge & { sessions: readonly DesktopSessionSummary[] }, +) { const locale = useUiLocale(); const copy = getSettingsTasksCopy(locale); const toast = useToast(); @@ -103,13 +103,12 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { [copy.noProject, projectNames], ); - const sessions = useExternalStoreSelector(props.catalog, selectSessions); // Store order is already recency-first with a stable id tie-break, and the // projection preserves it, so there is nothing left to sort here. - const archived = useMemo(() => archivedTaskRows(sessions), [sessions]); + const archived = useMemo(() => archivedTaskRows(props.sessions), [props.sessions]); const knownSessionIds = useMemo( - () => new Set(sessions.map((session) => session.id)), - [sessions], + () => new Set(props.sessions.map((session) => session.id)), + [props.sessions], ); const isSearching = query.trim().length > 0; const visible = useMemo( diff --git a/apps/desktop/src/renderer/stale-sessions.ts b/apps/desktop/src/renderer/stale-sessions.ts index c6ba8952cc..5f65601877 100644 --- a/apps/desktop/src/renderer/stale-sessions.ts +++ b/apps/desktop/src/renderer/stale-sessions.ts @@ -17,55 +17,4 @@ * under the License. */ -import type { SessionSendProjection } from '@maka/core/session-send-projection'; -import type { SessionCatalogState } from './session-catalog-state.js'; - -export interface StaleSessionsInput { - /** Sessions visible in the sidebar (already filtered + grouped). */ - sessions: ReadonlyArray<{ - id: string; - }>; - /** Readiness already resolved by each Session's owning Runtime Host. */ - sendOutcomes: Readonly>; -} - -export function deriveStaleSessionIds(input: StaleSessionsInput): Set { - const stale = new Set(); - for (const session of input.sessions) { - if (isStale(input.sendOutcomes[session.id])) { - stale.add(session.id); - } - } - return stale; -} - -const NO_SEND_OUTCOMES: Readonly> = {}; - -export const selectStaleSessionIds = ( - state: SessionCatalogState, - sendOutcomes: Readonly> | undefined, -): Set => - deriveStaleSessionIds({ sessions: state.sessions, sendOutcomes: sendOutcomes ?? NO_SEND_OUTCOMES }); - -/** - * A row is stale when its owning Runtime Host says the next send cannot go - * anywhere the user can fix from the rail. - * - * `fake_backend` is read from the projection rather than from `session.backend` - * (#3211): the readiness projection is the single authority on whether a task - * is usable, and a retired backend is one of its answers like any other. - * - * `provider_retired` belongs with them for the same reason and one more: the - * connection is still there and still enabled, so nothing else about the row - * looks wrong. Without this the task reads as healthy until it is opened, and - * the only fix — pointing it at another connection — is not one the rail can - * suggest for a task it never marked. - */ -function isStale(outcome: SessionSendProjection | undefined): boolean { - if (outcome?.kind !== 'blocked') return false; - return ( - outcome.reason === 'connection_missing' || - outcome.reason === 'fake_backend' || - outcome.reason === 'provider_retired' - ); -} +export * from './application/contracts/session-catalog/stale-sessions.js'; diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index ce6affb861..bd28c1e90e 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { useUiLocale } from '@maka/ui'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -32,16 +32,15 @@ import { selectAuthoritativeSessionIds, type SessionCatalogController, } from './session-catalog-state.js'; -import { sessionIdSetsEqual } from './features/conversation/index.js'; -import { useExternalStoreSelector } from './use-external-store-selector.js'; +import { sessionIdSetsEqual } from './application/contracts/session-catalog/session-id-set.js'; +import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js'; +import { createSessionPatchDrain } from './platform/desktop/session-catalog-sync.js'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; type ToastApi = { error(title: string, description?: string): void; }; -type RefBox = { current: T }; - export function useAppShellSessionList( toastApi: ToastApi, options: { @@ -62,109 +61,48 @@ export function useAppShellSessionList( undefined, sessionIdSetsEqual, ); - const sessionsRef = useRef([]); - const refresherRef = useRef | null>(null); - const pendingPatchesRef = useRef( - new Map void }[]>(), + // The catalog is the authority; the box only adapts its read shape. + const sessionsRef = useMemo( + () => ({ get current() { return catalog.getState().sessions; } }), + [catalog], ); - const patchDrainActiveRef = useRef(false); - - function commitSessions(next: DesktopSessionSummary[]): void { - sessionsRef.current = next; - catalog.commitSessions(next); - } - - function commitPatch(sessionId: string, summary: DesktopSessionSummary | null): void { - catalog.commitPatch(sessionId, summary); - sessionsRef.current = [...catalog.getState().sessions]; - } - - // `sessions:changed` carries the changed row's id, so the hot path reads and - // commits only that row. Calls arriving while a batch is in flight fold into - // the next drain instead of queueing one IPC per event. - async function drainSessionPatches(): Promise { - try { - while (pendingPatchesRef.current.size > 0) { - const batch = [...pendingPatchesRef.current.entries()]; - pendingPatchesRef.current.clear(); - await Promise.all(batch.map(async ([sessionId, waiters]) => { - try { - const summary = await window.maka.sessions.get(sessionId); - const normalized = summary === null - ? null - : normalizeSessionSummaryForDisplay(summary); - commitPatch(sessionId, normalized); - waiters.forEach(({ resolve }) => resolve(normalized)); - } catch { - // A failed row read must not evict the row; fall back to a full - // refresh (deduped by the refresher) so it cannot strand stale. - waiters.forEach(({ resolve }) => resolve(null)); - void refresherRef.current?.refresh().catch(() => undefined); - } - })); - } - } finally { - patchDrainActiveRef.current = false; - } - } + const refresherRef = useRef | null>(null); + refresherRef.current ??= createSessionListRefresher({ + listSessions: () => window.maka.sessions.list(), + currentSessions: () => [...sessionsRef.current], + commitSessions: (next) => + catalog.commitSessions(next.map(normalizeSessionSummaryForDisplay)), + onError: (error) => { + const locale = uiLocaleRef.current; + const copy = getDesktopConversationCopy(locale).actions; + toastApi.error( + copy.refreshSessionsFailedTitle, + localizedShellErrorMessage(error, copy.refreshSessionsFailedFallback, locale), + ); + }, + }); - function refreshChangedSession(sessionId: string): Promise { - const pending = new Promise((resolve) => { - const waiters = pendingPatchesRef.current.get(sessionId); - if (waiters) waiters.push({ resolve }); - else pendingPatchesRef.current.set(sessionId, [{ resolve }]); + // Fixed identities for the renderer's lifetime: everything closes over ref + // boxes or the stable controller, and consumers list the actions in dep + // arrays and hand them down as props (see `session-workspace-actions.ts`). + // Row-level refresh reads the changed row only; the drain lives on the + // Desktop adapter because the bridge is not reachable from this layer. + const actions = useMemo(() => { + const drain = createSessionPatchDrain({ + normalize: normalizeSessionSummaryForDisplay, + commitPatch: (sessionId, summary) => catalog.commitPatch(sessionId, summary), + onReadFailure: () => void refresherRef.current?.refresh().catch(() => undefined), }); - if (!patchDrainActiveRef.current) { - patchDrainActiveRef.current = true; - void drainSessionPatches(); - } - return pending; - } - - if (!refresherRef.current) { - refresherRef.current = createSessionListRefresher({ - listSessions: () => window.maka.sessions.list(), - currentSessions: () => sessionsRef.current, - commitSessions: (next) => commitSessions(next.map(normalizeSessionSummaryForDisplay)), - onError: (error) => { - const locale = uiLocaleRef.current; - const copy = getDesktopConversationCopy(locale).actions; - toastApi.error( - copy.refreshSessionsFailedTitle, - localizedShellErrorMessage(error, copy.refreshSessionsFailedFallback, locale), - ); + return { + refreshSessions: () => refresherRef.current!.refresh(), + refreshChangedSession: drain.request, + seedSessions(snapshotSessions: readonly DesktopSessionSummary[]) { + const next = snapshotSessions.map(normalizeSessionSummaryForDisplay); + catalog.commitSessions(next); + return next; }, - }); - } - - // Fixed identities for the renderer's lifetime: both close over ref boxes and - // a state setter only, and consumers list them in dep arrays and hand them - // down as props (see `session-workspace-actions.ts`). - const actionsRef = useRef<{ - refreshSessions(): Promise; - refreshChangedSession(sessionId: string): Promise; - seedSessions( - snapshotSessions: readonly DesktopSessionSummary[], - ): DesktopSessionSummary[]; - } | null>(null); - actionsRef.current ??= { - async refreshSessions() { - return refresherRef.current!.refresh(); - }, - refreshChangedSession, - seedSessions(snapshotSessions) { - const next = snapshotSessions.map(normalizeSessionSummaryForDisplay); - commitSessions(next); - return next; - }, - }; - const { refreshSessions, seedSessions } = actionsRef.current; + }; + }, [catalog]); - return { - authoritativeSessionIds, - sessionsRef, - refreshSessions, - refreshChangedSession: actionsRef.current.refreshChangedSession, - seedSessions, - }; + return { authoritativeSessionIds, sessionsRef, ...actions }; } diff --git a/apps/desktop/src/renderer/use-external-store-selector.ts b/apps/desktop/src/renderer/use-external-store-selector.ts index dc976d1de0..e224eb91ea 100644 --- a/apps/desktop/src/renderer/use-external-store-selector.ts +++ b/apps/desktop/src/renderer/use-external-store-selector.ts @@ -17,56 +17,4 @@ * under the License. */ -import { useMemo, useSyncExternalStore } from 'react'; - -/** The reading half of a renderer store: `app-shell-session-ui-state`, `session-catalog-state`. */ -export interface ExternalStore { - getState(): S; - subscribe(listener: () => void): () => void; -} - -/** - * Subscribe to one derived reading of a renderer store (#1985, #4109). - * - * A store's parts change at very different rates — `liveTurnBySession` moves - * once per streamed token, the session catalog at human speed. A component - * re-renders only when the value IT selects changes, so the chat transcript can - * follow every delta while the shell around it stays still. - * - * `select` must be a stable (module-level) function, and whatever it varies by - * — a session id, say — is passed as `arg` rather than captured. That is what - * lets the snapshot be memoized instead of published through a render-phase ref - * write, which React permits only for lazy initialization: a discarded - * concurrent render would otherwise hand its selector to the committed - * subscription. Changing `arg` rebuilds the cache, so the first render after - * switching sessions already reads the new one. - * - * The cache is keyed by the STATE the value was derived from, because - * `useSyncExternalStore` reads a snapshot several times per store state and - * demands the same value each time. A selector that derives a fresh object - * would otherwise loop, so keying it here is what makes `isEqual` a plain - * fewer-renders optimization: it carries a value's identity ACROSS a state the - * selection did not actually change. - */ -export function useExternalStoreSelector( - store: ExternalStore, - select: (state: S, arg: A) => T, - arg?: A, - isEqual?: (a: T, b: T) => boolean, -): T { - const getSnapshot = useMemo(() => { - let cache: { state: S; value: T } | null = null; - return (): T => { - const state = store.getState(); - if (cache && cache.state === state) return cache.value; - const next = select(state, arg as A); - const value = cache && (Object.is(cache.value, next) || isEqual?.(cache.value, next) === true) - ? cache.value - : next; - cache = { state, value }; - return value; - }; - }, [store, select, arg, isEqual]); - - return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); -} +export * from './application/contracts/session-catalog/use-external-store-selector.js'; diff --git a/apps/desktop/stories/command-search.stories.tsx b/apps/desktop/stories/command-search.stories.tsx index 2365b8ac69..49e76f2fb6 100644 --- a/apps/desktop/stories/command-search.stories.tsx +++ b/apps/desktop/stories/command-search.stories.tsx @@ -24,7 +24,6 @@ import { SearchModal } from '@maka/ui'; import { Download, FolderOpen, - MessageSquare, Plus, Settings, Sparkles, @@ -36,6 +35,8 @@ import { type Command, } from '../src/renderer/features/overlays/index.js'; import { createFakeOverlaysServices } from '../src/renderer/features/overlays/testing.js'; +import { createSessionCatalogController } from '../src/renderer/application/contracts/session-catalog/session-catalog-state.js'; +import type { DesktopSessionSummary } from '../src/shared/desktop-session-projection.js'; // Fidelity convention (#1433): every story below names the real app path // that reaches it. See apps/desktop/stories/FIDELITY.md. @@ -55,6 +56,7 @@ type SearchModalDeps = NonNullable[0]['deps']>; const noop = () => undefined; const noopNavigate = (_sessionId: string, _turnId?: string) => undefined; +const EMPTY_HIDDEN_SESSIONS: ReadonlySet = new Set(); const threadResults: SearchResult[] = [ { @@ -132,18 +134,32 @@ const paletteCommands: Command[] = [ keywords: ['export', 'markdown', '导出'], run: noop, }, - { - id: 'session:benchmark', - kind: 'session', - label: '生成本周 benchmark 对比表', - hint: '当前', - group: '任务', - Icon: MessageSquare, - keywords: ['benchmark', '任务'], - run: noop, - }, ]; +// Session rows come from the catalog, not the base list — seed one. +const storyCatalog = createSessionCatalogController(); +const benchmarkSession = { + id: 'session-benchmark', + name: '生成本周 benchmark 对比表', + revision: 1, + activityAt: 1, + isArchived: false, + isFlagged: false, + hasUnread: false, + labels: [], + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'openai-live', + connectionLocked: true, + model: 'gpt-5', + permissionMode: 'ask', + runtimeHostId: 'local', + profileId: 'local', + profileName: 'Local', + profileKind: 'local', +} satisfies DesktopSessionSummary; +storyCatalog.commitSessions([benchmarkSession]); + function searchModalDeps(response: SearchResponse): SearchModalDeps { return { searchThread: async () => response, @@ -176,7 +192,13 @@ function CommandPaletteFrame(props: { commands: Command[] }) { {(overlays) => ( <> - + )} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index fa000af5d8..243d1de73d 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.1` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 290 files — blocker 0, reimplementation 0, polish 4, aligned 286. +**Totals:** 292 files — blocker 0, reimplementation 0, polish 4, aligned 288. ## Exclusions (explicit) @@ -34,6 +34,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell | aligned — uses Astryx (AppShell) | aligned | | `apps/desktop/src/renderer/app.tsx` | other | Theme | aligned — uses Astryx (Theme) | aligned | | `apps/desktop/src/renderer/application/contracts/feature-services.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/application/contracts/session-catalog/catalog-row-watch.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/application/contracts/session-catalog/catalog-sessions.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned | | `apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/application/contracts/settings-presentation/settings-navigation.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index e562e8c6a6..426fdc83bd 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -5,6 +5,8 @@ apps/desktop/src/renderer/app-shell-overlays.tsx apps/desktop/src/renderer/app-shell.tsx apps/desktop/src/renderer/app.tsx apps/desktop/src/renderer/application/contracts/feature-services.tsx +apps/desktop/src/renderer/application/contracts/session-catalog/catalog-row-watch.tsx +apps/desktop/src/renderer/application/contracts/session-catalog/catalog-sessions.tsx apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx apps/desktop/src/renderer/application/contracts/settings-presentation/settings-navigation.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 060841ce33..4fc61e064c 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -117,14 +117,7 @@ export const ALLOWED = { useAppShellSessionWorkspace: 1, useAppShellTurnPresentation: 1, useComposerAttachments: 1, - useEffect: 7, - // The shell's own reads of the session catalog: the session count gates the - // onboarding surface, and the two revision-draft rows feed the commit it - // issues. Three selector call sites, all whole-tree-scoped reads the shell - // genuinely consumes — the catalog subscription itself lives in - // `SessionNavigationProvider` / `useAppShellCommands`, which is why this - // entry did not exist before (#5441). - useExternalStoreSelector: 3, + useEffect: 6, useLayoutEffect: 2, useNewTaskChoice: 1, useOnboardingSnapshot: 1, From 3a9cb5f5fe86591d197b204367b1ea582cffb116 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 20:55:01 +0800 Subject: [PATCH 12/14] chore(desktop): drop legacy catalog shims and dead exports Knip flagged five root re-export shims whose last consumers can all point at application/contracts/session-catalog directly, so the compatibility layer is no longer earning its keep. Repoint the six remaining importers and delete the shim files. Also remove selectSessionCount/selectCatalogRevision (no consumers; the revision fence reads catalog.getState() directly) and drop sessionMatchesRail from the session-navigation barrel while keeping the testing.ts export the controller test uses. --- apps/desktop/renderer-architecture.json | 51 +++++-------------- .../src/renderer/app-shell-command-actions.ts | 2 +- .../session-catalog/session-catalog-state.ts | 2 - .../src/renderer/chat-message-surface.tsx | 2 +- .../features/session-navigation/index.ts | 1 - apps/desktop/src/renderer/observable-state.ts | 20 -------- .../src/renderer/session-catalog-state.ts | 20 -------- .../src/renderer/session-event-health.ts | 20 -------- apps/desktop/src/renderer/stale-sessions.ts | 20 -------- .../renderer/use-app-shell-session-list.ts | 2 +- .../use-app-shell-session-ui-reads.ts | 2 +- .../use-app-shell-session-workspace.ts | 4 +- .../renderer/use-external-store-selector.ts | 20 -------- .../settings/settings-pages.stories.tsx | 2 +- 14 files changed, 19 insertions(+), 149 deletions(-) delete mode 100644 apps/desktop/src/renderer/observable-state.ts delete mode 100644 apps/desktop/src/renderer/session-catalog-state.ts delete mode 100644 apps/desktop/src/renderer/session-event-health.ts delete mode 100644 apps/desktop/src/renderer/stale-sessions.ts delete mode 100644 apps/desktop/src/renderer/use-external-store-selector.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index de9b431e0b..eefc5de96a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -98,7 +98,6 @@ "src/renderer/model-connection-errors.ts", "src/renderer/nav-selection.ts", "src/renderer/new-task-reload-intent.ts", - "src/renderer/observable-state.ts", "src/renderer/onboarding-hero-copy.ts", "src/renderer/onboarding-hero.tsx", "src/renderer/onboarding-provider-types.ts", @@ -109,11 +108,9 @@ "src/renderer/project-path-display.ts", "src/renderer/remote-project-directory-dialog.tsx", "src/renderer/scroll-motion-policy.ts", - "src/renderer/session-catalog-state.ts", "src/renderer/session-collaboration-dialog.tsx", "src/renderer/session-copy-attempt.ts", "src/renderer/session-error-presentation.ts", - "src/renderer/session-event-health.ts", "src/renderer/session-health-notice.ts", "src/renderer/session-read-state.ts", "src/renderer/session-status-presentation.ts", @@ -201,7 +198,6 @@ "src/renderer/shell-run-update-state.ts", "src/renderer/side-chat-command.ts", "src/renderer/skill-invocation-feedback.ts", - "src/renderer/stale-sessions.ts", "src/renderer/task-readiness-notice.ts", "src/renderer/theme.ts", "src/renderer/titlebar-dim-color.ts", @@ -213,7 +209,6 @@ "src/renderer/use-app-shell-session-ui-reads.ts", "src/renderer/use-app-shell-session-workspace.ts", "src/renderer/use-deep-research-run.ts", - "src/renderer/use-external-store-selector.ts", "src/renderer/use-new-task-choice.ts", "src/renderer/use-onboarding-snapshot.ts", "src/renderer/use-project-context.ts", @@ -876,7 +871,7 @@ "nonTriviaTokens": 24 }, "src/renderer/use-app-shell-session-list.ts": { - "importDeclarations": 6, + "importDeclarations": 5, "bridgePaths": { "window.maka.sessions.list": 1 }, @@ -890,22 +885,22 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/session-catalog/session-catalog-state.js": 1, "./application/contracts/session-catalog/session-id-set.js": 1, "./application/contracts/session-catalog/use-external-store-selector.js": 1, "./locales/conversation-copy.js": 1, "./locales/shell-copy.js": 1, "./platform/desktop/session-catalog-sync.js": 1, - "./session-catalog-state.js": 1, "./session-read-state.js": 1, "./session-status-presentation.js": 1, "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 7, + "importSpecifiers": 6, "nonTriviaTokens": 470 }, "src/renderer/use-app-shell-session-ui-reads.ts": { - "importDeclarations": 1, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -915,14 +910,14 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./features/conversation/index.js": 1, - "./use-external-store-selector.js": 1 + "./application/contracts/session-catalog/use-external-store-selector.js": 1, + "./features/conversation/index.js": 1 }, - "importSpecifiers": 1, + "importSpecifiers": 0, "nonTriviaTokens": 135 }, "src/renderer/use-app-shell-session-workspace.ts": { - "importDeclarations": 7, + "importDeclarations": 5, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -936,16 +931,16 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/session-catalog/session-catalog-state.js": 1, + "./application/contracts/session-catalog/use-external-store-selector.js": 1, "./bootstrap-selection-lease.js": 1, "./features/conversation/index.js": 1, "./new-task-reload-intent.js": 1, - "./session-catalog-state.js": 1, "./session-workspace-actions.js": 1, "./use-app-shell-session-list.js": 1, - "./use-external-store-selector.js": 1, "react": 1 }, - "importSpecifiers": 8, + "importSpecifiers": 5, "nonTriviaTokens": 459 } }, @@ -1109,12 +1104,12 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "./application/contracts/session-catalog/use-external-store-selector.js": 1, "./chat-recovery-notice": 1, "./features/conversation/index.js": 1, "./locales/shell-copy": 1, "./onboarding-hero": 1, "./use-deep-research-run": 1, - "./use-external-store-selector": 1, "@astryxdesign/core": 1, "@maka/core/deep-research": 1, "@maka/ui": 1, @@ -1994,17 +1989,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/session-catalog-state.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/session-catalog/session-catalog-state.js": 1 - } - }, "src/renderer/session-collaboration-dialog.tsx": { "bridgePaths": { "window.maka.localRuntimeHostRemoteAccess.getSnapshot": 2, @@ -3927,17 +3911,6 @@ "react": 1 } }, - "src/renderer/use-external-store-selector.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/session-catalog/use-external-store-selector.js": 1 - } - }, "src/renderer/use-new-task-choice.ts": { "bridgePaths": {}, "environmentCapabilities": {}, diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 2a169ba4bd..82ca021e16 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -32,7 +32,7 @@ import { } from './default-runtime-host-operation.js'; import { buildCommandList } from "./command-palette-commands.js"; import type { Command } from './features/overlays/index.js'; -import type { SessionCatalogController } from './session-catalog-state.js'; +import type { SessionCatalogController } from './application/contracts/session-catalog/session-catalog-state.js'; import { renderConversationMarkdown } from "./conversation-markdown.js"; import { commandPaletteActionErrorMessage, 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 c8b5908a30..fefcf2c4bf 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 @@ -130,8 +130,6 @@ export const selectSessionById = ( sessionId: string | undefined, ): DesktopSessionSummary | undefined => sessionId === undefined ? undefined : state.sessions.find((s) => s.id === sessionId); -export const selectSessionCount = (state: SessionCatalogState): number => state.sessions.length; -export const selectCatalogRevision = (state: SessionCatalogState): number => state.revision; export const selectActiveSessionId = (state: SessionCatalogState): string | undefined => state.activeSessionId; diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 11299b5c55..b44832834a 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -36,7 +36,7 @@ import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery' import type { TaskReadinessNotice } from './task-readiness-notice'; import { getShellCopy } from './locales/shell-copy'; import { selectLiveTurns } from './features/conversation/index.js'; -import { useExternalStoreSelector } from './use-external-store-selector'; +import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js'; import { useDeepResearchRun } from './use-deep-research-run'; import { ChatRecoveryNotice, SessionHealthRecoveryNotice } from './chat-recovery-notice'; diff --git a/apps/desktop/src/renderer/features/session-navigation/index.ts b/apps/desktop/src/renderer/features/session-navigation/index.ts index 69313296c2..38cadf9fe5 100644 --- a/apps/desktop/src/renderer/features/session-navigation/index.ts +++ b/apps/desktop/src/renderer/features/session-navigation/index.ts @@ -22,7 +22,6 @@ export { SessionNavigationProvider } from './ui/session-navigation-provider.js'; export { createSessionOpenCommand } from './controller/session-open-command.js'; export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js'; export { deriveSessionRail } from './model/session-rail.js'; -export { sessionMatchesRail } from './model/session-nav-filter.js'; export { sessionRailLayoutStore } from './model/session-rail-layout-store.js'; export type { SessionNavigationRowActions, diff --git a/apps/desktop/src/renderer/observable-state.ts b/apps/desktop/src/renderer/observable-state.ts deleted file mode 100644 index 756231273c..0000000000 --- a/apps/desktop/src/renderer/observable-state.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export * from './application/contracts/session-catalog/observable-state.js'; diff --git a/apps/desktop/src/renderer/session-catalog-state.ts b/apps/desktop/src/renderer/session-catalog-state.ts deleted file mode 100644 index 0023a22f57..0000000000 --- a/apps/desktop/src/renderer/session-catalog-state.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export * from './application/contracts/session-catalog/session-catalog-state.js'; diff --git a/apps/desktop/src/renderer/session-event-health.ts b/apps/desktop/src/renderer/session-event-health.ts deleted file mode 100644 index ee3f032d2b..0000000000 --- a/apps/desktop/src/renderer/session-event-health.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export * from './application/contracts/session-catalog/session-event-health.js'; diff --git a/apps/desktop/src/renderer/stale-sessions.ts b/apps/desktop/src/renderer/stale-sessions.ts deleted file mode 100644 index 5f65601877..0000000000 --- a/apps/desktop/src/renderer/stale-sessions.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export * from './application/contracts/session-catalog/stale-sessions.js'; diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index bd28c1e90e..293da0eb99 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -31,7 +31,7 @@ import { import { selectAuthoritativeSessionIds, type SessionCatalogController, -} from './session-catalog-state.js'; +} from './application/contracts/session-catalog/session-catalog-state.js'; import { sessionIdSetsEqual } from './application/contracts/session-catalog/session-id-set.js'; import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js'; import { createSessionPatchDrain } from './platform/desktop/session-catalog-sync.js'; diff --git a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts index 1cf826a2a6..c56cc101f4 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-ui-reads.ts @@ -18,7 +18,7 @@ */ import { sessionUiSelectors as select, type AppShellSessionUiStateController } from './features/conversation/index.js'; -import { useExternalStoreSelector } from './use-external-store-selector.js'; +import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js'; /** Shell subscribes to low-frequency execution and content summaries, never raw tokens. */ export function useAppShellSessionUiReads(controller: AppShellSessionUiStateController, activeId: string | undefined) { diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index df623d71f6..c7b64e33f2 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -22,8 +22,8 @@ import * as Conversation from './features/conversation/index.js'; import { selectActiveSessionId, useSessionCatalogController, -} from './session-catalog-state.js'; -import { useExternalStoreSelector } from './use-external-store-selector.js'; +} from './application/contracts/session-catalog/session-catalog-state.js'; +import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js'; import { useAppShellSessionList } from './use-app-shell-session-list.js'; import { createBootstrapSelectionLease } from './bootstrap-selection-lease.js'; import { hasNewTaskReloadIntent } from './new-task-reload-intent.js'; diff --git a/apps/desktop/src/renderer/use-external-store-selector.ts b/apps/desktop/src/renderer/use-external-store-selector.ts deleted file mode 100644 index e224eb91ea..0000000000 --- a/apps/desktop/src/renderer/use-external-store-selector.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export * from './application/contracts/session-catalog/use-external-store-selector.js'; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 3152893c58..c40202e4c5 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -90,7 +90,7 @@ import type { ArchivedTasksBridge } from '../../src/renderer/settings/tasks-sett import { createSessionCatalogController, type SessionCatalogController, -} from '../../src/renderer/session-catalog-state.js'; +} from '../../src/renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { DesktopLocalRuntimeHostRemoteAccessSnapshot, DesktopRuntimeHostProfileChangedEvent, From 9e2224c89ab7d82eeb52ece12b2ed7c38c22905f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 21:24:52 +0800 Subject: [PATCH 13/14] fix(desktop): fence revision-draft retirement against catalog admission lag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CatalogRowWatch reported a just-created revision owner as absent the moment sessionIds flipped, and the shell retired the draft on that absence — clearing the edited text and silently failing the send. The owner's created-row read is asynchronous, so absence at watch-switch time is admission lag, not removal. The watch now distinguishes three states per id: observed, removed by a targeted read (commitPatch(id, null) tombstones it — a list omission cannot testify about a row it predates), and pending. Retirement only fires for observed-or-removed rows, keeping genuine deletion cleanup intact. A patch-admitted row could still be evicted by a list snapshot taken before the admission, so commitSessions gains an observedAtRevision fence: rows confirmed after the snapshot's observation point survive the commit, the membership-level analogue of the per-row staleness fence. The refresher stamps each fetch with the catalog revision at issue time. The composed sequence — owner switch before admission, tombstone, stale list, authoritative eviction — is pinned in session-change-retirement.test.ts. --- apps/desktop/renderer-architecture.json | 6 +- .../session-change-retirement.test.ts | 64 +++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 9 ++- .../session-catalog/catalog-row-watch.tsx | 80 ++++++++++++++++--- .../session-catalog/session-catalog-state.ts | 78 ++++++++++++++---- .../renderer/use-app-shell-session-list.ts | 42 +++++----- 6 files changed, 226 insertions(+), 53 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index eefc5de96a..c394305b4d 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -854,7 +854,7 @@ "react": 1 }, "importSpecifiers": 99, - "nonTriviaTokens": 12983 + "nonTriviaTokens": 12966 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, @@ -878,7 +878,7 @@ "environmentCapabilities": {}, "hookCalls": { "useExternalStoreSelector": 1, - "useRef": 2, + "useRef": 1, "useUiLocale": 1 }, "lifecycleMethods": {}, @@ -897,7 +897,7 @@ "react": 1 }, "importSpecifiers": 6, - "nonTriviaTokens": 470 + "nonTriviaTokens": 486 }, "src/renderer/use-app-shell-session-ui-reads.ts": { "importDeclarations": 0, diff --git a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts index 7a6e63ce4d..c983ef2bdd 100644 --- a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts +++ b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts @@ -22,6 +22,13 @@ import { describe, it } from 'node:test'; import type { SessionChangedEvent, SessionSummary, StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; import { handleSessionChangedEvent } from '../../renderer/application/contracts/session-catalog/session-change-effects.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { + annotateWatchedRows, + catalogWatchedRowsUsable, + selectWatchedCatalogRows, + type DesktopSessionSummary, +} from '../../renderer/application/contracts/session-catalog/catalog-row-watch.js'; import { createSessionWorkspaceActions } from '../../renderer/session-workspace-actions.js'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; @@ -130,3 +137,60 @@ describe('session retirement sweep', () => { assert.deepEqual(retired, ['viewer']); }); }); + +describe('revision-draft row watch fence', () => { + const sessionRow = (id: string): DesktopSessionSummary => + ({ id, activityAt: 1, isArchived: false, revision: 1 }) as DesktopSessionSummary; + const emit = ( + catalog: ReturnType, + ids: readonly (string | undefined)[], + seen: Set, + ) => annotateWatchedRows(selectWatchedCatalogRows(catalog.getState(), ids), ids, seen); + + it('fences retirement while a just-created owner is still unobserved', () => { + const catalog = createSessionCatalogController(); + catalog.commitSessions([sessionRow('a')]); + const seen = new Set(); + const ids = ['a', 'b'] as const; + // The draft's owner flips to B while the created-row read is in flight: + // the catalog holds [A] and B is absent, but that absence is pending. + const rows = emit(catalog, ids, seen); + assert.equal(rows[1]?.pending, true); + assert.equal(catalogWatchedRowsUsable(rows), true); + }); + + it('still retires a never-admitted row a targeted read reported gone', () => { + const catalog = createSessionCatalogController(); + catalog.commitSessions([sessionRow('a')]); + catalog.commitPatch('b', null); + const rows = emit(catalog, ['a', 'b'], new Set()); + assert.equal(rows[1]?.pending, false); + assert.equal(catalogWatchedRowsUsable(rows), false); + }); + + it('keeps a patch-admitted row when a list observed before admission lands', () => { + const catalog = createSessionCatalogController(); + catalog.commitSessions([sessionRow('a')]); + const observedBeforePatch = catalog.getState().revision; + catalog.commitPatch('b', sessionRow('b')); + catalog.commitSessions([sessionRow('a')], { observedAtRevision: observedBeforePatch }); + const rows = emit(catalog, ['a', 'b'], new Set()); + assert.equal(rows[1]?.summary?.id, 'b'); + assert.equal(catalogWatchedRowsUsable(rows), true); + }); + + it('retires the draft owner once an authoritative list omits it', () => { + const catalog = createSessionCatalogController(); + catalog.commitSessions([sessionRow('a')]); + catalog.commitPatch('b', sessionRow('b')); + const seen = new Set(); + const ids = ['a', 'b'] as const; + emit(catalog, ids, seen); + catalog.commitSessions([sessionRow('a')], { + observedAtRevision: catalog.getState().revision, + }); + const rows = emit(catalog, ids, seen); + assert.equal(rows[1]?.pending, false); + assert.equal(catalogWatchedRowsUsable(rows), false); + }); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 4231cf5fe2..04043015e0 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -86,7 +86,7 @@ import { } from './features/session-navigation'; import { CatalogRowWatch, - type DesktopSessionSummary, + catalogWatchedRowsUsable, } from './application/contracts/session-catalog/catalog-row-watch.js'; import * as TaskEntry from './features/task-entry'; import type { TaskEntryShellProjection } from './features/task-entry'; @@ -625,11 +625,12 @@ function AppShellContent({ // The draft survives on exactly two catalog rows; CatalogRowWatch below // selects them so their changes alone can retire it. const retireRevisionDraftIfRowsLeave = useCallback( - (rows: readonly (DesktopSessionSummary | undefined)[]) => { + (rows: Parameters[0]) => { const draft = revisionDraftRef.current; if (!draft) return; - const [source, owner] = rows; - if (source && owner && !source.isArchived && !owner.isArchived) return; + // A watched row that is merely pending — never observed, never reported + // removed — is admission lag, not a departure. + if (catalogWatchedRowsUsable(rows)) return; composerRef.current?.clearDraft(draft.draftSessionId); if (draft.sourceSessionId !== draft.draftSessionId) composerRef.current?.clearDraft(draft.sourceSessionId); diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/catalog-row-watch.tsx b/apps/desktop/src/renderer/application/contracts/session-catalog/catalog-row-watch.tsx index e2f86ed7ee..8947eb51b9 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/catalog-row-watch.tsx +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/catalog-row-watch.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useEffectEvent, useMemo } from 'react'; +import { useEffect, useEffectEvent, useMemo, useRef } from 'react'; import type { DesktopSessionSummary } from '../../../../shared/desktop-session-projection.js'; export type { DesktopSessionSummary }; @@ -28,17 +28,65 @@ import { type SessionCatalogState, } from './session-catalog-state.js'; -const selectRows = ( +/** What the catalog can currently prove about a watched id. */ +export interface ObservedCatalogRow { + readonly summary: DesktopSessionSummary | undefined; + /** A targeted read answered "gone" — authoritative absence, not admission lag. */ + readonly removed: boolean; +} + +export interface CatalogWatchedRow { + readonly summary: DesktopSessionSummary | undefined; + /** + * No row yet and neither an observation nor a removal covers this id — the + * catalog simply has not caught up. Absence here is not evidence of removal. + */ + readonly pending: boolean; +} + +export const selectWatchedCatalogRows = ( state: SessionCatalogState, ids: readonly (string | undefined)[], -): (DesktopSessionSummary | undefined)[] => - ids.map((id) => selectSessionById(state, id)); +): ObservedCatalogRow[] => + ids.map((id) => ({ + summary: selectSessionById(state, id), + removed: id !== undefined && state.removedIds.has(id), + })); -function rowsEqual( - a: readonly (DesktopSessionSummary | undefined)[], - b: readonly (DesktopSessionSummary | undefined)[], +function observedRowsEqual( + a: readonly ObservedCatalogRow[], + b: readonly ObservedCatalogRow[], ): boolean { - return a.length === b.length && a.every((row, index) => row === b[index]); + return a.length === b.length + && a.every((row, index) => row.summary === b[index].summary && row.removed === b[index].removed); +} + +/** + * Resolve an emitted observation against the ids this watch has already seen. + * Marks each id that produced a row — so an id whose row later disappears is + * a removal, while one that was never observed stays pending: created-but-not- + * yet-admitted is the edit-and-resend window, not a deletion. + */ +export function annotateWatchedRows( + observed: readonly ObservedCatalogRow[], + ids: readonly (string | undefined)[], + seen: Set, +): CatalogWatchedRow[] { + return ids.map((id, index) => { + const { summary, removed } = observed[index] ?? { summary: undefined, removed: false }; + if (id !== undefined && summary !== undefined) seen.add(id); + return { + summary, + pending: id !== undefined && summary === undefined && !removed && !seen.has(id), + }; + }); +} + +/** Every watched row is present and usable, or still pending its first observation. */ +export function catalogWatchedRowsUsable(rows: readonly CatalogWatchedRow[]): boolean { + return rows.every( + (row) => row.pending || (row.summary !== undefined && !row.summary.isArchived), + ); } const EMPTY_IDS: readonly (string | undefined)[] = []; @@ -51,19 +99,25 @@ const EMPTY_IDS: readonly (string | undefined)[] = []; export function CatalogRowWatch(props: { catalog: SessionCatalogController; sessionIds: readonly (string | undefined)[] | undefined; - onRows: (rows: readonly (DesktopSessionSummary | undefined)[]) => void; + onRows: (rows: readonly CatalogWatchedRow[]) => void; }) { const onRows = useEffectEvent(props.onRows); // The caller passes an inline array; the selector memo is keyed on the arg, // so the ids need a stable identity across renders that do not change them. const idsKey = (props.sessionIds ?? EMPTY_IDS).join('\0'); const ids = useMemo(() => idsKey.split('\0').map((id) => id || undefined), [idsKey]); - const rows = useExternalStoreSelector( + const observed = useExternalStoreSelector( props.catalog, - selectRows, + selectWatchedCatalogRows, ids, - rowsEqual, + observedRowsEqual, + ); + // `seen` lives in an effect, not the selector: a snapshot can be read on a + // render React discards, and only rows actually reported count as observed. + const seenRef = useRef>(new Set()); + useEffect( + () => onRows(annotateWatchedRows(observed, ids, seenRef.current)), + [observed, ids, onRows], ); - useEffect(() => onRows(rows), [rows, onRows]); return null; } 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 fefcf2c4bf..1e813eb328 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,13 @@ export interface SessionCatalogState { readonly sessions: readonly DesktopSessionSummary[]; readonly revision: number; readonly activeSessionId: string | undefined; + /** + * Ids a targeted row read reported as gone (`sessions.get` → null). A list + * omission never lands here: a snapshot taken before a session existed + * cannot testify about it, so only the row-level answer counts as + * authoritative absence for an id the catalog never held. + */ + readonly removedIds: ReadonlySet; } export function createSessionCatalogController() { @@ -51,12 +58,19 @@ export function createSessionCatalogController() { sessions: [], revision: 0, activeSessionId: undefined, + removedIds: new Set(), }); + // Catalog revision at which each row's existence was last confirmed by a + // patch — the fence a stale list commit is measured against. + const existenceConfirmedAt = new Map(); return { getState: state.getState, subscribe: state.subscribe, - commitSessions(next: readonly DesktopSessionSummary[]): void { + commitSessions( + next: readonly DesktopSessionSummary[], + options?: { observedAtRevision?: number }, + ): void { const current = state.getState(); // Published references change iff values change: an unchanged row keeps // its identity so per-row readers and memos survive a re-list, and a @@ -69,15 +83,38 @@ export function createSessionCatalogController() { ? prior : s; }); - const sameRows = reconciled.length === current.sessions.length - && reconciled.every((s, i) => s === current.sessions[i]); + // A row whose existence a patch confirmed after this list was observed + // is newer than anything the list can claim about it — keep it. This is + // the membership-level analogue of the per-row staleness fence. + const inNext = new Set(next.map((s) => s.id)); + const observedAt = options?.observedAtRevision; + const sessions = observedAt === undefined + ? reconciled + : reconciled.concat( + current.sessions.filter( + (s) => !inNext.has(s.id) && (existenceConfirmedAt.get(s.id) ?? -1) > observedAt, + ), + ); + if (sessions.length !== reconciled.length) + sessions.sort(compareDesktopSessionCatalogSummaries); + let removedIds = current.removedIds; + if (removedIds.size > 0) { + const cleared = new Set(removedIds); + for (const s of sessions) cleared.delete(s.id); + if (cleared.size !== removedIds.size) removedIds = cleared; + } + for (const id of existenceConfirmedAt.keys()) + if (!sessions.some((s) => s.id === id)) existenceConfirmedAt.delete(id); + 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 && current.revision > 0) return; + if (sameRows && removedIds === current.removedIds && current.revision > 0) return; state.replaceState({ ...current, - sessions: sameRows ? current.sessions : reconciled, + sessions: sameRows ? current.sessions : sessions, + removedIds, revision: current.revision + 1, }); }, @@ -85,12 +122,22 @@ export function createSessionCatalogController() { const current = state.getState(); const index = current.sessions.findIndex((s) => s.id === sessionId); const prior = index < 0 ? undefined : current.sessions[index]; + const revision = current.revision + 1; if (summary === null) { - if (prior === undefined) return; + // A targeted "gone" answer tombstones the id even when the row was + // never admitted — watchers can then tell removal apart from + // admission still in flight. + if (prior === undefined && current.removedIds.has(sessionId)) return; + existenceConfirmedAt.delete(sessionId); + const removedIds = new Set(current.removedIds); + removedIds.add(sessionId); state.replaceState({ ...current, - sessions: current.sessions.filter((s) => s.id !== sessionId), - revision: current.revision + 1, + sessions: prior === undefined + ? current.sessions + : current.sessions.filter((s) => s.id !== sessionId), + removedIds, + revision, }); return; } @@ -101,12 +148,15 @@ export function createSessionCatalogController() { sessions.sort(compareDesktopSessionCatalogSummaries); const sameRows = sessions.length === current.sessions.length && sessions.every((s, i) => s === current.sessions[i]); - if (sameRows) return; - state.replaceState({ - ...current, - sessions, - revision: current.revision + 1, - }); + const removedIds = current.removedIds.has(sessionId) + ? new Set([...current.removedIds].filter((id) => id !== sessionId)) + : current.removedIds; + existenceConfirmedAt.set( + sessionId, + sameRows && removedIds === current.removedIds ? current.revision : revision, + ); + if (sameRows && removedIds === current.removedIds) return; + state.replaceState({ ...current, sessions, removedIds, revision }); }, setActiveSessionId(next: string | undefined): void { const current = state.getState(); diff --git a/apps/desktop/src/renderer/use-app-shell-session-list.ts b/apps/desktop/src/renderer/use-app-shell-session-list.ts index 293da0eb99..14452cb392 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -26,7 +26,6 @@ import { } from './session-status-presentation.js'; import { createSessionListRefresher, - type SessionListRefresher, } from './session-read-state.js'; import { selectAuthoritativeSessionIds, @@ -66,21 +65,26 @@ export function useAppShellSessionList( () => ({ get current() { return catalog.getState().sessions; } }), [catalog], ); - const refresherRef = useRef | null>(null); - refresherRef.current ??= createSessionListRefresher({ - listSessions: () => window.maka.sessions.list(), - currentSessions: () => [...sessionsRef.current], - commitSessions: (next) => - catalog.commitSessions(next.map(normalizeSessionSummaryForDisplay)), - onError: (error) => { - const locale = uiLocaleRef.current; - const copy = getDesktopConversationCopy(locale).actions; - toastApi.error( - copy.refreshSessionsFailedTitle, - localizedShellErrorMessage(error, copy.refreshSessionsFailedFallback, locale), - ); - }, - }); + const refresher = useMemo(() => { + let observedAtRevision = 0; + return createSessionListRefresher({ + listSessions: () => { + observedAtRevision = catalog.getState().revision; + return window.maka.sessions.list(); + }, + currentSessions: () => [...sessionsRef.current], + commitSessions: (next) => + catalog.commitSessions(next.map(normalizeSessionSummaryForDisplay), { observedAtRevision }), + onError: (error) => { + const locale = uiLocaleRef.current; + const copy = getDesktopConversationCopy(locale).actions; + toastApi.error( + copy.refreshSessionsFailedTitle, + localizedShellErrorMessage(error, copy.refreshSessionsFailedFallback, locale), + ); + }, + }); + }, [catalog, sessionsRef]); // Fixed identities for the renderer's lifetime: everything closes over ref // boxes or the stable controller, and consumers list the actions in dep @@ -91,10 +95,10 @@ export function useAppShellSessionList( const drain = createSessionPatchDrain({ normalize: normalizeSessionSummaryForDisplay, commitPatch: (sessionId, summary) => catalog.commitPatch(sessionId, summary), - onReadFailure: () => void refresherRef.current?.refresh().catch(() => undefined), + onReadFailure: () => void refresher.refresh().catch(() => undefined), }); return { - refreshSessions: () => refresherRef.current!.refresh(), + refreshSessions: () => refresher.refresh(), refreshChangedSession: drain.request, seedSessions(snapshotSessions: readonly DesktopSessionSummary[]) { const next = snapshotSessions.map(normalizeSessionSummaryForDisplay); @@ -102,7 +106,7 @@ export function useAppShellSessionList( return next; }, }; - }, [catalog]); + }, [catalog, refresher]); return { authoritativeSessionIds, sessionsRef, ...actions }; } From 40ae5afec51bb02f327b616b129c69f3668a0efd Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 20 Sep 2026 23:22:23 +0800 Subject: [PATCH 14/14] fix(desktop): keep locally-owned Sessions out of targeted catalog reads sessions.get can only answer for Host-owned rows, while the merged list also carries pending Sessions the local store still owns. Emitting a sessions:changed event with a pending Session id routed it through the row-level drain, whose sessions.get returned null and evicted the row. The sweep then retired the selected Session and cleared the transcript, leaving the shell on the new-task hero after every first send. The local-change emitter now drops the row id while the store holds the creation intent, so pending changes refresh the merged list; admission clears the marker and the targeted path resumes. On the renderer side, a row-level read retires only its own tombstoned id: an unrelated event can no longer read a still-uncommitted selection as deletion. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/desktop/renderer-architecture.json | 4 +- .../session-change-retirement.test.ts | 81 ++++++++++++++----- .../src/main/__tests__/session-local.test.ts | 43 +++++++++- apps/desktop/src/main/runtime-host-boot.ts | 10 +-- .../desktop/src/main/session-local-service.ts | 31 +++++++ .../desktop/src/renderer/app-shell-effects.ts | 2 + apps/desktop/src/renderer/app-shell.tsx | 1 + .../session-catalog/session-catalog-state.ts | 3 + .../session-catalog/session-change-effects.ts | 19 +++-- 9 files changed, 158 insertions(+), 36 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c394305b4d..10b064bcf4 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -496,7 +496,7 @@ "react": 1 }, "importSpecifiers": 14, - "nonTriviaTokens": 3502 + "nonTriviaTokens": 3512 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -854,7 +854,7 @@ "react": 1 }, "importSpecifiers": 99, - "nonTriviaTokens": 12966 + "nonTriviaTokens": 12972 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, diff --git a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts index c983ef2bdd..1416f4264c 100644 --- a/apps/desktop/src/main/__tests__/session-change-retirement.test.ts +++ b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts @@ -32,16 +32,26 @@ import { import { createSessionWorkspaceActions } from '../../renderer/session-workspace-actions.js'; import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -function row(id: string): SessionSummary { - return { id, name: id } as SessionSummary; +function row(id: string): DesktopSessionSummary { + return { id, name: id, activityAt: 1, isArchived: false, revision: 1 } as DesktopSessionSummary; } function flush(): Promise { return new Promise((resolve) => setImmediate(resolve)); } -function harness(activeId: string | undefined, catalog: SessionSummary[]) { - const sessionsRef = { current: [...catalog] }; +function harness( + activeId: string | undefined, + catalogRows: DesktopSessionSummary[], + source: Record, +) { + const catalog = createSessionCatalogController(); + catalog.commitSessions(catalogRows); + const sessionsRef = { + get current() { + return [...catalog.getState().sessions] as SessionSummary[]; + }, + }; const activeIdRef = { current: activeId }; const requestedRef = { current: activeId }; const retired: string[] = []; @@ -55,6 +65,7 @@ function harness(activeId: string | undefined, catalog: SessionSummary[]) { selectionRevisionRef: { current: 0 }, setActiveIdState: (next) => { activeIdRef.current = next; + requestedRef.current = next; }, setMessagesState: () => {}, setTransientMessagesState: () => {}, @@ -67,14 +78,20 @@ function harness(activeId: string | undefined, catalog: SessionSummary[]) { sessionsRef, retireSession: (sessionId: string) => retired.push(sessionId), retiredSessionIds: workspace.retiredSessionIds, + isSessionRemoved: catalog.isRemoved, clearPendingTurnActionsForSession: () => {}, refreshMessages: () => Promise.resolve(true), refreshProjects: () => Promise.resolve(), - refreshSessions: () => Promise.resolve(sessionsRef.current as SessionSummary[]), + refreshSessions: () => { + const next = Object.values(source).filter((s): s is DesktopSessionSummary => s !== null); + catalog.commitSessions(next); + return Promise.resolve(next as SessionSummary[]); + }, // Mirrors the production drain: the committed catalog is updated before the // row read resolves, so a resolved promise means sessionsRef is current. refreshChangedSession: (sessionId: string) => { - const next = catalog.find((session) => session.id === sessionId) ?? null; + const next = source[sessionId] ?? null; + catalog.commitPatch(sessionId, next); return Promise.resolve(next); }, setSessionEventHealthBySession: () => {}, @@ -85,12 +102,16 @@ function harness(activeId: string | undefined, catalog: SessionSummary[]) { toast: () => {}, }, }; - return { options, retired, sessionsRef }; + return { options, retired, sessionsRef, catalog }; } describe('session retirement sweep', () => { it('keeps the selected session when an unrelated row changes', async () => { - const { options, retired } = harness('viewer', [row('viewer'), row('background')]); + const { options, retired } = harness( + 'viewer', + [row('viewer'), row('background')], + { background: row('background') }, + ); const event: SessionChangedEvent = { reason: 'message-appended', sessionId: 'background', @@ -101,12 +122,26 @@ describe('session retirement sweep', () => { assert.deepEqual(retired, []); }); + it('keeps a selected session an unrelated row read cannot prove absent', async () => { + // The pending Session is selected before its row lands in the catalog; a + // targeted read proves only its own row, so the admission gap must not + // read as deletion. + const { options, retired } = harness('pending-task', [], { + background: row('background'), + }); + handleSessionChangedEvent( + { reason: 'updated', sessionId: 'background', ts: 1 }, + options, + ); + await flush(); + assert.deepEqual(retired, []); + }); + it('retires the selected session when its row leaves the catalog', async () => { - const { options, retired, sessionsRef } = harness('viewer', [row('viewer'), row('background')]); - options.refreshChangedSession = () => { - sessionsRef.current = [row('background')]; - return Promise.resolve(null); - }; + const { options, retired } = harness('viewer', [row('viewer'), row('background')], { + viewer: null, + background: row('background'), + }); handleSessionChangedEvent( { reason: 'deleted', sessionId: 'viewer', ts: 1 }, options, @@ -115,11 +150,15 @@ describe('session retirement sweep', () => { assert.deepEqual(retired, ['viewer']); }); - it('retires nothing when a row read fails and the catalog keeps the row', async () => { - const { options, retired } = harness('viewer', [row('viewer'), row('background')]); + it('keeps the selected session when its row read fails', async () => { + const { options, retired } = harness( + 'viewer', + [row('viewer'), row('background')], + {}, + ); options.refreshChangedSession = () => Promise.resolve(null); handleSessionChangedEvent( - { reason: 'status-change', sessionId: 'background', ts: 1 }, + { reason: 'status-change', sessionId: 'viewer', ts: 1 }, options, ); await flush(); @@ -127,11 +166,11 @@ describe('session retirement sweep', () => { }); it('sweeps retired rows after a membership refresh', async () => { - const { options, retired, sessionsRef } = harness('viewer', [row('viewer'), row('background')]); - options.refreshSessions = () => { - sessionsRef.current = [row('background')]; - return Promise.resolve(sessionsRef.current); - }; + const { options, retired } = harness( + 'viewer', + [row('viewer'), row('background')], + { background: row('background') }, + ); handleSessionChangedEvent({ reason: 'status-change', ts: 1 }, options); await flush(); assert.deepEqual(retired, ['viewer']); diff --git a/apps/desktop/src/main/__tests__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index e49bf808a9..f5f7b430f2 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -30,9 +30,10 @@ import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, } from '@maka/runtime-host/client'; -import type { TurnMessageSubmitInput, TurnMessageSubmitResult } from '@maka/runtime-host/protocol'; +import type { SessionCreateInput, TurnMessageSubmitInput, TurnMessageSubmitResult } from '@maka/runtime-host/protocol'; import { DesktopSessionLocalStore, type LocalMessageIntent } from '../session-local-store.js'; import { + createSessionLocalChangedEmitter, DesktopSessionLocalService, desktopSessionLocalPartition, registerDesktopSessionLocalIpc, @@ -247,6 +248,46 @@ test('a catalog read begun before local creation cannot erase that Session or it assert.equal(store.list('authority').length, 1); }); +test('a locally-owned Session change signals a list refresh, not a targeted row read', async (t) => { + const { store, beforeClose } = await database(t); + const target: DesktopSessionLocalTarget = { + partition: 'authority', + profileId: 'profile', + scope: { hostId: 'root', targetEpoch: 'target' }, + }; + const service = new DesktopSessionLocalService(store, { + targets: () => [target], + changed() {}, + onError: (error) => assert.fail(String(error)), + }); + beforeClose.push(() => service.close()); + const sent: { channel: string; payload: unknown }[] = []; + const emit = createSessionLocalChangedEmitter({ + send: (channel, _scope, payload) => sent.push({ channel, payload }), + locallyOwned: (scope, sessionId) => service.locallyOwned(scope, sessionId), + }); + // The store still holds the creation intent, so no Host row exists for a + // targeted `sessions.get` to read. + store.saveSession( + 'authority', + { id: 'session-1', name: 'task' } as DesktopSessionSummaryInput, + { sessionId: 'session-1' } as SessionCreateInput, + ); + emit(target.scope, 'session-1'); + assert.deepEqual( + sent.map(({ channel, payload }) => [channel, (payload as { sessionId?: string }).sessionId]), + [ + ['session-local:changed', 'session-1'], + ['sessions:changed', undefined], + ], + ); + // Host admission clears the creation marker, so the targeted path resumes. + store.saveSession('authority', { id: 'session-1', name: 'task' } as DesktopSessionSummaryInput); + emit(target.scope, 'session-1'); + const last = sent[sent.length - 1]?.payload as { sessionId?: string } | undefined; + assert.equal(last?.sessionId, 'session-1'); +}); + test('an authorization failure quarantines the still-connected authority from cache and admission', async (t) => { const { store, beforeClose } = await database(t); const target: DesktopSessionLocalTarget = { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 958ffbc26a..33f6dd0d51 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -102,7 +102,7 @@ import { renderAttachmentPreview, resizeImageForAttachment } from "./attachment- import { registerAttachmentPreviewIpc } from "./attachment-preview.js"; import { readFileCapped, resolvePickedAttachments } from "./attachment-ingest.js"; import { DesktopSessionLocalStore } from './session-local-store.js'; -import { DesktopSessionLocalService, desktopSessionLocalPartition, registerDesktopSessionLocalIpc, type DesktopSessionLocalTarget } from './session-local-service.js'; +import { createSessionLocalChangedEmitter, DesktopSessionLocalService, desktopSessionLocalPartition, registerDesktopSessionLocalIpc, type DesktopSessionLocalTarget } from './session-local-service.js'; import { registerBrowserIpc } from "./browser-ipc-main.js"; import { browserViewHost } from "./browser/browser-host.js"; import { releaseBrowserSession } from "./browser/session.js"; @@ -602,10 +602,10 @@ onMainWindowClose = () => { }; const attachmentApprovals = createAttachmentApprovalRegistry(); const sessionLocalStore = new DesktopSessionLocalStore(join(userDataDir, 'session-experience.sqlite')); -const localSessionChanged = (scope: DesktopTargetScope, sessionId?: string): void => { - mainWindowController.send('session-local:changed', scope, { sessionId }); - mainWindowController.send('sessions:changed', scope, { reason: 'updated', ts: Date.now(), ...(sessionId ? { sessionId } : {}) }); -}; +const localSessionChanged = createSessionLocalChangedEmitter({ + send: (channel, scope, payload) => mainWindowController.send(channel, scope, payload), + locallyOwned: (scope, sessionId) => sessionLocal.locallyOwned(scope, sessionId), +}); const sessionLocal = new DesktopSessionLocalService(sessionLocalStore, { targets: () => (runtimeHostManager?.entries() ?? []).flatMap((state) => { if (state.readiness === 'unavailable' && state.error instanceof RuntimeHostProfileConnectionError && state.error.reason === 'credential_rejected') return []; diff --git a/apps/desktop/src/main/session-local-service.ts b/apps/desktop/src/main/session-local-service.ts index 7470afe20f..826a5daa8e 100644 --- a/apps/desktop/src/main/session-local-service.ts +++ b/apps/desktop/src/main/session-local-service.ts @@ -125,6 +125,15 @@ export class DesktopSessionLocalService { return target; } + /** True while the local store still owns the Session's creation intent. */ + locallyOwned(scope: DesktopTargetScope, sessionId: string): boolean { + try { + return this.store.creation(this.target(scope).partition, sessionId) !== undefined; + } catch { + return false; + } + } + changed(scope?: DesktopTargetScope): void { if (scope) { const target = this.deps @@ -678,3 +687,25 @@ function requiredId(value: unknown): string { throw new Error('Invalid local Session or Message identity'); return value; } + +/** + * `session-local:changed` keeps the row id for message-level readers, while + * `sessions:changed` drops it for a Session the local store still owns: a + * targeted `sessions.get` can only answer for Host-owned rows, so a pending + * Session's change must signal a merged-list refresh instead. + */ +export function createSessionLocalChangedEmitter(deps: { + send(channel: string, scope: DesktopTargetScope, payload: unknown): void; + locallyOwned(scope: DesktopTargetScope, sessionId: string): boolean; +}): (scope: DesktopTargetScope, sessionId?: string) => void { + return (scope, sessionId) => { + deps.send('session-local:changed', scope, { sessionId }); + const catalogSessionId = + sessionId !== undefined && !deps.locallyOwned(scope, sessionId) ? sessionId : undefined; + deps.send('sessions:changed', scope, { + reason: 'updated', + ts: Date.now(), + ...(catalogSessionId !== undefined ? { sessionId: catalogSessionId } : {}), + }); + }; +} diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 33f614d008..05b00379e4 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -165,6 +165,8 @@ export function useAppShellBootstrapSubscriptions(options: { rendererMountedRef: RefBox; retireSession: (sessionId: string) => void; retiredSessionIds(sessions: readonly { id: string }[]): string[]; + /** A targeted row read committed this id's authoritative absence. */ + isSessionRemoved(sessionId: string): boolean; /** Mirrors the committed catalog; refresh promises resolve after commit. */ sessionsRef: RefBox; setSessionEventHealthBySession: SessionEventHealthUpdater; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 04043015e0..2fee1445e1 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1878,6 +1878,7 @@ function AppShellContent({ rendererMountedRef, retireSession: clearSessionRendererState, retiredSessionIds, + isSessionRemoved: sessionCatalogController.isRemoved, sessionsRef, setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, 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 1e813eb328..e16a54e92b 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 @@ -163,6 +163,9 @@ export function createSessionCatalogController() { if (current.activeSessionId === next) return; state.replaceState({ ...current, activeSessionId: next }); }, + isRemoved(sessionId: string): boolean { + return state.getState().removedIds.has(sessionId); + }, }; } diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts index 726b041070..50f2a64452 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts @@ -38,6 +38,8 @@ export function handleSessionChangedEvent( refreshChangedSession: (sessionId: string) => Promise; retireSession: (sessionId: string) => void; retiredSessionIds(sessions: readonly { id: string }[]): string[]; + /** A targeted row read committed this id's authoritative absence. */ + isSessionRemoved(sessionId: string): boolean; /** Mirrors the committed catalog; refresh promises resolve after commit. */ sessionsRef: RefBox; /** Surfaces a model rebound; the caller owns the copy. */ @@ -45,12 +47,10 @@ export function handleSessionChangedEvent( setSessionEventHealthBySession: SessionEventHealthUpdater; }, ): void { - // The sweep below reads the committed catalog (sessionsRef) rather than this - // result: on the single-row path the result is one row, not the complete - // list `retiredSessionIds` compares membership against. - const refreshedSessions: Promise = event.sessionId === undefined + const changedSessionId = event.sessionId; + const refreshedSessions: Promise = changedSessionId === undefined ? options.refreshSessions() - : options.refreshChangedSession(event.sessionId); + : options.refreshChangedSession(changedSessionId); if (event.reason === 'archived' && event.sessionId) options.retireSession(event.sessionId); if (event.reason === 'created' || event.reason === 'migrated') { void options.refreshProjects(); @@ -71,12 +71,17 @@ export function handleSessionChangedEvent( ) { options.clearPendingTurnActionsForSession(event.sessionId); } - const changedSessionId = event.sessionId; if (event.reason === 'message-appended' && changedSessionId && changedSessionId === options.activeIdRef.current) { void options.refreshMessages(changedSessionId); } if (event.reason === 'rebound') options.notifyModelRebound(event.modelId); void refreshedSessions.then(() => { - options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); + if (changedSessionId === undefined) { + // A list read is the catalog's membership authority. + options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); + return; + } + // A row-level read can only prove its own row's absence. + if (options.isSessionRemoved(changedSessionId)) options.retireSession(changedSessionId); }); }