diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d5f458bd1fd..10b064bcf4a 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", @@ -240,11 +235,8 @@ "legacyFeatureImports": [ "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-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/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/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", @@ -388,8 +380,8 @@ "./locales/shell-copy.js": 1, "react": 1 }, - "importSpecifiers": 9, - "nonTriviaTokens": 2275 + "importSpecifiers": 8, + "nonTriviaTokens": 2234 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -454,7 +446,7 @@ "nonTriviaTokens": 637 }, "src/renderer/app-shell-effects.ts": { - "importDeclarations": 10, + "importDeclarations": 9, "bridgePaths": { "window.maka.app.info": 1, "window.maka.appWindow.subscribeCommand": 1, @@ -491,10 +483,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-event-health": 1, "./shell-run-update-state.js": 1, "./theme": 1, "./titlebar-modal-sync": 1, @@ -502,8 +495,8 @@ "@maka/core/session-event-health": 1, "react": 1 }, - "importSpecifiers": 18, - "nonTriviaTokens": 3677 + "importSpecifiers": 14, + "nonTriviaTokens": 3512 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -533,7 +526,7 @@ "react": 1 }, "importSpecifiers": 8, - "nonTriviaTokens": 860 + "nonTriviaTokens": 858 }, "src/renderer/app-shell-project-actions.ts": { "importDeclarations": 4, @@ -711,7 +704,7 @@ "nonTriviaTokens": 1245 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 64, + "importDeclarations": 63, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -750,7 +743,7 @@ "useAppShellSessionWorkspace": 1, "useAppShellTurnPresentation": 1, "useComposerAttachments": 1, - "useEffect": 7, + "useEffect": 6, "useLayoutEffect": 2, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, @@ -793,6 +786,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, @@ -832,7 +826,6 @@ "./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, @@ -860,8 +853,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 100, - "nonTriviaTokens": 13003 + "importSpecifiers": 99, + "nonTriviaTokens": 12972 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, @@ -878,35 +871,36 @@ "nonTriviaTokens": 24 }, "src/renderer/use-app-shell-session-list.ts": { - "importDeclarations": 6, + "importDeclarations": 5, "bridgePaths": { "window.maka.sessions.list": 1 }, "environmentCapabilities": {}, "hookCalls": { - "useExternalStoreSelector": 3, - "useRef": 4, + "useExternalStoreSelector": 1, + "useRef": 1, "useUiLocale": 1 }, "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./features/conversation/index.js": 1, + "./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, - "./session-catalog-state.js": 1, + "./platform/desktop/session-catalog-sync.js": 1, "./session-read-state.js": 1, "./session-status-presentation.js": 1, - "./use-external-store-selector.js": 1, "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 9, + "importSpecifiers": 6, "nonTriviaTokens": 486 }, "src/renderer/use-app-shell-session-ui-reads.ts": { - "importDeclarations": 1, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -916,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": { @@ -937,17 +931,17 @@ "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, - "nonTriviaTokens": 461 + "importSpecifiers": 5, + "nonTriviaTokens": 459 } }, "closure": { @@ -1110,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, @@ -1839,15 +1833,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/observable-state.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/onboarding-hero-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2004,20 +1989,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/session-catalog-state.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useRef": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./observable-state.js": 1, - "react": 1 - } - }, "src/renderer/session-collaboration-dialog.tsx": { "bridgePaths": { "window.maka.localRuntimeHostRemoteAccess.getSnapshot": 2, @@ -2075,18 +2046,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": {}, @@ -3486,6 +3445,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, @@ -3845,15 +3805,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": {}, @@ -3960,19 +3911,6 @@ "react": 1 } }, - "src/renderer/use-external-store-selector.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useSyncExternalStore": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "react": 1 - } - }, "src/renderer/use-new-task-choice.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 6931544b170..214361cf4ba 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/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'; @@ -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 0776e81cf07..3cdce30cc96 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/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'; @@ -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__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 7c867272276..b64750940fd 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/main/__tests__/session-change-retirement.test.ts b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts new file mode 100644 index 00000000000..1416f4264c3 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-change-retirement.test.ts @@ -0,0 +1,235 @@ +/* + * 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/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'; + +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, + 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[] = []; + 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; + requestedRef.current = next; + }, + setMessagesState: () => {}, + setTransientMessagesState: () => {}, + setMessageLoadPending: () => {}, + clearSessionUiState: () => {}, + }); + const options = { + uiLocale: 'en' as const, + activeIdRef, + sessionsRef, + retireSession: (sessionId: string) => retired.push(sessionId), + retiredSessionIds: workspace.retiredSessionIds, + isSessionRemoved: catalog.isRemoved, + clearPendingTurnActionsForSession: () => {}, + refreshMessages: () => Promise.resolve(true), + refreshProjects: () => Promise.resolve(), + 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 = source[sessionId] ?? null; + catalog.commitPatch(sessionId, next); + return Promise.resolve(next); + }, + setSessionEventHealthBySession: () => {}, + notifyModelRebound: () => {}, + toastApi: { + error: () => {}, + info: () => {}, + toast: () => {}, + }, + }; + 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')], + { background: row('background') }, + ); + const event: SessionChangedEvent = { + reason: 'message-appended', + sessionId: 'background', + ts: 1, + }; + handleSessionChangedEvent(event, options); + await flush(); + 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 } = harness('viewer', [row('viewer'), row('background')], { + viewer: null, + background: row('background'), + }); + handleSessionChangedEvent( + { reason: 'deleted', sessionId: 'viewer', ts: 1 }, + options, + ); + await flush(); + assert.deepEqual(retired, ['viewer']); + }); + + 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: 'viewer', ts: 1 }, + options, + ); + await flush(); + assert.deepEqual(retired, []); + }); + + it('sweeps retired rows after a membership refresh', async () => { + 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']); + }); +}); + +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/main/__tests__/session-event-health.test.ts b/apps/desktop/src/main/__tests__/session-event-health.test.ts index 6f4f3c79737..db9b919e638 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-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index e49bf808a97..f5f7b430f22 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/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index ce3329f6c37..15e2eb107f2 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/application/contracts/session-catalog/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(), + sessionSendOutcomes: {}, + 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 562bdc5d8d7..f0318518d0b 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/application/contracts/session-catalog/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 ce3175b4f43..7768dfc81e4 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/application/contracts/session-catalog/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/__tests__/stale-sessions.test.ts b/apps/desktop/src/main/__tests__/stale-sessions.test.ts index dfc8771738b..f991aac745f 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__/use-onboarding-snapshot.test.ts b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts index 35084cadae8..16cbf1a0d0e 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/main/__tests__/workbar-model.test.ts b/apps/desktop/src/main/__tests__/workbar-model.test.ts index d2d5035636d..9b9e878b380 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/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 958ffbc26ad..33f6dd0d512 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/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index b0a67aea149..cf8d450af3b 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/main/session-local-service.ts b/apps/desktop/src/main/session-local-service.ts index 7470afe20f1..826a5daa8e1 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/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 2cddb6b9b8e..35749c78149 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 fc42c7d750e..c9bb44ff59b 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 151cab16cd4..2659e2685f6 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 a009b19a3f7..82ca021e16a 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -30,11 +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 type { SessionCatalogController } from './application/contracts/session-catalog/session-catalog-state.js'; import { renderConversationMarkdown } from "./conversation-markdown.js"; import { commandPaletteActionErrorMessage, @@ -75,9 +73,10 @@ export interface AppShellCommandListOptions { newTaskProfileId: string | undefined; settingsOpen: boolean; settingsProfileId: string | undefined; - sessions: readonly SessionSummary[]; + sessionCatalog: SessionCatalogController; themePref: ThemePreference; - visibleSessions: SessionSummary[]; + /** Sessions the rail hides (mounted side-chat forks) — the palette skips them too. */ + hiddenSessionIds: ReadonlySet; captureComposerImportOwner: () => ComposerImportOwner; createSession: () => void; openSideConversation: () => void; @@ -224,9 +223,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 +242,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, @@ -395,20 +394,6 @@ export function buildAppShellCommandList( }); } -export function buildAppShellSessionCommands( - optionsRef: RefBox, -): ReturnType { - const options = optionsRef.current; - return buildSessionCommands({ - locale: options.uiLocale, - sessions: options.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 @@ -417,26 +402,33 @@ 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, 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, visibleSessions } = commandOptions; - const baseCommands = useMemo( + const { uiLocale } = commandOptions; + const commands = useMemo( () => buildAppShellCommandList(optionsRef), [paletteOpen, uiLocale], ); - const sessionCommands = useMemo( - () => buildAppShellSessionCommands(optionsRef), - [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 e309fa4581e..05b00379e48 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'; +} from './application/contracts/session-catalog/session-event-health.js'; +import { handleSessionChangedEvent } from './application/contracts/session-catalog/session-change-effects.js'; import type { DesktopRuntimeHostProfileChangedEvent, WindowCommand, @@ -161,9 +161,14 @@ 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[]; + /** 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; toastApi: ToastApi; }) { @@ -175,8 +180,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; @@ -197,40 +202,14 @@ export function useAppShellBootstrapSubscriptions(options: { else if (command.id === 'openHelp') options.openHelp(); }); const handleSessionChange = useEffectEvent( - (event: SessionChangedEvent) => { - const refreshedSessions = options.refreshSessions(); - 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, + 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 9e522ac7843..f1bb3f01380 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 22ec2bf498b..2fee1445e1a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -84,6 +84,10 @@ import { type SessionNavigationPorts, type SessionNavigationRowActions, } from './features/session-navigation'; +import { + CatalogRowWatch, + catalogWatchedRowsUsable, +} 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'; @@ -107,7 +111,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'; @@ -124,7 +127,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 { deriveStaleSessionIds } 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 +214,7 @@ type ComposerImportOwner = { */ const SETTLE_FALLBACK_GRACE_MS = 1000; const { useSessionCollaborationDialog } = SessionCollaboration; + type AppShellProps = { /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ initialOnboardingSnapshot?: OnboardingSnapshot | null; @@ -300,11 +303,10 @@ function AppShellContent({ const sharedSessionDialog = useSessionCollaborationDialog(); const previousInterruptionShownRef = useRef(false); const { - sessions, - catalogRevision, authoritativeSessionIds, sessionsRef, refreshSessions, + refreshChangedSession, seedSessions, activeId, activeIdRef, @@ -332,6 +334,7 @@ function AppShellContent({ messageLoadPending, setMessageLoadPending, sessionUiController, + sessionCatalogController, activeCatalogSession, activeHostSession, requestedCatalogSession, @@ -340,6 +343,10 @@ function AppShellContent({ ownerActiveId, switchingSession, } = useAppShellSessionWorkspace(toastApi); + // 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) => { @@ -537,7 +544,7 @@ function AppShellContent({ const onboardingSettled = hasSettledInitialOnboarding(onboarding.snapshot?.milestones ?? []); const onboardingActivationCandidate = getOnboardingActivationCandidate( onboarding.snapshot, - sessions.length > 0, + sessionCount > 0, ); const { themePref, @@ -615,19 +622,24 @@ function AppShellContent({ revisionDraftRef.current = draft; setRevisionDraft(draft); }, []); - 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; - 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]); + // The draft survives on exactly two catalog rows; CatalogRowWatch below + // selects them so their changes alone can retire it. + const retireRevisionDraftIfRowsLeave = useCallback( + (rows: Parameters[0]) => { + const draft = revisionDraftRef.current; + if (!draft) 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); + if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); + else void abandonTurnRevisionCopyAttempt(draft); + commitRevisionDraft(null); + }, + [commitRevisionDraft], + ); const { resumePendingSessionId, @@ -635,24 +647,11 @@ 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 = useMemo( - () => - deriveStaleSessionIds({ - sessions, - sendOutcomes: onboarding.snapshot?.sessionSendOutcomes ?? {}, - }), - [sessions, onboarding.snapshot?.sessionSendOutcomes], - ); 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 +1071,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 +1327,28 @@ 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; 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 +1357,7 @@ function AppShellContent({ onPurge: (sessionIds) => sessionNavigationCommandsRef.current!.purgeSessions(sessionIds), }), - [sessions, localProjects], + [sessionCatalogController, localProjects], ); const activateSessionForFirstSend = useCallback((sessionId: string): Promise => { @@ -1879,9 +1874,12 @@ function AppShellContent({ refreshProjects, refreshShellSettings, refreshSessions, + refreshChangedSession, rendererMountedRef, retireSession: clearSessionRendererState, retiredSessionIds, + isSessionRemoved: sessionCatalogController.isRemoved, + sessionsRef, setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, }); @@ -2120,9 +2118,9 @@ function AppShellContent({ newTaskProfileId: taskEntry.selectors.selectedProfileId, settingsOpen, settingsProfileId: overlays.selectors.settings.request.profileId, - sessions, + sessionCatalog: sessionCatalogController, themePref, - visibleSessions, + hiddenSessionIds: selectors.hiddenSessionIds, captureComposerImportOwner, createSession, startModeSession, @@ -2169,6 +2167,11 @@ function AppShellContent({ reportError={showSessionError} > +
( + ids.map((id) => ({ + summary: selectSessionById(state, id), + removed: id !== undefined && state.removedIds.has(id), + })); + +function observedRowsEqual( + a: readonly ObservedCatalogRow[], + b: readonly ObservedCatalogRow[], +): boolean { + 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)[] = []; + +/** + * 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 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 observed = useExternalStoreSelector( + props.catalog, + selectWatchedCatalogRows, + ids, + 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], + ); + 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 00000000000..20dd4db3179 --- /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/observable-state.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/observable-state.ts similarity index 100% rename from apps/desktop/src/renderer/observable-state.ts rename to apps/desktop/src/renderer/application/contracts/session-catalog/observable-state.ts 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 00000000000..e16a54e92b1 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts @@ -0,0 +1,209 @@ +/* + * 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; + /** + * 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() { + const state = createObservableState({ + 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[], + 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 + // 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; + }); + // 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 && removedIds === current.removedIds && current.revision > 0) return; + state.replaceState({ + ...current, + sessions: sameRows ? current.sessions : sessions, + removedIds, + 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]; + const revision = current.revision + 1; + if (summary === null) { + // 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: prior === undefined + ? current.sessions + : current.sessions.filter((s) => s.id !== sessionId), + removedIds, + revision, + }); + 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]); + 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(); + if (current.activeSessionId === next) return; + state.replaceState({ ...current, activeSessionId: next }); + }, + isRemoved(sessionId: string): boolean { + return state.getState().removedIds.has(sessionId); + }, + }; +} + +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 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/application/contracts/session-catalog/session-change-effects.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-change-effects.ts new file mode 100644 index 00000000000..50f2a64452a --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/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 { recordSessionEventStreamChange } from './session-event-health.js'; + +type RefBox = { current: T }; + +type SessionEventHealthUpdater = ( + updater: (current: Record) => Record, +) => void; + +export function handleSessionChangedEvent( + event: SessionChangedEvent, + options: { + 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[]; + /** 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. */ + notifyModelRebound: (modelId: string | undefined) => void; + setSessionEventHealthBySession: SessionEventHealthUpdater; + }, +): void { + const changedSessionId = event.sessionId; + const refreshedSessions: Promise = changedSessionId === undefined + ? options.refreshSessions() + : options.refreshChangedSession(changedSessionId); + 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); + } + 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(() => { + 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); + }); +} diff --git a/apps/desktop/src/renderer/session-event-health.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-event-health.ts similarity index 100% rename from apps/desktop/src/renderer/session-event-health.ts rename to apps/desktop/src/renderer/application/contracts/session-catalog/session-event-health.ts diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-id-set.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-id-set.ts new file mode 100644 index 00000000000..38e9e5bda6e --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/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; +} 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 00000000000..f9b45292557 --- /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/stale-sessions.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/stale-sessions.ts similarity index 85% rename from apps/desktop/src/renderer/stale-sessions.ts rename to apps/desktop/src/renderer/application/contracts/session-catalog/stale-sessions.ts index 7a978f90662..c6ba8952cc8 100644 --- a/apps/desktop/src/renderer/stale-sessions.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/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-external-store-selector.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/use-external-store-selector.ts similarity index 100% rename from apps/desktop/src/renderer/use-external-store-selector.ts rename to apps/desktop/src/renderer/application/contracts/session-catalog/use-external-store-selector.ts diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 11299b5c556..b44832834af 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/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index e2cd3786823..0fa3781dd60 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: 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 cb92ff9bccc..69b765349b4 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 @@ -18,10 +18,15 @@ */ import { useRef, useState } from 'react'; -import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import type { StoredMessage } from '@maka/core/session'; import type { TransientUserMessageProjection } from '@maka/ui'; import { currentTranscriptRange } from './transcript-reading-position.js'; import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; +import { + selectSessionById, + type SessionCatalogController, +} 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 }; @@ -38,9 +43,8 @@ export type TranscriptPublisher = ( /** 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/conversation/model/live-turn-snapshot.ts b/apps/desktop/src/renderer/features/conversation/model/live-turn-snapshot.ts index 3ea88eb15d4..d157975d712 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 '../../../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 00000000000..e4ddb849784 --- /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 9727b03b042..711b270607b 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 dc5eb8555f1..48168d80710 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 '../../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'; @@ -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 9a4c34458f5..745a5a8a8f8 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 '../../application/contracts/session-catalog/session-catalog-state.js'; +import { useExternalStoreSelector } from '../../application/contracts/session-catalog/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-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts index a97a1ef0cd0..3c34e09a70f 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 a84bc26d968..597b5b8702a 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 { 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 { 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/model/session-nav-filter.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-nav-filter.ts index d5715531b7a..2df3ee0003f 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 e9aa78e26b9..c6793a8a337 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/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index 9626db5afc4..a08d2942c43 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 4a47d2780b1..c70336b508e 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,19 @@ 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 '../../../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. */ export interface SessionNavigationChromeInput { @@ -65,10 +71,13 @@ 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; + sessionSendOutcomes?: Readonly>; SessionBadge?: ComponentType<{ readonly sessionId: string }>; ports: SessionNavigationPorts; /** @@ -92,8 +101,22 @@ 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 staleSessionIds = useExternalStoreSelector( + props.catalog, + selectStaleSessionIds, + props.sessionSendOutcomes, + sessionIdSetsEqual, + ); + 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,10 +196,10 @@ 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, + staleSessionIds, worktreeSessionIds: controller.selectors.worktreeSessionIds, groups: controller.layout.viewMode === 'project' ? controller.selectors.groups : undefined, groupVariant: controller.layout.viewMode, @@ -197,8 +220,8 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) props.onSelectSession, projectActions, relinkableProjectIds, - props.rail, - props.staleSessionIds, + rail, + staleSessionIds, props.streamingSessionIds, props.workHubActive, rowActions, 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 f99f0d8518d..e094c3570d2 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 '../../application/contracts/session-catalog/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/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index 94d19bcb4e2..c3655022f28 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 eb42cc70a55..8aa8ef2aa55 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 '../../../../application/contracts/session-catalog/observable-state.js'; import type { WorkbarIngestInput } from '../../ports.js'; import { abandonPendingCompanionCopy, @@ -127,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[]; @@ -262,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, @@ -297,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); @@ -368,9 +378,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 +396,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: { @@ -871,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; 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 00000000000..da10e38f72a --- /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 deleted file mode 100644 index 873f3afb676..00000000000 --- a/apps/desktop/src/renderer/session-catalog-state.ts +++ /dev/null @@ -1,95 +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. - */ - -import { useRef } from 'react'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.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(); - state.replaceState({ ...current, sessions: next, 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; - -export const selectSessions = (state: SessionCatalogState): readonly DesktopSessionSummary[] => - state.sessions; -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/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index bd7cc9199a2..cd6a16f458c 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,13 +1241,17 @@ function SettingsPageBody(props: { /> ); case 'archived-tasks': - return ; + return ( + + {(sessions) => } + + ); case 'import-tasks': return ( ( {children} )} diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index 2b36e16ee2b..e3245e67822 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -28,6 +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 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'; @@ -44,7 +45,7 @@ import { * not have to understand. */ export interface ArchivedTasksBridge { - sessions: readonly DesktopSessionSummary[]; + catalog: SessionCatalogController; projects: readonly ProjectRecord[]; onRestore(sessionId: string): void; onDelete(sessionId: string): void; @@ -73,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(); 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 8e59c10401d..14452cb3925 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 { useMemo, useRef } from 'react'; import { useUiLocale } from '@maka/ui'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; @@ -26,24 +26,20 @@ import { } from './session-status-presentation.js'; import { createSessionListRefresher, - type SessionListRefresher, } from './session-read-state.js'; import { selectAuthoritativeSessionIds, - selectCatalogRevision, - selectSessions, type SessionCatalogController, -} from './session-catalog-state.js'; -import { sessionIdSetsEqual } from './features/conversation/index.js'; -import { useExternalStoreSelector } from './use-external-store-selector.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'; 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: { @@ -55,28 +51,30 @@ 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, undefined, sessionIdSetsEqual, ); - const sessionsRef = useRef([]); - const refresherRef = useRef | null>(null); - - function commitSessions(next: DesktopSessionSummary[]): void { - sessionsRef.current = next; - catalog.commitSessions(next); - } - - if (!refresherRef.current) { - refresherRef.current = createSessionListRefresher({ - listSessions: () => window.maka.sessions.list(), - currentSessions: () => sessionsRef.current, - commitSessions: (next) => commitSessions(next.map(normalizeSessionSummaryForDisplay)), + // The catalog is the authority; the box only adapts its read shape. + const sessionsRef = useMemo( + () => ({ get current() { return catalog.getState().sessions; } }), + [catalog], + ); + 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; @@ -86,35 +84,29 @@ export function useAppShellSessionList( ); }, }); - } + }, [catalog, sessionsRef]); - // 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; - seedSessions( - snapshotSessions: readonly DesktopSessionSummary[], - ): DesktopSessionSummary[]; - } | null>(null); - actionsRef.current ??= { - async refreshSessions() { - return refresherRef.current!.refresh(); - }, - seedSessions(snapshotSessions) { - const next = snapshotSessions.map(normalizeSessionSummaryForDisplay); - commitSessions(next); - return next; - }, - }; - const { refreshSessions, seedSessions } = actionsRef.current; + // 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 refresher.refresh().catch(() => undefined), + }); + return { + refreshSessions: () => refresher.refresh(), + refreshChangedSession: drain.request, + seedSessions(snapshotSessions: readonly DesktopSessionSummary[]) { + const next = snapshotSessions.map(normalizeSessionSummaryForDisplay); + catalog.commitSessions(next); + return next; + }, + }; + }, [catalog, refresher]); - return { - sessions, - catalogRevision, - authoritativeSessionIds, - sessionsRef, - refreshSessions, - seedSessions, - }; + return { authoritativeSessionIds, sessionsRef, ...actions }; } 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 1cf826a2a6d..c56cc101f49 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 f857023546b..c7b64e33f2c 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'; @@ -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/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts index 8e00c44f20a..9dea0886194 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,41 @@ 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. 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 (Object.keys(COMPARED_KEYS) as readonly (keyof typeof COMPARED_KEYS)[]).every( + (key) => valuesEqual(a[key], b[key]), + ); +} + /** * 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 +148,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 +194,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 +222,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 +235,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; diff --git a/apps/desktop/src/shared/desktop-session-projection.ts b/apps/desktop/src/shared/desktop-session-projection.ts index c90c967e174..d5c15ffcbeb 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/command-search.stories.tsx b/apps/desktop/stories/command-search.stories.tsx index 2365b8ac69a..49e76f2fb62 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/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 034bf29df85..c40202e4c5f 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/application/contracts/session-catalog/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/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index fa000af5d87..243d1de73de 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 e562e8c6a64..426fdc83bdc 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/packages/ui/src/__tests__/timeline-fold.test.ts b/packages/ui/src/__tests__/timeline-fold.test.ts index d807b22811d..1008bdfd03a 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 54e5aeb30dd..ebf92fe0ade 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 36614f04bbd..eeb1dcd2a96 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/components.tsx b/packages/ui/src/components.tsx index b1a53419194..e89bf04cc76 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/materialize.ts b/packages/ui/src/materialize.ts index 3178a5f9da4..0650c3614dd 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/session-setting-intent.test.ts b/packages/ui/src/session-setting-intent.test.ts index 8d48fdc5c79..9812f1e7e42 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 9f104faeaa9..0b14e4960b9 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); diff --git a/packages/ui/src/timeline-fold.ts b/packages/ui/src/timeline-fold.ts index 5b0d20b5622..a24b4e00092 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 c9f736c4a10..267c6972a8a 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 diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index fa6a2e73e08..4fc61e064c1 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -117,7 +117,7 @@ export const ALLOWED = { useAppShellSessionWorkspace: 1, useAppShellTurnPresentation: 1, useComposerAttachments: 1, - useEffect: 7, + useEffect: 6, useLayoutEffect: 2, useNewTaskChoice: 1, useOnboardingSnapshot: 1,