From 4f49aaf99e5b31d7e737773b1e3d5244bd1defcd Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:01:18 +0800 Subject: [PATCH 1/7] refactor(desktop): read transcript attachment bytes through the Conversation port AppShell passed window.maka.attachments.readBytes to the transcript as a prop. Declare readBytes on Conversation's attachment port (ComposerStagingServices), whose Desktop adapter already returns the bridge's attachments namespace, and have StagedQuoteChatView, the feature's transcript ChatView, take the reader from it. The reader contracts of ChatMessageSurface and StagedQuoteChatView drop the prop, so AppShell can no longer supply one; app-shell.tsx goes from five direct bridge paths to four. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 3 +- .../__tests__/composer-staging-fixture.ts | 1 + .../__tests__/composer-staging-owner.test.ts | 60 ++++++++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 1 - .../src/renderer/chat-message-surface.tsx | 1 + .../renderer/features/conversation/README.md | 2 + .../features/conversation/staging-services.ts | 6 +- .../ui/staged-quote-chat-view.tsx | 7 ++- .../stories/composer-slash-menu.stories.tsx | 1 + .../stories/shared-session-guest.stories.tsx | 1 + 10 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 1c79a7b7e3..0f01df3695 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -615,7 +615,6 @@ "src/renderer/app-shell.tsx": { "importDeclarations": 50, "bridgePaths": { - "window.maka.attachments.readBytes": 1, "window.maka.onboarding.setMilestone": 1, "window.maka.sessions.listTurnLandmarks": 1, "window.maka.settings.getClient": 1, @@ -729,7 +728,7 @@ "react": 1 }, "importSpecifiers": 74, - "nonTriviaTokens": 9080 + "nonTriviaTokens": 9069 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/composer-staging-fixture.ts b/apps/desktop/src/main/__tests__/composer-staging-fixture.ts index 8d131f9f01..ed4dd46887 100644 --- a/apps/desktop/src/main/__tests__/composer-staging-fixture.ts +++ b/apps/desktop/src/main/__tests__/composer-staging-fixture.ts @@ -27,6 +27,7 @@ import { const emptyServices: ComposerStagingServices = { pickFiles: async () => ({ ok: false, reason: 'cancelled' }), previewApproval: async () => ({ ok: false, reason: 'unavailable' }), + readBytes: async () => ({ ok: false, reason: 'not_found' }), }; /** Real owner with inert I/O, shared by renderer integration tests. */ diff --git a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts index b4d7add2b6..335922be31 100644 --- a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts @@ -18,22 +18,27 @@ */ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { afterEach, test } from 'node:test'; -import { act, createElement, createRef, Fragment, Profiler, StrictMode, useLayoutEffect } from 'react'; +import { fileURLToPath } from 'node:url'; +import { act, createElement, createRef, Fragment, Profiler, StrictMode, useLayoutEffect, type ComponentProps } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import { ChatSurfaceLayout, LocaleProvider, type ComposerHandle } from '@maka/ui'; import type { AttachmentRef, DirectoryReference } from '@maka/core/events'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; import { createComposerStagingCommands, createRevisionAwareOnSend, createStagedFollowUp, StagedComposer, StagedQuoteChatView, PlanProvider, PlanServicesProvider, - type PlanServices, type RevisionSendPorts, type ComposerStagingSubmission, + type PlanServices, type RevisionSendPorts, type ComposerStagingServices, type ComposerStagingSubmission, } from '../../renderer/features/conversation/index.js'; import { useComposerStaging } from '../../renderer/features/conversation/testing.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; import { createAppShellRevisionActions, type TurnRevisionDraft } from '../../renderer/app-shell-revision-actions.js'; +import { createDesktopComposerStagingServices } from '../../renderer/platform/desktop/create-composer-staging-services.js'; import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION } from './app-shell-chat-actions-fixture.js'; import { ComposerStagingFixture } from './composer-staging-fixture.js'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; const saved = Object.fromEntries([ 'window', 'document', 'Element', 'HTMLBRElement', 'sessionStorage', 'HTMLElement', 'HTMLIFrameElement', 'Event', 'Node', 'CSS', @@ -391,3 +396,54 @@ for (const context of ['attachment', 'directory'] as const) { assert.equal(currentRevision()?.sourceSessionId, 'draft-a', 'cleanup releases the guard'); }); } + +test('the transcript reads attachment bytes only through the injected attachment port', async () => { + const reads: string[] = []; + const callerReads: string[] = []; + const services: ComposerStagingServices = { + pickFiles: async () => ({ ok: false, reason: 'cancelled' }), + previewApproval: async () => ({ ok: false, reason: 'unavailable' }), + readBytes: async (sessionId, artifactId) => { + reads.push(`${sessionId}/${artifactId}`); + return { ok: true, base64: 'aW1n', mimeType: 'image/png' }; + }, + }; + const activeSession = { id: 'session-1', name: 'Images', status: 'active', labels: [] } as unknown as SessionSummary; + const message: StoredMessage = { + type: 'user', id: 'ask', turnId: 'turn-1', ts: 1, text: 'show this', + attachments: [{ + kind: 'image', name: 'preview.png', mimeType: 'image/png', bytes: 3, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-123' }, + }], + }; + // The contract omits the prop; a caller that forces one in is still overridden. + const transcript = { + activeSession, messages: [message], onNew: () => {}, scrollBehavior: 'auto', + onReadAttachmentBytes: async (sessionId: string, artifactId: string) => { + callerReads.push(`${sessionId}/${artifactId}`); + return { ok: false, reason: 'not_found' }; + }, + } as ComponentProps; + const markup = await renderTranscriptMarkup(createElement(LocaleProvider, { locale: 'en', children: + createElement(ComposerStagingFixture, { draftKey: 'session-1', services, children: + createElement(PlanServicesProvider, { services: planServices, children: + createElement(PlanProvider, { session: undefined, children: + createElement(ChatSurfaceLayout, { scrollToBottomLabel: 'Scroll to bottom', composer: null, children: + createElement(StagedQuoteChatView, transcript), + }), + }), + }), + }), + })); + assert.deepEqual([...new Set(reads)], ['session-1/attachment-123']); + assert.deepEqual(callerReads, []); + assert.match(markup, /src="data:image\/png;base64,aW1n"/); +}); + +test('Desktop backs the attachment port with the bridge reader; AppShell no longer reaches it', () => { + const readBytes: ComposerStagingServices['readBytes'] = async () => ({ ok: false, reason: 'not_found' }); + const bridge = { attachments: { readBytes } } as unknown as Parameters[0]; + assert.equal(createDesktopComposerStagingServices(bridge).readBytes, readBytes); + const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); + assert.deepEqual(shell.split('\n').filter((line) => /\battachments\s*\.\s*readBytes\b|\bonReadAttachmentBytes\b/.test(line)), []); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 3612887cbd..bd6442f64c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1875,7 +1875,6 @@ function AppShellContent({ onEditUserMessage={sharedSessionActive ? undefined : (turnId) => { void beginEditUserMessage(turnId); }} safeResumeAction={safeResumeAction} onLineageBadgeClick={(turnId) => { if (activeId) openSessionInChat(activeId, turnId); }} - onReadAttachmentBytes={window.maka.attachments.readBytes} onOpenLinkedSession={openSessionInChat} scrollTargetTurn={ activeId && searchScrollTarget?.sessionId === activeId diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index f48390ddeb..b220bcb27c 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -58,6 +58,7 @@ interface ChatMessageSurfaceProps extends Omit< | 'handleRef' | 'pendingQuotes' | 'onQuoteAnnotationSubmit' + | 'onReadAttachmentBytes' > { /** * #1985: the live projection and the shell-run records are the only session diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index d8c13a1af0..956effe8ab 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -181,6 +181,8 @@ files, directory references and quote chips at the actual Composer; No staging state or reactive read port is returned to AppShell. The private context/binding modules and the controller owner entry seal this boundary. The Desktop attachment service is injected at the composition root. +`StagedQuoteChatView` also takes the transcript's image `readBytes` from that +service (R2 M5); AppShell and `ChatMessageSurface` cannot pass another reader. The shell holds only stable commands. Submission captures a draft-bound snapshot before awaiting revision preparation or delivery. Cleanup stays bound to that diff --git a/apps/desktop/src/renderer/features/conversation/staging-services.ts b/apps/desktop/src/renderer/features/conversation/staging-services.ts index 97fcf2145b..98002392b8 100644 --- a/apps/desktop/src/renderer/features/conversation/staging-services.ts +++ b/apps/desktop/src/renderer/features/conversation/staging-services.ts @@ -19,8 +19,12 @@ import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; +import type { ArtifactBinaryReadResult } from '@maka/core/artifacts'; -export type ComposerStagingServices = ComposerAttachmentService; +/** One attachment port: the Composer stages through it and the transcript reads image bytes through it. */ +export interface ComposerStagingServices extends ComposerAttachmentService { + readBytes(sessionId: string, artifactId: string): Promise; +} const context = createServicesContext('ComposerStagingServicesProvider'); export const ComposerStagingServicesProvider = context.Provider; export const useComposerStagingServices = context.useServices; diff --git a/apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx b/apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx index 9b21768117..12c55d2a56 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/staged-quote-chat-view.tsx @@ -20,9 +20,12 @@ import type { ComponentProps } from 'react'; import { PlanChatView } from './plan-surfaces.js'; import { useComposerStaging } from './composer-staging-context.js'; +import { useComposerStagingServices } from '../staging-services.js'; +/** Transcript attachment reads come from the injected attachment port, never from the caller. */ export function StagedQuoteChatView(props: Omit, - 'handleRef' | 'pendingQuotes' | 'onQuoteAnnotationSubmit'>) { + 'handleRef' | 'pendingQuotes' | 'onQuoteAnnotationSubmit' | 'onReadAttachmentBytes'>) { const staging = useComposerStaging(); - return ; + const { readBytes } = useComposerStagingServices(); + return ; } diff --git a/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index da78e285f7..168870f5b8 100644 --- a/apps/desktop/stories/composer-slash-menu.stories.tsx +++ b/apps/desktop/stories/composer-slash-menu.stories.tsx @@ -214,6 +214,7 @@ function SlashMenuComposer({ const stagingServices: ComposerStagingServices = { pickFiles: async () => ({ ok: false, reason: 'cancelled' }), previewApproval: async () => ({ ok: false, reason: 'unavailable' }), + readBytes: async () => ({ ok: false, reason: 'not_found' }), }; // Match AppShell's persistent staging scope around the real mention reader. diff --git a/apps/desktop/stories/shared-session-guest.stories.tsx b/apps/desktop/stories/shared-session-guest.stories.tsx index 437723ecf2..af182d31a4 100644 --- a/apps/desktop/stories/shared-session-guest.stories.tsx +++ b/apps/desktop/stories/shared-session-guest.stories.tsx @@ -39,6 +39,7 @@ import { const stagingServices: ComposerStagingServices = { pickFiles: async () => ({ ok: false, reason: 'cancelled' }), previewApproval: async () => ({ ok: false, reason: 'unavailable' }), + readBytes: async () => ({ ok: false, reason: 'not_found' }), }; const SESSION_ID = 'shared-session-story'; From 5bd6132d308c79960ec0bea9bee914c2f1919227 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:26:39 +0800 Subject: [PATCH 2/7] refactor(desktop): own WorkHub enablement in an application authority AppShell read the client WorkHub switch from the Desktop bridge into its own state and passed it to Workbar, the session rail and the WorkHub dock. Create a WorkHub enablement authority in application/contracts beside the WorkHub workspace contract, with its Desktop source injected at composition like the session catalog. Workbar, the rail and the dock read it directly; the rail and WorkHub's main-window navigation check it again before opening WorkHub. WorkHubEnablementWatch hands AppShell only the on/off edges for the navigation it still owns (workHubActive and the destination). AppShell loses settings.getClient, settings.subscribeClientChanged, the workHubEnabled state, its ref and its effect. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 12 +- .../session-navigation-controller.test.ts | 53 ++++++++- .../main/__tests__/workbar-controller.test.ts | 6 +- .../main/__tests__/workhub-enablement.test.ts | 103 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 41 ++----- .../workhub-workspace/workhub-enablement.ts | 101 +++++++++++++++++ .../composition/desktop-feature-services.tsx | 10 ++ .../features/session-navigation/README.md | 5 + .../ui/session-navigation-provider.tsx | 9 +- .../controller/use-workbar-controller.ts | 5 +- .../src/renderer/features/workhub/index.ts | 1 + .../features/workhub/ui/main-navigation.tsx | 6 +- .../features/workhub/ui/workhub-dock.tsx | 5 +- .../create-workhub-enablement-source.ts | 29 +++++ packages/ui/src/testing.ts | 2 +- scripts/check-app-shell-hooks.mjs | 4 +- 16 files changed, 337 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/workhub-enablement.test.ts create mode 100644 apps/desktop/src/renderer/application/contracts/workhub-workspace/workhub-enablement.ts create mode 100644 apps/desktop/src/renderer/platform/desktop/create-workhub-enablement-source.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 0f01df3695..0b1497f358 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -616,9 +616,7 @@ "importDeclarations": 50, "bridgePaths": { "window.maka.onboarding.setMilestone": 1, - "window.maka.sessions.listTurnLandmarks": 1, - "window.maka.settings.getClient": 1, - "window.maka.settings.subscribeClientChanged": 1 + "window.maka.sessions.listTurnLandmarks": 1 }, "environmentCapabilities": { "window.requestAnimationFrame": 3 @@ -633,11 +631,11 @@ "useAppShellSessionUiReads": 1, "useAppShellSessionWorkspace": 1, "useAppShellTurnPresentation": 1, - "useEffect": 2, + "useEffect": 1, "useLayoutEffect": 1, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, - "useRef": 7, + "useRef": 6, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, "useShellAppearance": 1, @@ -647,7 +645,7 @@ "useShellMemoryPill": 1, "useShellResume": 1, "useStableActions": 4, - "useState": 10, + "useState": 9, "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, @@ -728,7 +726,7 @@ "react": 1 }, "importSpecifiers": 74, - "nonTriviaTokens": 9069 + "nonTriviaTokens": 8915 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, 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 73a055016c..2eca495b1c 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -24,10 +24,11 @@ import type { ProjectRecord } from '@maka/core/project'; import { LocaleProvider, useSessionRailData, + type SessionRailChrome, type SessionRailData, type SessionRailSelection, } from '@maka/ui'; -import { useSessionRailSelection } from '@maka/ui/testing'; +import { useSessionRailChrome, useSessionRailSelection } from '@maka/ui/testing'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeSessionNavigationServices, @@ -47,6 +48,10 @@ import { createSessionCatalogController } from '../../renderer/application/contr import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import type { SnapshotReader } from '../../renderer/application/contracts/snapshot-reader.js'; import { createProductionSessionUiStateController } from '../../renderer/features/conversation/testing.js'; +import { + WorkHubEnablementProvider, + type WorkHubEnablement, +} from '../../renderer/application/contracts/workhub-workspace/workhub-enablement.js'; const EMPTY_STREAMING_SESSIONS = new Set(); @@ -192,7 +197,11 @@ function input( function navigationTree( catalog: ReturnType, - shell: { activeSessionId: string; workHubActive: boolean }, + shell: { + activeSessionId: string; + workHubActive: boolean; + workHubEntry?: { active: boolean; label: string; onSelect(): void }; + }, sibling: ReactNode, child: ReactNode, streamingSessions: SnapshotReader> = { @@ -608,6 +617,46 @@ describe('SessionNavigationProvider selection', () => { assert.deepEqual([...selection().selectedIds], []); }); + + it('offers the WorkHub entry only while the switch is on, checked again on select', async () => { + let chrome: SessionRailChrome | undefined; + function ChromeProbe() { + chrome = useSessionRailChrome(); + return null; + } + let enabled = false; + const listeners = new Set<() => void>(); + const enablement: WorkHubEnablement = { + isEnabled: () => enabled, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const opened: string[] = []; + const catalog = createSessionCatalogController(); + catalog.commitSessions(linkedCatalog); + const { root } = installReactRenderer(); + await act(async () => root.render(createElement(WorkHubEnablementProvider, { value: enablement }, + navigationTree(catalog, { + activeSessionId: 'root', + workHubActive: false, + workHubEntry: { active: false, label: 'WorkHub', onSelect: () => opened.push('workhub') }, + }, null, createElement(ChromeProbe))))); + const workHubEntry = () => chrome?.workHubEntry; + assert.equal(workHubEntry(), undefined); + enabled = true; + await act(async () => listeners.forEach((listener) => listener())); + const entry = workHubEntry(); + assert.equal(entry?.label, 'WorkHub'); + enabled = false; + entry?.onSelect(); + assert.deepEqual(opened, [], 'a click that lands after the switch went off does nothing'); + enabled = true; + entry?.onSelect(); + assert.deepEqual(opened, ['workhub']); + await act(async () => root.unmount()); + }); }); describe('createSessionOpenCommand', () => { diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 02030d18af..8fa1cbd896 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -18,6 +18,7 @@ */ import { WorkHubWorkspaceServicesProvider, type WorkHubWorkspaceServices } from '../../renderer/application/contracts/workhub-workspace/use-workhub-workspace.js'; +import { WorkHubEnablementProvider } from '../../renderer/application/contracts/workhub-workspace/workhub-enablement.js'; import { deferred } from '@maka/core/test-only/async-primitives'; import { strict as assert } from 'node:assert'; import { afterEach, describe, it } from 'node:test'; @@ -765,8 +766,9 @@ describe('useWorkbarController', () => { const render = (active: boolean) => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(WorkbarServicesProvider, { services }, - createElement(WorkHubWorkspaceServicesProvider, { value: coordination }, - createElement(ControllerProbe, { ...ordinary, workHub: { enabled: true, active } }))), + createElement(WorkHubEnablementProvider, { value: { isEnabled: () => true, subscribe: () => () => {} } }, + createElement(WorkHubWorkspaceServicesProvider, { value: coordination }, + createElement(ControllerProbe, { ...ordinary, workHub: { active } })))), })); await act(async () => render(true)); assert.equal(controller().host.activeId, coordinationId); diff --git a/apps/desktop/src/main/__tests__/workhub-enablement.test.ts b/apps/desktop/src/main/__tests__/workhub-enablement.test.ts new file mode 100644 index 0000000000..f7919e9701 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-enablement.test.ts @@ -0,0 +1,103 @@ +/* + * 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 assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { act, createElement, StrictMode } from 'react'; +import { + createWorkHubEnablement, + WorkHubEnablementProvider, + WorkHubEnablementWatch, + type WorkHubEnablementSource, +} from '../../renderer/application/contracts/workhub-workspace/workhub-enablement.js'; +import { createDesktopWorkHubEnablementSource } from '../../renderer/platform/desktop/create-workhub-enablement-source.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +function controllableSource() { + const reads: Array<{ resolve(enabled: boolean): void; reject(error: Error): void }> = []; + const handlers = new Set<() => void>(); + const source: WorkHubEnablementSource = { + read: () => new Promise((resolve, reject) => { reads.push({ resolve, reject }); }), + subscribeChanges(handler) { + handlers.add(handler); + return () => handlers.delete(handler); + }, + }; + return { source, reads, handlers, change: () => [...handlers].forEach((handler) => handler()) }; +} + +test('reads only while subscribed, keeps the last value on a failed read, and restarts from off', async () => { + const { source, reads, handlers, change } = controllableSource(); + const enablement = createWorkHubEnablement(source); + assert.equal(reads.length, 0, 'no reader, no read'); + let notified = 0; + const unsubscribe = enablement.subscribe(() => { notified += 1; }); + assert.equal(handlers.size, 1); + await act(async () => reads[0]!.resolve(true)); + assert.deepEqual([enablement.isEnabled(), notified], [true, 1]); + change(); + await act(async () => reads[1]!.reject(new Error('settings unavailable'))); + assert.deepEqual([enablement.isEnabled(), notified], [true, 1], 'a failed read keeps the last known value'); + change(); + await act(async () => reads[2]!.resolve(true)); + assert.equal(notified, 1, 'an unchanged value does not notify'); + change(); + unsubscribe(); + await act(async () => reads[3]!.resolve(false)); + assert.deepEqual([enablement.isEnabled(), notified, handlers.size], [false, 1, 0], 'a read that lands after the last reader left is dropped'); +}); + +test('the watch hands the shell each edge of the switch, once, under StrictMode', async () => { + const { source, reads, change } = controllableSource(); + const enablement = createWorkHubEnablement(source); + const edges: string[] = []; + const { root } = installReactRenderer(); + await act(async () => root.render(createElement(StrictMode, null, + createElement(WorkHubEnablementProvider, { value: enablement }, + createElement(WorkHubEnablementWatch, { onEnabled: () => edges.push('on'), onDisabled: () => edges.push('off') }))))); + await act(async () => { for (const read of reads) read.resolve(false); }); + assert.deepEqual(edges, [], 'starting off is not an edge'); + change(); + await act(async () => reads.at(-1)!.resolve(true)); + change(); + await act(async () => reads.at(-1)!.resolve(true)); + change(); + await act(async () => reads.at(-1)!.resolve(false)); + assert.deepEqual(edges, ['on', 'off']); + await act(async () => root.unmount()); +}); + +test('Desktop reads the client WorkHub switch; AppShell no longer reaches it', async () => { + const subscribed: Array<() => void> = []; + const source = createDesktopWorkHubEnablementSource({ + settings: { + getClient: async () => ({ workHub: { enabled: true } }), + subscribeClientChanged: (handler: () => void) => { subscribed.push(handler); return () => {}; }, + }, + } as unknown as Parameters[0]); + assert.equal(await source.read(), true); + const handler = () => {}; + source.subscribeChanges(handler); + assert.deepEqual(subscribed, [handler]); + const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); + assert.deepEqual(shell.split('\n').filter((line) => /\bsettings\s*\.\s*(?:getClient|subscribeClientChanged)\b|\bworkHubEnabled\b/.test(line)), []); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index bd6442f64c..41084cd82b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { WorkHubControlOverlay, WorkHubDock, WorkHubMainNavigation } from './features/workhub'; +import { WorkHubControlOverlay, WorkHubDock, WorkHubEnablementWatch, WorkHubMainNavigation } from './features/workhub'; import { RuntimeHostHandoffOverlay } from './features/runtime-host-management/index.js'; import { useCallback, @@ -352,32 +352,9 @@ function AppShellContent({ )); }, []); const navSelectionRef = useRef(navSelection); - const [workHubEnabled, setWorkHubEnabled] = useState(false); + // Navigation only: whether WorkHub is enabled at all is the client switch + // the WorkHub enablement authority owns (see WorkHubEnablementWatch below). const [workHubActive, setWorkHubActive] = useState(false); - const workHubEnabledRef = useRef(false); - useEffect(() => { - let disposed = false; - const refresh = async () => { - try { - const enabled = (await window.maka.settings.getClient()).workHub.enabled; - if (disposed) return; - const becameEnabled = enabled && !workHubEnabledRef.current; - workHubEnabledRef.current = enabled; - setWorkHubEnabled(enabled); - if (!enabled || becameEnabled) setWorkHubActive(enabled); - if (becameEnabled) setNavSelection({ section: 'sessions' }); - } catch { - // Keep the last known client-owned setting. A transient settings read - // must not leave the shell half-switched between WorkHub and Session. - } - }; - void refresh(); - const unsubscribe = window.maka.settings.subscribeClientChanged(() => void refresh()); - return () => { - disposed = true; - unsubscribe(); - }; - }, [setNavSelection]); // #4582: read only displayed/owner Session chrome. Token content and global // streaming membership subscribe inside their consuming regions. const { @@ -735,7 +712,6 @@ function AppShellContent({ [shellCopy], ); const openWorkHub = useCallback(() => { - if (!workHubEnabledRef.current) return; overlays.commands.closeSettings(); setNavSelection({ section: 'sessions' }); setWorkHubActive(true); @@ -1520,7 +1496,7 @@ function AppShellContent({ + { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} /> commands.toggleTool('inspector')} onToggleWorkbar={commands.toggleRightPanel} onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} /> - + ; + subscribeChanges(handler: () => void): () => void; +} + +/** + * The one WorkHub switch the shell, Workbar, the rail and the dock follow. + * It reads while anyone is subscribed and starts from off again afterwards, + * as the shell's own state did. A failed read keeps the last known value, so + * a transient settings error cannot leave the shell half-switched. + */ +export interface WorkHubEnablement { + isEnabled(): boolean; + subscribe(listener: () => void): () => void; +} + +export function createWorkHubEnablement(source: WorkHubEnablementSource): WorkHubEnablement { + const listeners = new Set<() => void>(); + let enabled = false; + let generation = 0; + let unsubscribeSource: (() => void) | undefined; + const refresh = async (readGeneration: number) => { + try { + const next = await source.read(); + if (readGeneration !== generation || next === enabled) return; + enabled = next; + for (const listener of [...listeners]) listener(); + } catch { + // Keep the last known value. + } + }; + return { + isEnabled: () => enabled, + subscribe(listener) { + listeners.add(listener); + if (listeners.size === 1) { + const readGeneration = ++generation; + unsubscribeSource = source.subscribeChanges(() => void refresh(readGeneration)); + void refresh(readGeneration); + } + return () => { + if (!listeners.delete(listener) || listeners.size > 0) return; + generation += 1; + enabled = false; + unsubscribeSource?.(); + unsubscribeSource = undefined; + }; + }, + }; +} + +const DISABLED: WorkHubEnablement = { isEnabled: () => false, subscribe: () => () => {} }; +const EnablementContext = createContext(DISABLED); +export const WorkHubEnablementProvider = EnablementContext.Provider; + +/** Invocation-time reads, for commands that must not act while WorkHub is off. */ +export function useWorkHubEnablement(): WorkHubEnablement { + return useContext(EnablementContext); +} + +export function useWorkHubEnabled(): boolean { + const enablement = useContext(EnablementContext); + return useSyncExternalStore(enablement.subscribe, enablement.isEnabled); +} + +/** Navigation the shell owns follows the switch: turning it on opens WorkHub, turning it off leaves it. */ +export function WorkHubEnablementWatch(props: { onEnabled(): void; onDisabled(): void }) { + const enablement = useContext(EnablementContext); + const follow = useEffectEvent((enabled: boolean) => (enabled ? props.onEnabled() : props.onDisabled())); + useEffect(() => { + let followed = false; + return enablement.subscribe(() => { + const enabled = enablement.isEnabled(); + if (enabled === followed) return; + followed = enabled; + follow(enabled); + }); + }, [enablement]); + return null; +} diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 0fc18196c0..e433bef2b3 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -64,6 +64,11 @@ import { createSessionCatalogController, SessionCatalogContext, } from '../application/contracts/session-catalog/session-catalog-state.js'; +import { + createWorkHubEnablement, + WorkHubEnablementProvider, +} from '../application/contracts/workhub-workspace/workhub-enablement.js'; +import { createDesktopWorkHubEnablementSource } from '../platform/desktop/create-workhub-enablement-source.js'; if (import.meta.env.DEV) { const stopObserving = observeReactPerformanceMeasures(); @@ -77,6 +82,9 @@ export function createDesktopFeatureServices() { // through `useSessionCatalogController` so providers below do not need it // drilled through the shell. sessionCatalog: createSessionCatalogController(), + // Client-global, like the catalog: the shell, Workbar, the rail and the + // WorkHub dock all follow the same switch. + workHubEnablement: createWorkHubEnablement(createDesktopWorkHubEnablementSource()), appUpdate: createDesktopAppUpdateServices(), clientPlugins: createDesktopClientPluginServices(), diagnostics: createDesktopDiagnosticsServices(), @@ -106,6 +114,7 @@ export function DesktopFeatureServicesProvider(props: { }) { return ( + @@ -149,6 +158,7 @@ export function DesktopFeatureServicesProvider(props: { + ); } diff --git a/apps/desktop/src/renderer/features/session-navigation/README.md b/apps/desktop/src/renderer/features/session-navigation/README.md index 489ee4f183..c59fa8abc1 100644 --- a/apps/desktop/src/renderer/features/session-navigation/README.md +++ b/apps/desktop/src/renderer/features/session-navigation/README.md @@ -75,6 +75,11 @@ through `SessionNavigationPorts`, which the shell composes. - Local Sessions group by Project while remote Sessions group by Runtime Host. - Opening a Session first exits WorkHub, selects the Sessions destination, then activates the Session and replaces or clears the turn-scroll target. +- The WorkHub entry follows the client switch in the application WorkHub + enablement authority (`application/contracts/workhub-workspace`), which the + Workbar and the WorkHub dock read too. The rail shows the entry only while it + is on and checks it again before opening WorkHub; AppShell passes the entry + but holds no enablement state. - At most one row mutation runs per Session. Mutations retain revision-family semantics, and renderer state is cleared only after the Host confirms removal. - Width persistence remains trailing-debounced; width, collapse, and grouping 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 810140aa61..1944604b66 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 @@ -64,6 +64,7 @@ import { sessionIdSetsEqual } from '../../../application/contracts/session-catal import { useExternalStoreSelector } from '../../../application/contracts/session-catalog/use-external-store-selector.js'; import type { SessionSendProjection } from '@maka/core/session-send-projection'; import type { SnapshotReader } from '../../../application/contracts/snapshot-reader.js'; +import { useWorkHubEnabled, useWorkHubEnablement } from '../../../application/contracts/workhub-workspace/workhub-enablement.js'; /** The chrome the shell owns and the rail only displays. */ export interface SessionNavigationChromeInput { @@ -72,6 +73,7 @@ export interface SessionNavigationChromeInput { scheduledTasks?: readonly ScheduledTask[]; moduleMemory?: NavModuleMemory; workHubActive: boolean; + /** Shown, and selectable, only while the client WorkHub switch is on. */ workHubEntry?: { active: boolean; label: string; onSelect(): void }; projectActions?: ProjectRowActions; onSelect(selection: NavSelection): void; @@ -145,6 +147,8 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) ports: props.ports, }); const openRowId = props.workHubActive ? undefined : rail.activeRowId; + const workHubEnabled = useWorkHubEnabled(); + const workHubEnablement = useWorkHubEnablement(); const selection = useSessionSelection({ sessions: rail.sessions, commands: controller.commands, @@ -320,7 +324,10 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) props.onNew(); }, onOpenSettings: props.onOpenSettings, - workHubEntry: props.workHubEntry, + workHubEntry: workHubEnabled && props.workHubEntry ? { + ...props.workHubEntry, + onSelect: () => { if (workHubEnablement.isEnabled()) props.workHubEntry?.onSelect(); }, + } : undefined, }; return ( diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index b0438eb072..a4b2a8b65a 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -41,6 +41,7 @@ import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell import { sideChatTitleFromPrompt } from '../../../side-chat-command.js'; import { desktopSessionKey, parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js'; import { useWorkHubWorkspace } from '../../../application/contracts/workhub-workspace/use-workhub-workspace.js'; +import { useWorkHubEnabled } from '../../../application/contracts/workhub-workspace/workhub-enablement.js'; import { useWorkbarServices } from '../services-context.js'; import type { WorkbarHostModel } from '../ui/workbar-host.js'; import { SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY } from '../ui/side-chat-close-confirmation.js'; @@ -110,7 +111,7 @@ export interface WorkbarControllerSelectors { } export interface UseWorkbarControllerInput { - workHub?: { enabled: boolean; active: boolean }; + workHub?: { active: boolean }; /** Whether the Session workspace (rather than a module page) owns the shell. */ available: boolean; /** Local selection owns layout even while Host creation is pending. */ @@ -169,7 +170,7 @@ function nextOrdinal( export function useWorkbarController( requested: UseWorkbarControllerInput, ): WorkbarController { - const coordination = useWorkHubWorkspace(requested.workHub?.enabled ?? false, requested.authoritativeSessionIds); + const coordination = useWorkHubWorkspace(useWorkHubEnabled(), requested.authoritativeSessionIds); const workspace = requested.workHub?.active ? 'workhub' : 'session'; const activeSessionId = requested.workHub?.active ? coordination.sessionId : requested.activeSession?.id; const input: UseWorkbarControllerInput = requested.workHub?.active ? { diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts index a0422e555d..cf4ca50316 100644 --- a/apps/desktop/src/renderer/features/workhub/index.ts +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -25,6 +25,7 @@ export { WorkHubDock } from './ui/workhub-dock.js'; export { WorkHubSurfaceSwitch } from './ui/workhub-surface-switch.js'; export { WorkHubControlOverlay } from './ui/control-overlay.js'; export { WorkHubMainNavigation } from './ui/main-navigation.js'; +export { WorkHubEnablementWatch } from '../../application/contracts/workhub-workspace/workhub-enablement.js'; export { startWorkHubCoordinationLifecycle, WorkHubModelConfigurationRequiredError, diff --git a/apps/desktop/src/renderer/features/workhub/ui/main-navigation.tsx b/apps/desktop/src/renderer/features/workhub/ui/main-navigation.tsx index 36b6cc4503..20c1bc4d78 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/main-navigation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/main-navigation.tsx @@ -20,19 +20,21 @@ import { useEffect, useRef, useState } from 'react'; import { useSettingsNavigation } from '../../../application/contracts/settings-presentation/settings-navigation.js'; import { useWorkHubServices } from '../services.js'; +import { useWorkHubEnablement } from '../../../application/contracts/workhub-workspace/workhub-enablement.js'; export function WorkHubMainNavigation(props: { workbarReady: boolean; onOpenUsage(): void; onToggleWorkbar(): void; onOpenWorkHub(): void; onOpenSession(sessionId: string): void }) { const [pendingAction, setPendingAction] = useState<'usage' | 'toggle'>(); const { presentation } = useWorkHubServices(); const settingsNavigation = useSettingsNavigation(); + const enablement = useWorkHubEnablement(); const current = useRef(props); current.current = props; useEffect(() => presentation.onOpenMain((navigation) => { if (navigation.kind === 'workhub') { - current.current.onOpenWorkHub(); + if (enablement.isEnabled()) current.current.onOpenWorkHub(); setPendingAction(navigation.panelAction); } else if (navigation.kind === 'settings') { setPendingAction(undefined); settingsNavigation.openSettingsSection(navigation.section); } else { setPendingAction(undefined); current.current.onOpenSession(navigation.sessionKey); } - }), [presentation, settingsNavigation]); + }), [enablement, presentation, settingsNavigation]); useEffect(() => { if (pendingAction && props.workbarReady) { if (pendingAction === 'usage') current.current.onOpenUsage(); diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx index 3a3983eb51..afbdbbd13e 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx @@ -19,6 +19,7 @@ import type { WorkbarTogglePosition } from '@maka/core/settings'; import { isNativeSurfaceOccluded, watchNativeSurface, type NativeSurfaceWatch } from '../../../application/contracts/native-surface-occlusion.js'; +import { useWorkHubEnabled } from '../../../application/contracts/workhub-workspace/workhub-enablement.js'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Button } from '@astryxdesign/core'; import { useUiLocale } from '@maka/ui'; @@ -27,13 +28,13 @@ import { useWorkHubServices } from '../services.js'; import { workHubLiveCopy } from '../locales/workhub-live-copy.js'; /** The main window owns only this landing space; the live view keeps its React owner. */ -export function WorkHubDock({ enabled, visible = true, workbar, workbarTogglePosition }: { - enabled: boolean; +export function WorkHubDock({ visible = true, workbar, workbarTogglePosition }: { visible?: boolean; workbar: { bottomOpen: boolean; rightCollapsed: boolean }; workbarTogglePosition?: WorkbarTogglePosition; }) { const { presentation } = useWorkHubServices(); + const enabled = useWorkHubEnabled(); const t = workHubLiveCopy[useUiLocale()]; const element = useRef(null); const workbarRef = useRef({ diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-enablement-source.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-enablement-source.ts new file mode 100644 index 0000000000..2735dc594e --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-enablement-source.ts @@ -0,0 +1,29 @@ +/* + * 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 { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { WorkHubEnablementSource } from '../../application/contracts/workhub-workspace/workhub-enablement.js'; + +export function createDesktopWorkHubEnablementSource( + bridge: { settings: Pick } = window.maka, +): WorkHubEnablementSource { + return { + read: async () => (await bridge.settings.getClient()).workHub.enabled, + subscribeChanges: (handler) => bridge.settings.subscribeClientChanged(handler), + }; +} diff --git a/packages/ui/src/testing.ts b/packages/ui/src/testing.ts index 3361c7af02..655ac48c31 100644 --- a/packages/ui/src/testing.ts +++ b/packages/ui/src/testing.ts @@ -27,4 +27,4 @@ export { } from './transcript-scroll-authority.js'; export { foldTimeline } from './timeline-fold.js'; -export { useSessionRailSelection } from './session-rail-context.js'; +export { useSessionRailChrome, useSessionRailSelection } from './session-rail-context.js'; diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index b9ee6c7830..e3da1be549 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -114,7 +114,7 @@ export const ALLOWED = { useAppShellSessionUiReads: 1, useAppShellSessionWorkspace: 1, useAppShellTurnPresentation: 1, - useEffect: 2, + useEffect: 1, useLayoutEffect: 1, useNewTaskChoice: 1, useOnboardingSnapshot: 1, @@ -138,7 +138,7 @@ export const ALLOWED = { useShellMemoryPill: 1, useShellResume: 1, useStableActions: 4, - useState: 8, + useState: 7, useTaskSubmissionReadiness: 1, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry From 60204626086b95c53975c8ba0be1ab55d33cc92c Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:42:50 +0800 Subject: [PATCH 3/7] refactor(desktop): own onboarding in an application authority AppShell owned the onboarding snapshot: the poller, its invalidation subscriptions, the refresh after Settings closes and the bridge write behind "skip setup". Move the poller into an onboarding authority in application/contracts, created at composition with a Desktop source (the former onboarding-snapshot-bridge, now also writing the skip milestone on the default Host). The session rail reads per-Session send outcomes from it directly. AppShell receives a read-only projection plus refresh and skip through OnboardingProjectionRoot, the same render-prop pattern as the other shell roots, and derives first-run gating, the default-Host connection seed and the activation candidate from it. A failed read is now a flag. The localized error text was never shown, so nothing that might carry paths or tokens is kept. The snapshot types move to src/shared so the application layer can name them; preload re-exports them unchanged. AppShell loses onboarding.setMilestone, useOnboardingSnapshot and the sessionSendOutcomes prop it threaded to the rail. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 29 +- ...t.test.ts => onboarding-authority.test.ts} | 187 +++++++-- .../onboarding-incremental-preload.test.ts | 6 +- .../session-navigation-controller.test.ts | 30 +- apps/desktop/src/preload/bridge-contract.d.ts | 24 +- apps/desktop/src/renderer/app-shell.tsx | 42 +- .../onboarding/onboarding-authority.ts | 358 ++++++++++++++++++ .../composition/desktop-feature-services.tsx | 9 + .../features/session-navigation/README.md | 3 + .../ui/session-navigation-provider.tsx | 7 +- .../desktop/create-onboarding-source.ts | 53 +++ .../desktop/onboarding-snapshot-bridge.ts | 64 ---- .../src/renderer/use-onboarding-snapshot.ts | 341 ----------------- .../src/shared/onboarding-snapshot.d.ts | 42 ++ scripts/check-app-shell-hooks.mjs | 1 - 15 files changed, 688 insertions(+), 508 deletions(-) rename apps/desktop/src/main/__tests__/{use-onboarding-snapshot.test.ts => onboarding-authority.test.ts} (64%) create mode 100644 apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts create mode 100644 apps/desktop/src/renderer/platform/desktop/create-onboarding-source.ts delete mode 100644 apps/desktop/src/renderer/platform/desktop/onboarding-snapshot-bridge.ts delete mode 100644 apps/desktop/src/renderer/use-onboarding-snapshot.ts create mode 100644 apps/desktop/src/shared/onboarding-snapshot.d.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 0b1497f358..835b76fd96 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -178,7 +178,6 @@ "src/renderer/use-app-shell-session-list.ts", "src/renderer/use-app-shell-session-ui-reads.ts", "src/renderer/use-app-shell-session-workspace.ts", - "src/renderer/use-onboarding-snapshot.ts", "src/renderer/use-project-context.ts", "src/renderer/use-shell-appearance.ts", "src/renderer/use-shell-connections.ts", @@ -613,9 +612,8 @@ "nonTriviaTokens": 561 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 50, + "importDeclarations": 49, "bridgePaths": { - "window.maka.onboarding.setMilestone": 1, "window.maka.sessions.listTurnLandmarks": 1 }, "environmentCapabilities": { @@ -634,7 +632,6 @@ "useEffect": 1, "useLayoutEffect": 1, "useNewTaskChoice": 1, - "useOnboardingSnapshot": 1, "useRef": 6, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, @@ -666,6 +663,7 @@ "./app-shell-stop-action": 1, "./app-shell-turn-actions": 1, "./application/contracts/conversation-copy": 1, + "./application/contracts/onboarding/onboarding-authority.js": 1, "./application/contracts/session-catalog/catalog-row-watch.js": 1, "./application/contracts/turn-presentation": 1, "./chat-composer-region": 1, @@ -705,7 +703,6 @@ "./shell/frame-style": 1, "./use-app-shell-session-ui-reads": 1, "./use-app-shell-session-workspace": 1, - "./use-onboarding-snapshot": 1, "./use-project-context": 1, "./use-shell-appearance": 1, "./use-shell-connections": 1, @@ -725,8 +722,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 74, - "nonTriviaTokens": 8915 + "importSpecifiers": 71, + "nonTriviaTokens": 8901 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -3387,24 +3384,6 @@ "./theme": 1 } }, - "src/renderer/use-onboarding-snapshot.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useEffect": 1, - "useRef": 2, - "useState": 2, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./platform/desktop/onboarding-snapshot-bridge.js": 2, - "@maka/ui": 1, - "react": 1 - } - }, "src/renderer/use-project-context.ts": { "bridgePaths": { "window.maka.app.sessionProjectInfo": 1, diff --git a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts b/apps/desktop/src/main/__tests__/onboarding-authority.test.ts similarity index 64% rename from apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts rename to apps/desktop/src/main/__tests__/onboarding-authority.test.ts index 269e1857be..1de907ab2e 100644 --- a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-authority.test.ts @@ -18,16 +18,157 @@ */ import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { afterEach, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { act, createElement } from 'react'; import type { OnboardingState } from '@maka/core/onboarding'; import { applyOnboardingSessionUpdate, + createOnboardingAuthority, createOnboardingSnapshotPoller, getOnboardingActivationCandidate, + OnboardingAuthorityProvider, + OnboardingProjectionRoot, onboardingSnapshotProjectionEqual, -} from '../../renderer/use-onboarding-snapshot.js'; + type OnboardingShellProjection, + type OnboardingSource, +} from '../../renderer/application/contracts/onboarding/onboarding-authority.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { createDesktopOnboardingSource } from '../../renderer/platform/desktop/create-onboarding-source.js'; import type { OnboardingSnapshot } from '../../preload/bridge-contract.js'; +describe('createOnboardingAuthority', () => { + afterEach(cleanupFakeDom); + + function source(overrides: Partial = {}) { + const calls: string[] = []; + let invalidate: ((sessionId?: string) => void) | undefined; + const snapshots = [READY_SNAPSHOT, NEEDS_CONNECTION_SNAPSHOT]; + const value: OnboardingSource = { + getSnapshot: async () => { calls.push('snapshot'); return snapshots.shift() ?? NEEDS_CONNECTION_SNAPSHOT; }, + getSessionUpdate: async (sessionId) => { + calls.push(`session:${sessionId}`); + return { kind: 'delta', sessionId, outcome: { kind: 'blocked', reason: 'connection_missing', connectionLocked: false } }; + }, + subscribeInvalidations(handler) { + calls.push('subscribe'); + invalidate = handler; + return () => { calls.push('unsubscribe'); invalidate = undefined; }; + }, + skipInitialOnboarding: async () => { calls.push('skip'); }, + ...overrides, + }; + return { value, calls, invalidate: (sessionId?: string) => invalidate?.(sessionId) }; + } + + it('reads only while subscribed and keeps the snapshot for the next reader', async () => { + const fake = source(); + const authority = createOnboardingAuthority(fake.value); + authority.refresh(); + await flushMicrotasks(); + assert.deepEqual(fake.calls, [], 'no reader, no read'); + let notified = 0; + const unsubscribe = authority.subscribe(() => { notified += 1; }); + await flushMicrotasks(); + assert.equal(authority.getProjection().snapshot, READY_SNAPSHOT); + fake.invalidate('one'); + await flushMicrotasks(); + assert.deepEqual(authority.getProjection().snapshot?.sessionSendOutcomes, { + one: { kind: 'blocked', reason: 'connection_missing', connectionLocked: false }, + }); + assert.equal(notified, 2); + unsubscribe(); + assert.deepEqual(fake.calls, ['snapshot', 'subscribe', 'session:one', 'unsubscribe']); + assert.notEqual(authority.getProjection().snapshot, null, 'the accepted snapshot survives the last reader'); + }); + + it('flags a failed read without dropping the snapshot, and clears it on the next success', async () => { + let fail = false; + const fake = source({ + getSnapshot: async () => { + if (fail) throw new Error('Authorization: Bearer sk-live-secret-token-value'); + return READY_SNAPSHOT; + }, + }); + const authority = createOnboardingAuthority(fake.value); + const unsubscribe = authority.subscribe(() => {}); + await flushMicrotasks(); + fail = true; + fake.invalidate(); + await flushMicrotasks(); + assert.deepEqual(authority.getProjection(), { snapshot: READY_SNAPSHOT, failed: true }); + fail = false; + authority.refresh(); + await flushMicrotasks(); + assert.deepEqual(authority.getProjection(), { snapshot: READY_SNAPSHOT, failed: false }); + unsubscribe(); + }); + + it('hands the shell the live projection and the authority commands through its root', async () => { + const fake = source(); + const authority = createOnboardingAuthority(fake.value); + const seen: OnboardingShellProjection[] = []; + const { root } = installReactRenderer(); + await act(async () => root.render(createElement(OnboardingAuthorityProvider, { value: authority }, + createElement(OnboardingProjectionRoot, { + children: (onboarding: OnboardingShellProjection) => { + seen.push(onboarding); + return null; + }, + })))); + await act(async () => flushMicrotasks()); + assert.equal(seen.at(-1)?.snapshot, READY_SNAPSHOT); + assert.equal(seen.at(-1)?.refresh, authority.refresh); + await act(async () => { fake.invalidate(); await flushMicrotasks(); }); + assert.equal(seen.at(-1)?.snapshot, NEEDS_CONNECTION_SNAPSHOT); + await act(async () => root.unmount()); + assert.equal(fake.calls.at(-1), 'unsubscribe', 'the root is the subscriber that keeps the reads alive'); + }); + + it('re-pulls after a skip lands, and not after a skip fails', async () => { + let skipFails = false; + const fake = source({ + skipInitialOnboarding: async () => { + fake.calls.push('skip'); + if (skipFails) throw new Error('Host unavailable'); + }, + }); + const authority = createOnboardingAuthority(fake.value); + const unsubscribe = authority.subscribe(() => {}); + await flushMicrotasks(); + fake.calls.length = 0; + await authority.skipInitialOnboarding(); + await flushMicrotasks(); + assert.deepEqual(fake.calls, ['skip', 'snapshot']); + fake.calls.length = 0; + skipFails = true; + await assert.rejects(authority.skipInitialOnboarding(), /Host unavailable/); + await flushMicrotasks(); + assert.deepEqual(fake.calls, ['skip']); + unsubscribe(); + }); + + it('Desktop invalidates on named Session, connection and owner-profile events; AppShell no longer writes the milestone', async () => { + const handlers: Record void> = {}; + const invalidations: Array = []; + const listen = (name: string) => (handler: (event?: unknown) => void) => { handlers[name] = handler; return () => {}; }; + const desktop = createDesktopOnboardingSource({ + sessions: { subscribeChanges: listen('sessions') }, + connections: { subscribeEvents: listen('connections') }, + runtimeHostProfiles: { subscribeChanges: listen('profiles') }, + } as unknown as Parameters[0]); + desktop.subscribeInvalidations((sessionId) => invalidations.push(sessionId)); + handlers.sessions?.({ sessionId: 'one' }); + handlers.connections?.(); + handlers.profiles?.({ profileAccess: 'guest', isDefault: false }); + handlers.profiles?.({ profileAccess: 'owner', isDefault: false }); + assert.deepEqual(invalidations, ['one', undefined, undefined]); + const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); + assert.deepEqual(shell.split('\n').filter((line) => /\bonboarding\s*\.\s*setMilestone\b|\buseOnboardingSnapshot\b/.test(line)), []); + }); +}); + function flushMicrotasks(): Promise { return new Promise((resolve) => setImmediate(resolve)); } @@ -164,8 +305,8 @@ describe('createOnboardingSnapshotPoller', () => { }, { onSnapshot: (snapshot) => emitted.push(snapshot), onSessionUpdate: (update) => emitted.push(update), - onError: (error) => assert.fail(error), - }, () => 'zh-CN'); + onError: () => assert.fail('unexpected onboarding read failure'), + }); await poller.pull(); await poller.pullSession('one'); assert.equal(fullReads, 1); @@ -175,20 +316,20 @@ describe('createOnboardingSnapshotPoller', () => { it('keeps the accepted snapshot on a targeted failure until a complete resync succeeds', async () => { const snapshots: OnboardingSnapshot[] = []; - const errors: string[] = []; + let failures = 0; let fullReads = 0; const poller = createOnboardingSnapshotPoller({ getSnapshot: async () => ++fullReads === 1 ? READY_SNAPSHOT : NEEDS_CONNECTION_SNAPSHOT, getSessionUpdate: async () => { throw new Error('Host disconnected'); }, }, { onSnapshot: (snapshot) => snapshots.push(snapshot), - onError: (message) => errors.push(message), - }, () => 'zh-CN'); + onError: () => { failures += 1; }, + }); await poller.pull(); await poller.pullSession('one'); assert.deepEqual(snapshots, [READY_SNAPSHOT, NEEDS_CONNECTION_SNAPSHOT]); assert.equal(fullReads, 2, 'the failed delta needs an authoritative resync'); - assert.equal(errors.length, 1); + assert.equal(failures, 1); }); it('coalesces a repeat while a targeted read is in flight', async () => { @@ -206,8 +347,8 @@ describe('createOnboardingSnapshotPoller', () => { }, { onSnapshot: () => {}, onSessionUpdate: (update) => emitted.push(update.sessionId), - onError: (error) => assert.fail(error), - }, () => 'zh-CN'); + onError: () => assert.fail('unexpected onboarding read failure'), + }); await poller.pull(); const first = poller.pullSession('one'); void poller.pullSession('one'); @@ -230,8 +371,8 @@ describe('createOnboardingSnapshotPoller', () => { }, { onSnapshot: () => {}, onSessionUpdate: () => assert.fail('superseded update must not publish'), - onError: (error) => assert.fail(error), - }, () => 'zh-CN'); + onError: () => assert.fail('unexpected onboarding read failure'), + }); await poller.pull(); const first = poller.pullSession('active'); for (let index = 0; index < 65; index++) void poller.pullSession(`pending-${index}`); @@ -241,7 +382,7 @@ describe('createOnboardingSnapshotPoller', () => { assert.equal(fullReads, 2); }); - it('scrubs getSnapshot rejections before routing them to onError', async () => { + it('reports a getSnapshot rejection without carrying its text', async () => { const events: Array<{ type: 'snap' | 'err'; payload: unknown }> = []; const poller = createOnboardingSnapshotPoller( { @@ -251,14 +392,12 @@ describe('createOnboardingSnapshotPoller', () => { }, { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), - onError: (m) => events.push({ type: 'err', payload: m }), + onError: () => events.push({ type: 'err', payload: undefined }), }, - () => 'zh-CN', ); await poller.pull(); - assert.deepEqual(events, [{ type: 'err', payload: '鉴权失败' }]); - assert.notEqual(String(events[0]?.payload).includes('/Users/demo'), true); - assert.notEqual(String(events[0]?.payload).includes('sk-live-secret'), true); + // Nothing renders the failure's text, so none of it (paths, tokens) is kept. + assert.deepEqual(events, [{ type: 'err', payload: undefined }]); }); it('a pull issued while another is in flight runs once after it settles', async () => { @@ -277,7 +416,6 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh-CN', ); const pull1 = poller.pull(); const pull2 = poller.pull(); @@ -308,7 +446,6 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh-CN', ); void poller.pull(); void poller.pull(); @@ -339,7 +476,6 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh-CN', ); const pull = poller.pull(); poller.dispose(); @@ -361,9 +497,8 @@ describe('createOnboardingSnapshotPoller', () => { }, { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), - onError: (m) => events.push({ type: 'err', payload: m }), + onError: () => events.push({ type: 'err', payload: undefined }), }, - () => 'zh-CN', ); const pull = poller.pull(); @@ -386,9 +521,8 @@ describe('createOnboardingSnapshotPoller', () => { }, { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), - onError: (m) => events.push({ type: 'err', payload: m }), + onError: () => events.push({ type: 'err', payload: undefined }), }, - () => 'zh-CN', ); const pull = poller.pull(); @@ -405,9 +539,8 @@ describe('createOnboardingSnapshotPoller', () => { { getSnapshot: async () => READY_SNAPSHOT }, { onSnapshot: (s) => events.push({ type: 'snap', payload: s }), - onError: (m) => events.push({ type: 'err', payload: m }), + onError: () => events.push({ type: 'err', payload: undefined }), }, - () => 'zh-CN', ); poller.dispose(); diff --git a/apps/desktop/src/main/__tests__/onboarding-incremental-preload.test.ts b/apps/desktop/src/main/__tests__/onboarding-incremental-preload.test.ts index 938bd49387..d3bf587295 100644 --- a/apps/desktop/src/main/__tests__/onboarding-incremental-preload.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-incremental-preload.test.ts @@ -26,7 +26,7 @@ import { build } from 'esbuild'; import type { MakaBridge } from '../../preload/bridge-contract.js'; import type { OnboardingSnapshot } from '../onboarding-service.js'; import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; -import { createOnboardingSnapshotPoller } from '../../renderer/use-onboarding-snapshot.js'; +import { createOnboardingSnapshotPoller } from '../../renderer/application/contracts/onboarding/onboarding-authority.js'; const owners = [ { hostId: 'local-host', targetEpoch: 'local-epoch', profileId: 'local', profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready' }, @@ -172,8 +172,8 @@ test('a Renderer Session event crosses preload to only its owning Host update', assert.equal(update.sessionId, desktopSessionKey({ hostId: 'remote-host', sessionId: 'remote-task' })); receiveUpdate(); }, - onError: (message) => assert.fail(message), - }, () => 'en'); + onError: () => assert.fail('unexpected onboarding read failure'), + }); await poller.pull(); const unsubscribe = fixture.bridge.sessions.subscribeChanges((event) => { if (event.sessionId) void poller.pullSession(event.sessionId); 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 2eca495b1c..fcbd7587ab 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -52,6 +52,11 @@ import { WorkHubEnablementProvider, type WorkHubEnablement, } from '../../renderer/application/contracts/workhub-workspace/workhub-enablement.js'; +import { + OnboardingAuthorityProvider, + type OnboardingAuthority, + type OnboardingSnapshot, +} from '../../renderer/application/contracts/onboarding/onboarding-authority.js'; const EMPTY_STREAMING_SESSIONS = new Set(); @@ -223,7 +228,6 @@ function navigationTree( hiddenSessionIds, projectScopes: [localProjectScope], streamingSessions, - sessionSendOutcomes: {}, ports: ports(linkedCatalog, shell.activeSessionId), commandsRef: { current: null }, selection: { section: 'sessions' }, @@ -618,6 +622,30 @@ describe('SessionNavigationProvider selection', () => { assert.deepEqual([...selection().selectedIds], []); }); + it('marks stale rows from the onboarding authority rather than a shell prop', async () => { + let rail: SessionRailData | undefined; + function Rail() { + rail = useSessionRailData(); + return null; + } + const snapshot = { + sessionSendOutcomes: { remote: { kind: 'blocked', reason: 'connection_missing', connectionLocked: false } }, + } as unknown as OnboardingSnapshot; + const onboarding: OnboardingAuthority = { + getProjection: () => ({ snapshot, failed: false }), + subscribe: () => () => {}, + refresh: () => {}, + skipInitialOnboarding: async () => {}, + }; + const catalog = createSessionCatalogController(); + catalog.commitSessions(linkedCatalog); + const { root } = installReactRenderer(); + await act(async () => root.render(createElement(OnboardingAuthorityProvider, { value: onboarding }, + navigationTree(catalog, { activeSessionId: 'root', workHubActive: false }, null, createElement(Rail))))); + assert.deepEqual([...(rail?.staleSessionIds ?? [])], ['remote']); + await act(async () => root.unmount()); + }); + it('offers the WorkHub entry only while the switch is on, checked again on select', async () => { let chrome: SessionRailChrome | undefined; function ChromeProbe() { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d705d18425..3868b8e3f5 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -324,27 +324,9 @@ export interface RecallSearchResult { readonly searchedEverySession: boolean; } -export interface OnboardingSnapshot { - state: OnboardingState; - milestones: OnboardingMilestone[]; - sessions: DesktopSessionSummary[]; - connections: import('@maka/core/llm-connections').ProjectedLlmConnection[]; - defaultSlug: string | null; - chatModelChoices: import('@maka/core/chat-model-choice').ChatModelChoice[]; - sessionSendOutcomes: Record; -} - -export type DesktopOnboardingSessionUpdate = - | { kind: 'resync' } - | { - kind: 'delta'; - sessionId: string; - outcome: import('@maka/core/session-send-projection').SessionSendProjection | null; - defaultHost?: { - state: OnboardingState; - milestones: OnboardingMilestone[]; - }; - }; +// Shared with the renderer's application onboarding authority, which may not import preload. +import type { OnboardingSnapshot, DesktopOnboardingSessionUpdate } from '../shared/onboarding-snapshot.js'; +export type { OnboardingSnapshot, DesktopOnboardingSessionUpdate } from '../shared/onboarding-snapshot.js'; export interface DesktopTaskSubmissionReadinessRequest { connectionSlug?: string; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 41084cd82b..18b9bf97d2 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -92,7 +92,11 @@ import type { SessionCollaborationDialogProjection } from './features/session-co import { NEW_TASK_PENDING_KEY } from './pending-items'; import { desktopSlashCommandAvailability, parseDesktopSlashCommand } from './desktop-slash-command'; import { mergeWorkspaceReferences, rebaseWorkspaceFileReferences } from './follow-up-submit-routing'; -import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; +import { + getOnboardingActivationCandidate, + OnboardingProjectionRoot, + type OnboardingShellProjection, +} from './application/contracts/onboarding/onboarding-authority.js'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; @@ -121,10 +125,7 @@ import { AppShellOverlays } from './app-shell-overlays'; import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import { CustomPetCompanion } from './custom-pet-companion'; import { derivePetActivityState } from './custom-pet-companion-model'; -import { - defaultRuntimeHostDiagnosticTarget, - runOnDefaultRuntimeHost, -} from './platform/desktop/default-runtime-host-operation.js'; +import { defaultRuntimeHostDiagnosticTarget } from './platform/desktop/default-runtime-host-operation.js'; import { useAppShellProjectContext } from './use-project-context'; import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; import { createAppShellChatActions } from './app-shell-chat-actions'; @@ -204,9 +205,13 @@ export function AppShell() { {(workbar) => ( - + + {(onboarding) => ( + + )} + )} @@ -240,6 +245,7 @@ function AppShellContent({ overlays, sharedSessionDialog, workbar: { bridge, commands, selectors, LiveContextUsageProbe }, + onboarding, uiLocale, uiLocaleOverride, setUiLocaleOverride, @@ -249,6 +255,7 @@ function AppShellContent({ overlays: OverlaysShellProjection; sharedSessionDialog: SessionCollaborationDialogProjection; workbar: WorkbarShellProjection; + onboarding: OnboardingShellProjection; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; @@ -315,7 +322,6 @@ function AppShellContent({ const { searchScrollTarget } = overlays.selectors; const settingsOpen = overlays.selectors.settings.open; - const onboarding = useOnboardingSnapshot(); // The owner bridge keeps commands stable while TaskEntryRoot swaps the // current feature-owned implementation below the shell. const { resolveWorkBoardTarget, prepareWorkBoardDraft, openSessionWorkspaceRecovery } = taskEntry.commands; @@ -772,7 +778,7 @@ function AppShellContent({ transcriptHasHistory; // PR110c: OnboardingState is now the single source of truth for // first-run UI. The renderer never re-derives provider readiness; - // `useOnboardingSnapshot()` pulls the derived state from the main + // the application onboarding authority pulls the derived state from the main // process (PR110a + PR110b contract) and reactively invalidates on // `sessions:changed` + `connections:event`. The hero renders only // when sessions.length === 0; any session (including archived / @@ -785,18 +791,18 @@ function AppShellContent({ defaultConnection: snapshot.defaultSlug, chatModelChoices: snapshot.chatModelChoices, }); - } else if (onboarding.error) { + } else if (onboarding.failed) { // Session bootstrap is independent above. If onboarding itself failed, // retain the previous connection-specific recovery path as well. void defaultHostConnections.refreshConnections(); } - }, [onboarding.error, onboarding.snapshot]); + }, [onboarding.failed, onboarding.snapshot]); // Nothing settled to show while the first snapshot pull is in flight. The // flag keeps the composer hidden and — through `data-maka-content-ready` on // .appFrame — holds the launch overlay until a real frame exists: sessions, // a hero, or the load-error fallback. const isOnboardingLoading = - sessionCount === 0 && onboardingState === undefined && !onboardingSettled && !onboarding.error; + sessionCount === 0 && onboardingState === undefined && !onboardingSettled && !onboarding.failed; // 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. @@ -1624,7 +1630,6 @@ function AppShellContent({ hiddenSessionIds={selectors.hiddenSessionIds} projectScopes={taskEntry.selectors.projectScopes} streamingSessions={sessionUiReads.streaming} - sessionSendOutcomes={onboarding.snapshot?.sessionSendOutcomes} SessionBadge={SessionCollaboration.SessionTurnRequestBadge} NavigationExtras={SessionCollaboration.SessionCollaborationNavigation} ports={sessionNavigationPorts} @@ -1906,14 +1911,7 @@ function AppShellContent({ onRefreshConnections={refreshConnections} onSkip={async () => { try { - await runOnDefaultRuntimeHost((host) => - window.maka.onboarding.setMilestone( - 'initial_onboarding', - 'skipped', - host, - ), - ); - onboarding.refresh(); + await onboarding.skipInitialOnboarding(); } catch (error) { toastApi.error( shellCopy.skipErrorTitle, diff --git a/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts b/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts new file mode 100644 index 0000000000..8ec85b5583 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts @@ -0,0 +1,358 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * The renderer's one onboarding authority (PR110c contract, #4582 M5). + * + * The renderer NEVER re-derives provider readiness: it consumes Main's + * snapshot and targeted Session updates, invalidated by existing event + * channels only. Desktop supplies that source at composition; the authority + * owns the pull discipline and the skip command, and readers take read-only + * projections. A failed read is reported as a flag, never as error text. + */ + +import { createContext, useContext, useSyncExternalStore, type ReactNode } from 'react'; +import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; +import type { SessionSendProjection } from '@maka/core/session-send-projection'; +import { valuesEqual } from '@maka/ui'; +import type { + DesktopOnboardingSessionUpdate, + OnboardingSnapshot, +} from '../../../../shared/onboarding-snapshot.js'; + +export type { OnboardingSnapshot }; + +export interface OnboardingSource { + getSnapshot(): Promise; + getSessionUpdate?(sessionId: string): Promise; + /** Fires with a Session id for a named change, without one when anything else may be stale. */ + subscribeInvalidations(onInvalidate: (sessionId?: string) => void): () => void; + /** Records that the user skipped first-run setup on the default Host. */ + skipInitialOnboarding(): Promise; +} + +export interface OnboardingProjection { + /** `null` until the first complete read lands. */ + readonly snapshot: OnboardingSnapshot | null; + /** The last read failed; the next successful read clears it. */ + readonly failed: boolean; +} + +export interface OnboardingAuthority { + getProjection(): OnboardingProjection; + subscribe(listener: () => void): () => void; + /** Re-pull after an action Main has no event for, such as closing Settings. */ + refresh(): void; + skipInitialOnboarding(): Promise; +} + +/** + * The core readiness pair may seed only the unfinished first task. Once the + * guide is settled or workspace history exists, normal Composer preference + * rules own new-task selection again. + */ +export function getOnboardingActivationCandidate( + snapshot: Pick | null, + hasWorkspaceHistory: boolean, +): { llmConnectionSlug: string; model: string } | undefined { + if ( + snapshot?.state.kind !== 'ready_empty' || + hasWorkspaceHistory || + hasSettledInitialOnboarding(snapshot.milestones) + ) { + return undefined; + } + return { + llmConnectionSlug: snapshot.state.connectionSlug, + model: snapshot.state.model, + }; +} + +/** + * `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]), + ); +} + +/** + * Serializes complete and targeted reads — an invalidation while a read is + * in flight schedules a bounded follow-up — and gates callbacks on the active + * flag plus a dispose-bumped ticket so pending responses cannot write after + * the last reader leaves. + */ +export interface OnboardingSnapshotPollerCallbacks { + onSnapshot(snapshot: OnboardingSnapshot): void; + onSessionUpdate?(update: Extract): void; + onError(): void; +} + +export interface OnboardingSnapshotPoller { + /** Called when a reader arrives, so StrictMode cleanup replay can recover. */ + activate(): void; + /** Fetch the latest snapshot unless disposed. */ + pull(): Promise; + /** Refresh one Session's projection after an identified change. */ + pullSession(sessionId: string): Promise; + /** Stop accepting callbacks. Pending responses become no-ops. */ + dispose(): void; +} + +export function createOnboardingSnapshotPoller( + deps: Pick, + callbacks: OnboardingSnapshotPollerCallbacks, +): OnboardingSnapshotPoller { + let inflightTicket = 0; + let active = true; + let inflight: Promise | null = null; + let fullPending = false; + let hasSnapshot = false; + const pendingSessions = new Set(); + const maxPendingSessions = 64; + + function emitSnapshot(snapshot: OnboardingSnapshot): void { + if (!active) return; + callbacks.onSnapshot(snapshot); + } + + function emitError(): void { + if (!active) return; + callbacks.onError(); + } + + async function runPull(): Promise { + const ticket = ++inflightTicket; + try { + const next = await deps.getSnapshot(); + if (!active || ticket !== inflightTicket || fullPending) return; + hasSnapshot = true; + emitSnapshot(next); + } catch { + if (!active || ticket !== inflightTicket || fullPending) return; + emitError(); + } + } + + async function runSessionUpdate(sessionId: string): Promise { + const ticket = ++inflightTicket; + try { + const update = await deps.getSessionUpdate!(sessionId); + if (!active || ticket !== inflightTicket) return; + if (update?.kind === 'resync') { + fullPending = true; + } else if (update?.kind === 'delta' && !fullPending && !pendingSessions.has(sessionId)) { + callbacks.onSessionUpdate?.(update); + } + } catch { + if (!active || ticket !== inflightTicket || fullPending) return; + fullPending = true; + emitError(); + } + } + + function drain(): Promise { + if (!active) return Promise.resolve(); + if (inflight !== null) return inflight; + const loop = (async () => { + do { + if (fullPending) { + fullPending = false; + pendingSessions.clear(); + await runPull(); + } else { + const sessionId = pendingSessions.values().next().value; + if (sessionId === undefined) break; + pendingSessions.delete(sessionId); + await runSessionUpdate(sessionId); + } + } while (active && (fullPending || pendingSessions.size > 0)); + inflight = null; + })(); + inflight = loop; + return loop; + } + + return { + activate(): void { + active = true; + }, + pull(): Promise { + if (!active) return Promise.resolve(); + fullPending = true; + pendingSessions.clear(); + return drain(); + }, + pullSession(sessionId: string): Promise { + if (!active) return Promise.resolve(); + if ( + !deps.getSessionUpdate || + (!hasSnapshot && inflight === null) || + (pendingSessions.size >= maxPendingSessions && !pendingSessions.has(sessionId)) + ) { + fullPending = true; + pendingSessions.clear(); + return drain(); + } + if (!fullPending) pendingSessions.add(sessionId); + return drain(); + }, + dispose(): void { + active = false; + inflightTicket += 1; + fullPending = false; + pendingSessions.clear(); + hasSnapshot = false; + }, + }; +} + +export function applyOnboardingSessionUpdate( + snapshot: OnboardingSnapshot, + update: Extract, +): OnboardingSnapshot { + const previous = snapshot.sessionSendOutcomes[update.sessionId]; + const outcomeChanged = update.outcome === null + ? previous !== undefined + : !valuesEqual(previous, update.outcome); + const state = update.defaultHost?.state ?? snapshot.state; + const milestones = update.defaultHost?.milestones ?? snapshot.milestones; + if (!outcomeChanged && valuesEqual(state, snapshot.state) && + valuesEqual(milestones, snapshot.milestones)) return snapshot; + const sessionSendOutcomes = outcomeChanged + ? { ...snapshot.sessionSendOutcomes } + : snapshot.sessionSendOutcomes; + if (outcomeChanged) { + if (update.outcome === null) delete sessionSendOutcomes[update.sessionId]; + else sessionSendOutcomes[update.sessionId] = update.outcome; + } + return { ...snapshot, state, milestones, sessionSendOutcomes }; +} + +const INITIAL: OnboardingProjection = { snapshot: null, failed: false }; + +/** + * Reads while anyone is subscribed. The last accepted snapshot survives a + * reader leaving and is revalidated when one returns, as the shell's own + * state survived StrictMode's effect replay. + */ +export function createOnboardingAuthority(source: OnboardingSource): OnboardingAuthority { + const listeners = new Set<() => void>(); + let projection = INITIAL; + let unsubscribeInvalidations: (() => void) | undefined; + const publish = (snapshot: OnboardingSnapshot | null, failed: boolean) => { + if (snapshot === projection.snapshot && failed === projection.failed) return; + projection = { snapshot, failed }; + for (const listener of [...listeners]) listener(); + }; + const poller = createOnboardingSnapshotPoller(source, { + onSnapshot: (next) => publish( + projection.snapshot !== null && onboardingSnapshotProjectionEqual(projection.snapshot, next) + ? projection.snapshot + : next, + false, + ), + onSessionUpdate: (update) => publish( + projection.snapshot === null ? null : applyOnboardingSessionUpdate(projection.snapshot, update), + false, + ), + onError: () => publish(projection.snapshot, true), + }); + poller.dispose(); + return { + getProjection: () => projection, + subscribe(listener) { + listeners.add(listener); + if (listeners.size === 1) { + poller.activate(); + void poller.pull(); + unsubscribeInvalidations = source.subscribeInvalidations((sessionId) => { + if (sessionId) void poller.pullSession(sessionId); + else void poller.pull(); + }); + } + return () => { + if (!listeners.delete(listener) || listeners.size > 0) return; + unsubscribeInvalidations?.(); + unsubscribeInvalidations = undefined; + poller.dispose(); + }; + }, + refresh: () => void poller.pull(), + async skipInitialOnboarding() { + await source.skipInitialOnboarding(); + void poller.pull(); + }, + }; +} + +const IDLE: OnboardingAuthority = { + getProjection: () => INITIAL, + subscribe: () => () => {}, + refresh: () => {}, + skipInitialOnboarding: async () => {}, +}; +const AuthorityContext = createContext(IDLE); +export const OnboardingAuthorityProvider = AuthorityContext.Provider; + +export type OnboardingShellProjection = + OnboardingProjection & Pick; + +/** + * The shell's read, handed down like the other shell roots' projections: + * first-run surface gating, the default-Host connection seed, the model + * activation candidate and the readiness refresh key derive from this + * read-only projection; refresh and skip are its only commands. + */ +export function OnboardingProjectionRoot(props: { + children(onboarding: OnboardingShellProjection): ReactNode; +}) { + const authority = useContext(AuthorityContext); + const projection = useSyncExternalStore(authority.subscribe, authority.getProjection); + return props.children({ + ...projection, + refresh: authority.refresh, + skipInitialOnboarding: authority.skipInitialOnboarding, + }); +} + +const selectSendOutcomes = (authority: OnboardingAuthority) => authority.getProjection().snapshot?.sessionSendOutcomes; + +/** Per-Session send outcomes, for readers that need nothing else from onboarding. */ +export function useOnboardingSessionSendOutcomes(): Readonly> | undefined { + const authority = useContext(AuthorityContext); + return useSyncExternalStore(authority.subscribe, () => selectSendOutcomes(authority)); +} diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index e433bef2b3..beb1b7aed1 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -69,6 +69,11 @@ import { WorkHubEnablementProvider, } from '../application/contracts/workhub-workspace/workhub-enablement.js'; import { createDesktopWorkHubEnablementSource } from '../platform/desktop/create-workhub-enablement-source.js'; +import { + createOnboardingAuthority, + OnboardingAuthorityProvider, +} from '../application/contracts/onboarding/onboarding-authority.js'; +import { createDesktopOnboardingSource } from '../platform/desktop/create-onboarding-source.js'; if (import.meta.env.DEV) { const stopObserving = observeReactPerformanceMeasures(); @@ -85,6 +90,8 @@ export function createDesktopFeatureServices() { // Client-global, like the catalog: the shell, Workbar, the rail and the // WorkHub dock all follow the same switch. workHubEnablement: createWorkHubEnablement(createDesktopWorkHubEnablementSource()), + // First-run state is read by the shell, the rail and composer readiness. + onboarding: createOnboardingAuthority(createDesktopOnboardingSource()), appUpdate: createDesktopAppUpdateServices(), clientPlugins: createDesktopClientPluginServices(), diagnostics: createDesktopDiagnosticsServices(), @@ -115,6 +122,7 @@ export function DesktopFeatureServicesProvider(props: { return ( + @@ -158,6 +166,7 @@ export function DesktopFeatureServicesProvider(props: { + ); diff --git a/apps/desktop/src/renderer/features/session-navigation/README.md b/apps/desktop/src/renderer/features/session-navigation/README.md index c59fa8abc1..dfae1bcf52 100644 --- a/apps/desktop/src/renderer/features/session-navigation/README.md +++ b/apps/desktop/src/renderer/features/session-navigation/README.md @@ -80,6 +80,9 @@ through `SessionNavigationPorts`, which the shell composes. Workbar and the WorkHub dock read too. The rail shows the entry only while it is on and checks it again before opening WorkHub; AppShell passes the entry but holds no enablement state. +- Stale-row marks come from per-Session send outcomes in the application + onboarding authority (`application/contracts/onboarding`), read here + directly rather than threaded through AppShell. - At most one row mutation runs per Session. Mutations retain revision-family semantics, and renderer state is cleared only after the Host confirms removal. - Width persistence remains trailing-debounced; width, collapse, and grouping 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 1944604b66..ab33c14504 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 @@ -62,9 +62,9 @@ import { selectSessions, type SessionCatalogController } from '../../../applicat 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'; import type { SnapshotReader } from '../../../application/contracts/snapshot-reader.js'; import { useWorkHubEnabled, useWorkHubEnablement } from '../../../application/contracts/workhub-workspace/workhub-enablement.js'; +import { useOnboardingSessionSendOutcomes } from '../../../application/contracts/onboarding/onboarding-authority.js'; /** The chrome the shell owns and the rail only displays. */ export interface SessionNavigationChromeInput { @@ -98,7 +98,6 @@ export interface SessionNavigationProviderProps extends SessionNavigationChromeI projectScopes: readonly SessionNavigationProjectScope[]; /** The activity projection subscribes here, without publishing through AppShell. */ streamingSessions: SnapshotReader>; - sessionSendOutcomes?: Readonly>; SessionBadge?: ComponentType<{ readonly sessionId: string }>; ports: SessionNavigationPorts; /** @@ -128,10 +127,12 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) props.streamingSessions.getSnapshot, ); const sessions = useExternalStoreSelector(props.catalog, selectSessions); + // Send outcomes come from the onboarding authority, not through AppShell. + const sessionSendOutcomes = useOnboardingSessionSendOutcomes(); const staleSessionIds = useExternalStoreSelector( props.catalog, selectStaleSessionIds, - props.sessionSendOutcomes, + sessionSendOutcomes, sessionIdSetsEqual, ); const rail = useMemo( diff --git a/apps/desktop/src/renderer/platform/desktop/create-onboarding-source.ts b/apps/desktop/src/renderer/platform/desktop/create-onboarding-source.ts new file mode 100644 index 0000000000..ce12138d92 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-onboarding-source.ts @@ -0,0 +1,53 @@ +/* + * 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 { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { OnboardingSource } from '../../application/contracts/onboarding/onboarding-authority.js'; +import { runOnDefaultRuntimeHost } from './default-runtime-host-operation.js'; + +/** + * Named Session events use targeted reads; connection and Owner profile + * changes request complete snapshots. Settings changes are NOT subscribed: + * there is no settings-wide event channel, so callers that need a re-pull + * after a settings-only write (closing Settings) refresh the authority. + */ +export function createDesktopOnboardingSource( + bridge: Pick = window.maka, +): OnboardingSource { + return { + getSnapshot: () => bridge.onboarding.getSnapshot(), + getSessionUpdate: (sessionId) => bridge.onboarding.getSessionUpdate(sessionId), + subscribeInvalidations(onInvalidate) { + const unsubscribeSessions = bridge.sessions.subscribeChanges((event) => + onInvalidate(event.sessionId)); + const unsubscribeConnections = bridge.connections.subscribeEvents(() => onInvalidate()); + const unsubscribeProfiles = bridge.runtimeHostProfiles.subscribeChanges((event) => { + if (event.profileAccess === 'owner' || event.isDefault) onInvalidate(); + }); + return () => { + unsubscribeSessions(); + unsubscribeConnections(); + unsubscribeProfiles(); + }; + }, + skipInitialOnboarding: async () => { + await runOnDefaultRuntimeHost((host) => + bridge.onboarding.setMilestone('initial_onboarding', 'skipped', host)); + }, + }; +} diff --git a/apps/desktop/src/renderer/platform/desktop/onboarding-snapshot-bridge.ts b/apps/desktop/src/renderer/platform/desktop/onboarding-snapshot-bridge.ts deleted file mode 100644 index 25974e8e1d..0000000000 --- a/apps/desktop/src/renderer/platform/desktop/onboarding-snapshot-bridge.ts +++ /dev/null @@ -1,64 +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 { generalizedErrorMessageForLocale } from '@maka/core/redaction'; -import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { OnboardingSnapshot } from '../../../preload/bridge-contract.js'; -import { getOnboardingCopy } from '../../locales/onboarding-copy.js'; - -export function getOnboardingActivationCandidate( - snapshot: Pick | null, - hasWorkspaceHistory: boolean, -): { llmConnectionSlug: string; model: string } | undefined { - if ( - snapshot?.state.kind !== 'ready_empty' || - hasWorkspaceHistory || - hasSettledInitialOnboarding(snapshot.milestones) - ) { - return undefined; - } - return { - llmConnectionSlug: snapshot.state.connectionSlug, - model: snapshot.state.model, - }; -} - -export function onboardingSnapshotErrorMessage(error: unknown, locale: UiLocale): string { - const fallback = getOnboardingCopy(locale).snapshotErrorFallback; - return generalizedErrorMessageForLocale(error, fallback, locale); -} - -export const desktopOnboardingSnapshotDeps = { - getSnapshot: () => window.maka.onboarding.getSnapshot(), - getSessionUpdate: (sessionId: string) => window.maka.onboarding.getSessionUpdate(sessionId), - subscribeInvalidations(onInvalidate: (sessionId?: string) => void) { - const unsubscribeSessions = window.maka.sessions.subscribeChanges((event) => - onInvalidate(event.sessionId)); - const unsubscribeConnections = window.maka.connections.subscribeEvents(() => onInvalidate()); - const unsubscribeProfiles = window.maka.runtimeHostProfiles.subscribeChanges((event) => { - if (event.profileAccess === 'owner' || event.isDefault) onInvalidate(); - }); - return () => { - unsubscribeSessions(); - unsubscribeConnections(); - unsubscribeProfiles(); - }; - }, -}; diff --git a/apps/desktop/src/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts deleted file mode 100644 index b451cfa46a..0000000000 --- a/apps/desktop/src/renderer/use-onboarding-snapshot.ts +++ /dev/null @@ -1,341 +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. - */ - -/** - * `useOnboardingSnapshot` — renderer hook over the PR110b IPC. - * - * @kenji + @xuan PR110c review gates: - * 1. Renderer NEVER re-derives provider readiness; it consumes - * `onboarding:getSnapshot()` and targeted onboarding updates. Connections, secrets, default - * slugs etc. are not touched. - * 2. Invalidation uses existing event channels — - * `sessions:changed`, `connections:event`, and Host profile changes. No new event bus - * for PR110c. - * 3. `refresh()` is provided for action-driven re-pulls (e.g. - * "the user just clicked '打开设置 · 模型' so re-pull when the - * modal closes"). - */ - -import { useCallback, useEffect, useRef, useState } from 'react'; -import { type UiLocale } from '@maka/core/ui-locale'; -import { useUiLocale, valuesEqual } from '@maka/ui'; -import type { OnboardingSnapshot } from '../preload/bridge-contract.js'; -import type { DesktopOnboardingSessionUpdate } from '../preload/bridge-contract.js'; -import { - desktopOnboardingSnapshotDeps, - onboardingSnapshotErrorMessage, -} from './platform/desktop/onboarding-snapshot-bridge.js'; -export { getOnboardingActivationCandidate } from './platform/desktop/onboarding-snapshot-bridge.js'; - -/** - * Hook return type — `snapshot` is `null` while the initial getSnapshot - * IPC is still in flight, then settles to the latest derived value. - * `error` carries a generalized Chinese message if the IPC ever fails - * (`onboarding:getSnapshot` is best-effort; main treats it as - * non-throwing in current implementations, but we surface the slot - * defensively). - */ -export interface UseOnboardingSnapshotResult { - snapshot: OnboardingSnapshot | null; - error: string | null; - refresh: () => void; -} - -export interface UseOnboardingSnapshotDeps { - /** Fetch the current snapshot. */ - getSnapshot: () => Promise; - getSessionUpdate?: (sessionId: string) => Promise; - /** - * Subscribe to invalidation signals. The handler is fired - * (debounced internally by the caller if needed) whenever an - * upstream event suggests the snapshot may be stale. Return value - * is an unsubscribe function. - */ - subscribeInvalidations: (onInvalidate: (sessionId?: string) => void) => () => void; -} - -/** - * The core readiness pair may seed only the unfinished first task. Once the - * guide is settled or workspace history exists, normal Composer preference - * rules own new-task selection again. - */ -/** - * `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 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, -): UseOnboardingSnapshotResult { - const locale = useUiLocale(); - const localeRef = useRef(locale); - localeRef.current = locale; - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const pollerRef = useRef(null); - - if (pollerRef.current === null) { - pollerRef.current = createOnboardingSnapshotPoller(deps, { - onSnapshot: (next) => { - setSnapshot((prev) => - prev !== null && onboardingSnapshotProjectionEqual(prev, next) ? prev : next, - ); - setError(null); - }, - onSessionUpdate: (update) => { - setSnapshot((prev) => prev === null ? prev : applyOnboardingSessionUpdate(prev, update)); - setError(null); - }, - onError: (message) => { - setError(message); - }, - }, () => localeRef.current); - } - - useEffect(() => { - const poller = pollerRef.current!; - poller.activate(); - void poller.pull(); - const unsubscribe = deps.subscribeInvalidations((sessionId) => { - if (sessionId) void poller.pullSession(sessionId); - else void poller.pull(); - }); - return () => { - unsubscribe(); - poller.dispose(); - }; - }, [deps]); - - const refresh = useCallback(() => { - void pollerRef.current?.pull(); - }, []); - - return { - snapshot, - error, - refresh, - }; -} - -/** - * React-less poller. Serializes complete and targeted IPCs — an invalidation while a - * read is in flight schedules a bounded 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; - onSessionUpdate?(update: Extract): void; - onError(message: string): void; -} - -export interface OnboardingSnapshotPoller { - /** React effect setup calls this so StrictMode cleanup replay can recover. */ - activate(): void; - /** Fetch the latest snapshot unless disposed. */ - pull(): Promise; - /** Refresh one Session's projection after an identified change. */ - pullSession(sessionId: string): Promise; - /** Stop accepting callbacks. Pending IPC responses become no-ops. */ - dispose(): void; -} - -export function createOnboardingSnapshotPoller( - deps: Pick, - callbacks: OnboardingSnapshotPollerCallbacks, - getLocale: () => UiLocale, -): OnboardingSnapshotPoller { - let inflightTicket = 0; - let active = true; - let inflight: Promise | null = null; - let fullPending = false; - let hasSnapshot = false; - const pendingSessions = new Set(); - const maxPendingSessions = 64; - - function emitSnapshot(snapshot: OnboardingSnapshot): void { - if (!active) return; - callbacks.onSnapshot(snapshot); - } - - function emitError(message: string): void { - if (!active) return; - callbacks.onError(message); - } - - async function runPull(): Promise { - const ticket = ++inflightTicket; - try { - const next = await deps.getSnapshot(); - if (!active || ticket !== inflightTicket || fullPending) return; - hasSnapshot = true; - emitSnapshot(next); - } catch (err) { - if (!active || ticket !== inflightTicket || fullPending) return; - emitError(onboardingSnapshotErrorMessage(err, getLocale())); - } - } - - async function runSessionUpdate(sessionId: string): Promise { - const ticket = ++inflightTicket; - try { - const update = await deps.getSessionUpdate!(sessionId); - if (!active || ticket !== inflightTicket) return; - if (update?.kind === 'resync') { - fullPending = true; - } else if (update?.kind === 'delta' && !fullPending && !pendingSessions.has(sessionId)) { - callbacks.onSessionUpdate?.(update); - } - } catch (error) { - if (!active || ticket !== inflightTicket || fullPending) return; - fullPending = true; - emitError(onboardingSnapshotErrorMessage(error, getLocale())); - } - } - - function drain(): Promise { - if (!active) return Promise.resolve(); - if (inflight !== null) return inflight; - const loop = (async () => { - do { - if (fullPending) { - fullPending = false; - pendingSessions.clear(); - await runPull(); - } else { - const sessionId = pendingSessions.values().next().value; - if (sessionId === undefined) break; - pendingSessions.delete(sessionId); - await runSessionUpdate(sessionId); - } - } while (active && (fullPending || pendingSessions.size > 0)); - inflight = null; - })(); - inflight = loop; - return loop; - } - - return { - activate(): void { - active = true; - }, - pull(): Promise { - if (!active) return Promise.resolve(); - fullPending = true; - pendingSessions.clear(); - return drain(); - }, - pullSession(sessionId: string): Promise { - if (!active) return Promise.resolve(); - if ( - !deps.getSessionUpdate || - (!hasSnapshot && inflight === null) || - (pendingSessions.size >= maxPendingSessions && !pendingSessions.has(sessionId)) - ) { - fullPending = true; - pendingSessions.clear(); - return drain(); - } - if (!fullPending) pendingSessions.add(sessionId); - return drain(); - }, - dispose(): void { - active = false; - inflightTicket += 1; - fullPending = false; - pendingSessions.clear(); - hasSnapshot = false; - }, - }; -} - -export function applyOnboardingSessionUpdate( - snapshot: OnboardingSnapshot, - update: Extract, -): OnboardingSnapshot { - const previous = snapshot.sessionSendOutcomes[update.sessionId]; - const outcomeChanged = update.outcome === null - ? previous !== undefined - : !valuesEqual(previous, update.outcome); - const state = update.defaultHost?.state ?? snapshot.state; - const milestones = update.defaultHost?.milestones ?? snapshot.milestones; - if (!outcomeChanged && valuesEqual(state, snapshot.state) && - valuesEqual(milestones, snapshot.milestones)) return snapshot; - const sessionSendOutcomes = outcomeChanged - ? { ...snapshot.sessionSendOutcomes } - : snapshot.sessionSendOutcomes; - if (outcomeChanged) { - if (update.outcome === null) delete sessionSendOutcomes[update.sessionId]; - else sessionSendOutcomes[update.sessionId] = update.outcome; - } - return { ...snapshot, state, milestones, sessionSendOutcomes }; -} - -/** - * Default renderer binding lives in the Desktop platform adapter. Named - * Session events use targeted reads; connection and Owner profile changes - * request complete snapshots. - * - * Settings changes are NOT subscribed: there is no existing - * settings-wide event channel and PR110c is not inventing one. If a - * settings write changes onboarding state (e.g. user picks a default - * connection via the connection store IPCs), the resulting - * `connections:event` should fire and cover this. - * - * Callers that need a re-pull on a specific UI action (e.g. modal - * close) should call `refresh()` from the returned object. - */ -export function useOnboardingSnapshot(): UseOnboardingSnapshotResult { - // Bind to the live IPC bridge. `deps` is memoized as a module-level - // object so the effect deps stay stable across re-renders. - return useOnboardingSnapshotImpl(desktopOnboardingSnapshotDeps); -} diff --git a/apps/desktop/src/shared/onboarding-snapshot.d.ts b/apps/desktop/src/shared/onboarding-snapshot.d.ts new file mode 100644 index 0000000000..074564267d --- /dev/null +++ b/apps/desktop/src/shared/onboarding-snapshot.d.ts @@ -0,0 +1,42 @@ +/* + * 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 { OnboardingMilestone, OnboardingState } from '@maka/core/onboarding'; +import type { DesktopSessionSummary } from './desktop-session-projection.js'; + +export interface OnboardingSnapshot { + state: OnboardingState; + milestones: OnboardingMilestone[]; + sessions: DesktopSessionSummary[]; + connections: import('@maka/core/llm-connections').ProjectedLlmConnection[]; + defaultSlug: string | null; + chatModelChoices: import('@maka/core/chat-model-choice').ChatModelChoice[]; + sessionSendOutcomes: Record; +} + +export type DesktopOnboardingSessionUpdate = + | { kind: 'resync' } + | { + kind: 'delta'; + sessionId: string; + outcome: import('@maka/core/session-send-projection').SessionSendProjection | null; + defaultHost?: { + state: OnboardingState; + milestones: OnboardingMilestone[]; + }; + }; diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index e3da1be549..db821eede7 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -117,7 +117,6 @@ export const ALLOWED = { useEffect: 1, useLayoutEffect: 1, useNewTaskChoice: 1, - useOnboardingSnapshot: 1, // Replaces `useSessionNavigationController`, which is now called inside // `SessionNavigationProvider`. The entry shrinks rather than disappearing, // because the shell body does read the rail: the command palette lists the From 6d5500928e21d11594392191a6da96c6d12e4b7a Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:52:11 +0800 Subject: [PATCH 4/7] refactor(desktop): give the session catalog its Desktop source The shell's catalog refresh called window.maka.sessions.list itself. The session catalog now takes a SessionCatalogSource (full lists and the change feed), injected at composition from the Desktop session catalog adapter, and the shell refreshes through catalog.source. A catalog built without a source, as in tests and stories, is detached. use-app-shell-session-list.ts loses its sessions.list bridge path. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 6 +- .../__tests__/session-catalog-source.test.ts | 75 +++++++++++++++++++ .../session-catalog/session-catalog-state.ts | 20 ++++- .../composition/desktop-feature-services.tsx | 3 +- .../platform/desktop/session-catalog-sync.ts | 13 +++- .../renderer/use-app-shell-session-list.ts | 2 +- 6 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-catalog-source.test.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 835b76fd96..f5da580b43 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -727,9 +727,7 @@ }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, - "bridgePaths": { - "window.maka.sessions.list": 1 - }, + "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { "useExternalStoreSelector": 1, @@ -752,7 +750,7 @@ "react": 1 }, "importSpecifiers": 5, - "nonTriviaTokens": 481 + "nonTriviaTokens": 479 }, "src/renderer/use-app-shell-session-ui-reads.ts": { "importDeclarations": 0, diff --git a/apps/desktop/src/main/__tests__/session-catalog-source.test.ts b/apps/desktop/src/main/__tests__/session-catalog-source.test.ts new file mode 100644 index 0000000000..70c3070b75 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-catalog-source.test.ts @@ -0,0 +1,75 @@ +/* + * 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 assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { LocaleProvider } from '@maka/ui'; +import type { SessionChangedEvent } from '@maka/core/session'; +import { + createSessionCatalogController, + type SessionCatalogSource, +} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { createDesktopSessionCatalogSource } from '../../renderer/platform/desktop/session-catalog-sync.js'; +import { useAppShellSessionList } from '../../renderer/use-app-shell-session-list.js'; +import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +const summary = (id: string, name = id): DesktopSessionSummary => ({ + id, name, isFlagged: false, isArchived: false, labels: [], + hasUnread: false, status: 'active', backend: 'ai-sdk', + revision: 1, activityAt: 1, runtimeHostId: 'local', profileId: 'local', profileName: 'Local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', + permissionMode: 'ask', profileKind: 'local', +}) as DesktopSessionSummary; + +test('the shell refreshes the catalog through the catalog source, not the bridge', async () => { + const reads: string[] = []; + const source: SessionCatalogSource = { + list: async () => { reads.push('list'); return [summary('a'), summary('b')]; }, + subscribeChanges: () => () => {}, + }; + const catalog = createSessionCatalogController(source); + let list!: ReturnType; + function Probe() { + list = useAppShellSessionList({ error: (title) => assert.fail(title) }, { catalog }); + return null; + } + const { root } = installReactRenderer(); + await act(async () => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(Probe) }))); + await act(async () => { await list.refreshSessions(); }); + assert.deepEqual(reads, ['list']); + assert.deepEqual(catalog.getState().sessions.map((session) => session.id), ['a', 'b']); + await act(async () => root.unmount()); +}); + +test('Desktop backs the catalog source with the Session bridge', async () => { + const calls: string[] = []; + const handler = (_event: SessionChangedEvent) => {}; + const source = createDesktopSessionCatalogSource({ + sessions: { + list: async () => { calls.push('list'); return []; }, + subscribeChanges: (subscribed: typeof handler) => { calls.push(subscribed === handler ? 'subscribe' : 'subscribe:other'); return () => {}; }, + }, + } as unknown as Parameters[0]); + await source.list(); + source.subscribeChanges(handler); + assert.deepEqual(calls, ['list', 'subscribe']); +}); diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts index 5a08672ded..f7b8f2569c 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts @@ -18,6 +18,7 @@ */ import { createContext, useContext, useRef } from 'react'; +import type { SessionChangedEvent } from '@maka/core/session'; import { valuesEqual } from '@maka/ui'; import { compareDesktopSessionCatalogSummaries, @@ -54,7 +55,23 @@ export interface SessionCatalogState { readonly removedIds: ReadonlySet; } -export function createSessionCatalogController() { +/** + * Where the catalog's full lists and change events come from. Desktop + * supplies it at composition, so the shell's catalog refresh and change + * subscription do not reach the Session bridge themselves. + */ +export interface SessionCatalogSource { + list(): Promise; + subscribeChanges(handler: (event: SessionChangedEvent) => void): () => void; +} + +/** A catalog that is only ever committed to, as in tests and stories. */ +const DETACHED_SOURCE: SessionCatalogSource = { + list: async () => [], + subscribeChanges: () => () => {}, +}; + +export function createSessionCatalogController(source: SessionCatalogSource = DETACHED_SOURCE) { const state = createObservableState({ sessions: [], revision: 0, @@ -79,6 +96,7 @@ export function createSessionCatalogController() { }; return { + source, getState: state.getState, subscribe: state.subscribe, isAutomaticQueryBlocked(sessionId: string): boolean { diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index beb1b7aed1..d55536e521 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -64,6 +64,7 @@ import { createSessionCatalogController, SessionCatalogContext, } from '../application/contracts/session-catalog/session-catalog-state.js'; +import { createDesktopSessionCatalogSource } from '../platform/desktop/session-catalog-sync.js'; import { createWorkHubEnablement, WorkHubEnablementProvider, @@ -86,7 +87,7 @@ export function createDesktopFeatureServices() { // service — it is created once with the other app singletons and read // through `useSessionCatalogController` so providers below do not need it // drilled through the shell. - sessionCatalog: createSessionCatalogController(), + sessionCatalog: createSessionCatalogController(createDesktopSessionCatalogSource()), // Client-global, like the catalog: the shell, Workbar, the rail and the // WorkHub dock all follow the same switch. workHubEnablement: createWorkHubEnablement(createDesktopWorkHubEnablementSource()), diff --git a/apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts b/apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts index 79080b61fc..aec56afabd 100644 --- a/apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts +++ b/apps/desktop/src/renderer/platform/desktop/session-catalog-sync.ts @@ -17,7 +17,18 @@ * under the License. */ -import type { DesktopSessionSummary } from '../../../preload/bridge-contract.js'; +import type { DesktopSessionSummary, MakaBridge } from '../../../preload/bridge-contract.js'; +import type { SessionCatalogSource } from '../../application/contracts/session-catalog/session-catalog-state.js'; + +/** The session catalog's Desktop source: full lists and change events. */ +export function createDesktopSessionCatalogSource( + bridge: { sessions: Pick } = window.maka, +): SessionCatalogSource { + return { + list: () => bridge.sessions.list(), + subscribeChanges: (handler) => bridge.sessions.subscribeChanges(handler), + }; +} /** * `sessions:changed` carries the changed row's id, so the hot path reads and 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 0357e48ba5..d0d5d5069d 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-list.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-list.ts @@ -70,7 +70,7 @@ export function useAppShellSessionList( return createSessionListRefresher({ listSessions: () => { observedAtRevision = catalog.getState().revision; - return window.maka.sessions.list(); + return catalog.source.list(); }, currentSessions: () => [...sessionsRef.current], commitSessions: (next) => From 093d29906ef1216c1f8998d3dedba5ff47b51115 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 17:56:50 +0800 Subject: [PATCH 5/7] refactor(desktop): move the root lifecycle's Desktop events behind injected sources app-shell-effects.ts subscribed to seven Desktop bridge paths directly: app.info for the document's platform tag, the window menu, connection events, Host profile changes, Session changes and both settings-change feeds. The reactions stay a root application lifecycle, since they refresh several regions at once, but the environment leaves the shell: - ShellLifecycleSources (application/contracts/shell-lifecycle.ts) is supplied at composition by a Desktop adapter that also writes the data-os tag. - ShellLifecycleSubscriptions is the one subscriber. It takes Session changes from the session catalog's own source. - useAppShellBootstrapSubscriptions keeps the startup refreshes, the hotkeys and the mounted flag, and returns the handlers. Every handler now reads the latest render. The two settings-change handlers used to capture the first render's connection refresh; they now refresh the current connection projections, as the Host-change handler already did. app-shell-effects.ts loses all seven bridge paths and one effect. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 18 +-- .../main/__tests__/shell-lifecycle.test.ts | 133 ++++++++++++++++++ .../desktop/src/renderer/app-shell-effects.ts | 121 ++++++---------- apps/desktop/src/renderer/app-shell.tsx | 4 +- .../application/contracts/shell-lifecycle.ts | 97 +++++++++++++ .../composition/desktop-feature-services.tsx | 6 + .../desktop/create-shell-lifecycle-sources.ts | 53 +++++++ 7 files changed, 338 insertions(+), 94 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/shell-lifecycle.test.ts create mode 100644 apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts create mode 100644 apps/desktop/src/renderer/platform/desktop/create-shell-lifecycle-sources.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f5da580b43..3fe3ec596e 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -455,22 +455,13 @@ }, "src/renderer/app-shell-effects.ts": { "importDeclarations": 5, - "bridgePaths": { - "window.maka.app.info": 1, - "window.maka.appWindow.subscribeCommand": 1, - "window.maka.connections.subscribeEvents": 1, - "window.maka.runtimeHostProfiles.subscribeChanges": 1, - "window.maka.sessions.subscribeChanges": 1, - "window.maka.settings.subscribeClientChanged": 1, - "window.maka.settings.subscribeExternalChanged": 1 - }, + "bridgePaths": {}, "environmentCapabilities": { "cancelAnimationFrame": 1, - "document.documentElement.setAttribute": 1, "requestAnimationFrame": 1 }, "hookCalls": { - "useEffect": 7, + "useEffect": 6, "useHotkeys": 1 }, "lifecycleMethods": {}, @@ -486,7 +477,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 1361 + "nonTriviaTokens": 1140 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -665,6 +656,7 @@ "./application/contracts/conversation-copy": 1, "./application/contracts/onboarding/onboarding-authority.js": 1, "./application/contracts/session-catalog/catalog-row-watch.js": 1, + "./application/contracts/shell-lifecycle.js": 1, "./application/contracts/turn-presentation": 1, "./chat-composer-region": 1, "./chat-message-surface": 1, @@ -723,7 +715,7 @@ "react": 1 }, "importSpecifiers": 71, - "nonTriviaTokens": 8901 + "nonTriviaTokens": 8920 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts b/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts new file mode 100644 index 0000000000..82d32c632a --- /dev/null +++ b/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts @@ -0,0 +1,133 @@ +/* + * 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 assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { act, createElement } from 'react'; +import type { ConnectionEvent } from '@maka/core/connections'; +import type { SessionChangedEvent } from '@maka/core/session'; +import { + ShellLifecycleSourcesProvider, + ShellLifecycleSubscriptions, + type ShellLifecycleHandlers, + type ShellLifecycleSources, + type ShellRuntimeHostChange, + type ShellWindowCommand, +} from '../../renderer/application/contracts/shell-lifecycle.js'; +import { + createSessionCatalogController, + SessionCatalogContext, +} from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { createDesktopShellLifecycleSources } from '../../renderer/platform/desktop/create-shell-lifecycle-sources.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +function fakeSources() { + const listeners = new Map void>>(); + const on = (name: string) => (handler: (event: E) => void) => { + const set = listeners.get(name) ?? new Set(); + set.add(handler as (event: never) => void); + listeners.set(name, set); + return () => { set.delete(handler as (event: never) => void); }; + }; + let tagged = 0; + let untagged = 0; + const sources: ShellLifecycleSources = { + tagDocumentPlatform: () => { tagged += 1; return () => { untagged += 1; }; }, + subscribeWindowCommands: on('window'), + subscribeConnectionEvents: on('connections'), + subscribeRuntimeHostChanges: on('hosts'), + subscribeClientSettingsChanges: on('client'), + subscribeExternalSettingsChanges: on('external'), + }; + const emit = (name: string, event?: unknown) => + [...(listeners.get(name) ?? [])].forEach((handler) => (handler as (event?: unknown) => void)(event)); + const live = () => [...listeners.values()].reduce((total, set) => total + set.size, 0); + return { sources, emit, live, tags: () => [tagged, untagged] }; +} + +test('one subscriber routes each Desktop event to the shell\'s latest handler', async () => { + const { sources, emit, live, tags } = fakeSources(); + let sessionListener: ((event: SessionChangedEvent) => void) | undefined; + const catalog = createSessionCatalogController({ + list: async () => [], + subscribeChanges(handler) { + sessionListener = handler; + return () => { sessionListener = undefined; }; + }, + }); + const calls: string[] = []; + const handlers = (generation: number): ShellLifecycleHandlers => ({ + onWindowCommand: (command) => calls.push(`${generation}:window:${command.id}`), + onConnectionEvent: (event) => calls.push(`${generation}:connection:${event.type}`), + onRuntimeHostChange: (event) => calls.push(`${generation}:host:${event.readiness}`), + onClientSettingsChanged: () => calls.push(`${generation}:client`), + onExternalSettingsChanged: () => calls.push(`${generation}:external`), + onSessionChange: (event) => calls.push(`${generation}:session:${event.sessionId}`), + }); + const render = (generation: number) => createElement(SessionCatalogContext.Provider, { value: catalog, children: + createElement(ShellLifecycleSourcesProvider, { value: sources, children: + createElement(ShellLifecycleSubscriptions, handlers(generation)) }) }); + const { root } = installReactRenderer(); + await act(async () => root.render(render(1))); + assert.deepEqual([live(), Boolean(sessionListener), tags()], [5, true, [1, 0]]); + await act(async () => root.render(render(2))); + assert.deepEqual([live(), tags()], [5, [1, 0]], 'a new render does not resubscribe'); + emit('window', { id: 'newTask' } satisfies ShellWindowCommand); + emit('connections', { type: 'connection_list_changed' } as ConnectionEvent); + emit('hosts', { readiness: 'ready', isDefault: true } satisfies ShellRuntimeHostChange); + emit('client'); + emit('external'); + sessionListener?.({ sessionId: 'a', reason: 'updated', ts: 1 } as SessionChangedEvent); + assert.deepEqual(calls, [ + '2:window:newTask', '2:connection:connection_list_changed', '2:host:ready', + '2:client', '2:external', '2:session:a', + ]); + await act(async () => root.unmount()); + assert.deepEqual([live(), Boolean(sessionListener), tags()], [0, false, [1, 1]]); +}); + +test('Desktop supplies the lifecycle events and tags the document; the effects reach no bridge', async () => { + const subscribed: string[] = []; + const attributes: Array<[string, string]> = []; + const listen = (name: string) => () => { subscribed.push(name); return () => {}; }; + const sources = createDesktopShellLifecycleSources({ + app: { info: async () => ({ platform: 'darwin' }) }, + appWindow: { subscribeCommand: listen('window') }, + connections: { subscribeEvents: listen('connections') }, + runtimeHostProfiles: { subscribeChanges: listen('hosts') }, + settings: { subscribeClientChanged: listen('client'), subscribeExternalChanged: listen('external') }, + } as unknown as Parameters[0], { + setAttribute: (name: string, value: string) => { attributes.push([name, value]); }, + }); + sources.subscribeWindowCommands(() => {}); + sources.subscribeConnectionEvents(() => {}); + sources.subscribeRuntimeHostChanges(() => {}); + sources.subscribeClientSettingsChanges(() => {}); + sources.subscribeExternalSettingsChanges(() => {}); + assert.deepEqual(subscribed, ['window', 'connections', 'hosts', 'client', 'external']); + sources.tagDocumentPlatform(); + sources.tagDocumentPlatform()(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(attributes, [['data-os', 'darwin']], 'a cancelled tag never writes'); + const effects = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell-effects.ts', import.meta.url)), 'utf8'); + assert.deepEqual(effects.split('\n').filter((line) => /\bwindow\.maka\b/.test(line)), []); +}); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 2f824de62c..911dfaf394 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -20,7 +20,7 @@ import { useEffect, useEffectEvent } from 'react'; import { useHotkeys } from '@astryxdesign/core/hooks'; import type { ConnectionEvent } from '@maka/core/connections'; -import type { SessionChangedEvent, SessionSummary } from '@maka/core/session'; +import type { SessionSummary } from '@maka/core/session'; import type { ThemePalette, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from '@maka/ui'; @@ -30,10 +30,7 @@ import { startTitlebarModalSync } from './titlebar-modal-sync'; import { safeLocalStorageSet } from './browser-storage'; import type { NavigationState } from './nav-selection.js'; import { handleSessionChangedEvent } from './application/contracts/session-catalog/session-change-effects.js'; -import type { - DesktopRuntimeHostProfileChangedEvent, - WindowCommand, -} from '../preload/bridge-contract.js'; +import type { ShellLifecycleHandlers } from './application/contracts/shell-lifecycle.js'; type RefBox = { current: T }; @@ -61,27 +58,7 @@ export function useAppShellNavRefSync(options: { navSelection: NavSelection; nav } export function useAppShellHostEffects() { - // Tag the document with the host OS so glass-material CSS rules - // (sidebar vibrancy passthrough) - // can light up only on macOS, where `BrowserWindow({ vibrancy: 'sidebar' })` - // paints the native blur material behind the renderer. Other platforms - // keep their opaque chrome since vibrancy is a no-op there. - useEffect(() => { - let cancelled = false; - void window.maka.app - .info() - .then((info) => { - if (cancelled) return; - document.documentElement.setAttribute('data-os', info.platform); - }) - .catch(() => { - /* swallow — leaves data-os unset, CSS falls back to opaque chrome */ - }); - return () => { - cancelled = true; - }; - }, []); - + // The host-OS tag on the document is applied by ShellLifecycleSubscriptions. // Modal-open titlebar dimming/hiding is driven by observing the top layer // (`dialog:modal`) rather than the shell's own modal state, so dialogs // mounted deep in module pages — the scheduled-task form above all — are @@ -149,50 +126,15 @@ export function useAppShellBootstrapSubscriptions(options: { sessionsRef: RefBox; recordSessionChange(sessionId: string, ts: number): void; toastApi: ToastApi; -}) { +}): ShellLifecycleHandlers { const runDeferredStartupRefreshes = useEffectEvent(() => { void options.bootstrapSessions(); void options.applyE2eFixture(); }); - const handleConnectionSubscriptionEvent = useEffectEvent((event: ConnectionEvent) => { - options.handleConnectionEvent(event); - }); const refreshRuntimeHostSettingsMirrors = () => { void options.refreshShellSettings(); void options.refreshConnections(); }; - const handleRuntimeHostChange = useEffectEvent((event: DesktopRuntimeHostProfileChangedEvent) => { - void options.refreshSessions().then(() => { - options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); - }); - if (event.readiness !== 'ready') return; - if (!event.isDefault) return; - refreshRuntimeHostSettingsMirrors(); - void options.refreshProjects(); - void options.refreshMemoryActive('load'); - }); - // PR-2088: the macOS application menu routes New Task / Settings / Keyboard - // Shortcuts here through one channel. The renderer already owns these - // implementations; the menu is only a second entry surface. The keydown - // path (useHotkeys below) stays active on every platform: on macOS AppKit - // resolves the menu accelerator before the web contents sees the keydown, - // so a real keypress dispatches exactly once, while CDP-injected test keys - // still reach this handler for the renderer path. - const handleWindowCommand = useEffectEvent((command: WindowCommand) => { - if (command.id === 'newTask') void options.createSession(); - else if (command.id === 'openSettings') options.openSettings(); - else if (command.id === 'openHelp') options.openHelp(); - }); - const handleSessionChange = useEffectEvent( - (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 // the hook's default "stay silent while typing" rule. @@ -234,31 +176,50 @@ export function useAppShellBootstrapSubscriptions(options: { // `refreshShellSettings` is // waited because it drives theme + locale before first paint settles. // Everything else is fire-and-forget on a rAF to keep the critical - // render path as short as possible. + // render path as short as possible. ShellLifecycleSubscriptions holds the + // event subscriptions for the same lifetime. void options.refreshShellSettings(); // Non-critical: defer to next frame so the first paint isn't blocked. const startupFrame = requestAnimationFrame(runDeferredStartupRefreshes); - const unsubscribeConnections = window.maka.connections.subscribeEvents(handleConnectionSubscriptionEvent); - const unsubscribeRuntimeHostChanges = - window.maka.runtimeHostProfiles.subscribeChanges(handleRuntimeHostChange); - const unsubscribeSettingsExternal = window.maka.settings.subscribeExternalChanged( - refreshRuntimeHostSettingsMirrors, - ); - const unsubscribeClientSettings = window.maka.settings.subscribeClientChanged( - () => void options.refreshShellSettings(), - ); - const unsubscribeSessionChanges = window.maka.sessions.subscribeChanges(handleSessionChange); - const unsubscribeWindowCommand = window.maka.appWindow.subscribeCommand(handleWindowCommand); markRendererMounted(); return () => { cancelAnimationFrame(startupFrame); cleanupPendingRefs(); - unsubscribeConnections(); - unsubscribeRuntimeHostChanges(); - unsubscribeSettingsExternal(); - unsubscribeClientSettings(); - unsubscribeSessionChanges(); - unsubscribeWindowCommand(); }; }, []); + return { + onConnectionEvent: options.handleConnectionEvent, + onRuntimeHostChange(event) { + void options.refreshSessions().then(() => { + options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); + }); + if (event.readiness !== 'ready') return; + if (!event.isDefault) return; + refreshRuntimeHostSettingsMirrors(); + void options.refreshProjects(); + void options.refreshMemoryActive('load'); + }, + onExternalSettingsChanged: refreshRuntimeHostSettingsMirrors, + onClientSettingsChanged: () => void options.refreshShellSettings(), + onSessionChange: (event) => + handleSessionChangedEvent(event, { + ...options, + notifyModelRebound: (modelId) => { + const copy = getDesktopConversationCopy(options.uiLocale).actions; + options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(modelId)); + }, + }), + // PR-2088: the macOS application menu routes New Task / Settings / Keyboard + // Shortcuts here through one channel. The renderer already owns these + // implementations; the menu is only a second entry surface. The keydown + // path (useHotkeys above) stays active on every platform: on macOS AppKit + // resolves the menu accelerator before the web contents sees the keydown, + // so a real keypress dispatches exactly once, while CDP-injected test keys + // still reach this handler for the renderer path. + onWindowCommand(command) { + if (command.id === 'newTask') void options.createSession(); + else if (command.id === 'openSettings') options.openSettings(); + else if (command.id === 'openHelp') options.openHelp(); + }, + }; } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 18b9bf97d2..d7c5500e5a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -97,6 +97,7 @@ import { OnboardingProjectionRoot, type OnboardingShellProjection, } from './application/contracts/onboarding/onboarding-authority.js'; +import { ShellLifecycleSubscriptions } from './application/contracts/shell-lifecycle.js'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; @@ -1197,7 +1198,7 @@ function AppShellContent({ navSelectionRef, }); useAppShellHostEffects(); - useAppShellBootstrapSubscriptions({ + const shellLifecycle = useAppShellBootstrapSubscriptions({ uiLocale, activeIdRef, applyE2eFixture, @@ -1539,6 +1540,7 @@ function AppShellContent({ })} > + { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} /> void; + subscribeWindowCommands(handler: (command: ShellWindowCommand) => void): () => void; + subscribeConnectionEvents(handler: (event: ConnectionEvent) => void): () => void; + subscribeRuntimeHostChanges(handler: (event: ShellRuntimeHostChange) => void): () => void; + subscribeClientSettingsChanges(handler: () => void): () => void; + subscribeExternalSettingsChanges(handler: () => void): () => void; +} + +/** What the shell does with each event; Session changes come from the catalog's own feed. */ +export interface ShellLifecycleHandlers { + onWindowCommand(command: ShellWindowCommand): void; + onConnectionEvent(event: ConnectionEvent): void; + onRuntimeHostChange(event: ShellRuntimeHostChange): void; + onClientSettingsChanged(): void; + onExternalSettingsChanged(): void; + onSessionChange(event: SessionChangedEvent): void; +} + +const DETACHED: ShellLifecycleSources = { + tagDocumentPlatform: () => () => {}, + subscribeWindowCommands: () => () => {}, + subscribeConnectionEvents: () => () => {}, + subscribeRuntimeHostChanges: () => () => {}, + subscribeClientSettingsChanges: () => () => {}, + subscribeExternalSettingsChanges: () => () => {}, +}; +const SourcesContext = createContext(DETACHED); +export const ShellLifecycleSourcesProvider = SourcesContext.Provider; + +/** Subscribes for the shell's lifetime; each event reaches the shell's latest handler. */ +export function ShellLifecycleSubscriptions(handlers: ShellLifecycleHandlers) { + const sources = useContext(SourcesContext); + const catalog = useSessionCatalogController(); + const onWindowCommand = useEffectEvent(handlers.onWindowCommand); + const onConnectionEvent = useEffectEvent(handlers.onConnectionEvent); + const onRuntimeHostChange = useEffectEvent(handlers.onRuntimeHostChange); + const onClientSettingsChanged = useEffectEvent(handlers.onClientSettingsChanged); + const onExternalSettingsChanged = useEffectEvent(handlers.onExternalSettingsChanged); + const onSessionChange = useEffectEvent(handlers.onSessionChange); + useEffect(() => sources.tagDocumentPlatform(), [sources]); + useEffect(() => { + const unsubscribes = [ + sources.subscribeConnectionEvents(onConnectionEvent), + sources.subscribeRuntimeHostChanges(onRuntimeHostChange), + sources.subscribeExternalSettingsChanges(onExternalSettingsChanged), + sources.subscribeClientSettingsChanges(onClientSettingsChanged), + catalog.source.subscribeChanges(onSessionChange), + sources.subscribeWindowCommands(onWindowCommand), + ]; + return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); + }, [catalog, sources]); + return null; +} diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index d55536e521..9e37ea2ad3 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -75,6 +75,8 @@ import { OnboardingAuthorityProvider, } from '../application/contracts/onboarding/onboarding-authority.js'; import { createDesktopOnboardingSource } from '../platform/desktop/create-onboarding-source.js'; +import { ShellLifecycleSourcesProvider } from '../application/contracts/shell-lifecycle.js'; +import { createDesktopShellLifecycleSources } from '../platform/desktop/create-shell-lifecycle-sources.js'; if (import.meta.env.DEV) { const stopObserving = observeReactPerformanceMeasures(); @@ -93,6 +95,8 @@ export function createDesktopFeatureServices() { workHubEnablement: createWorkHubEnablement(createDesktopWorkHubEnablementSource()), // First-run state is read by the shell, the rail and composer readiness. onboarding: createOnboardingAuthority(createDesktopOnboardingSource()), + // The root lifecycle's Desktop events; only ShellLifecycleSubscriptions reads them. + shellLifecycle: createDesktopShellLifecycleSources(), appUpdate: createDesktopAppUpdateServices(), clientPlugins: createDesktopClientPluginServices(), diagnostics: createDesktopDiagnosticsServices(), @@ -124,6 +128,7 @@ export function DesktopFeatureServicesProvider(props: { + @@ -167,6 +172,7 @@ export function DesktopFeatureServicesProvider(props: { + diff --git a/apps/desktop/src/renderer/platform/desktop/create-shell-lifecycle-sources.ts b/apps/desktop/src/renderer/platform/desktop/create-shell-lifecycle-sources.ts new file mode 100644 index 0000000000..b5e6317710 --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-shell-lifecycle-sources.ts @@ -0,0 +1,53 @@ +/* + * 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 { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { ShellLifecycleSources } from '../../application/contracts/shell-lifecycle.js'; + +export type DesktopShellLifecycleBridge = Pick; + +/** The root lifecycle's Desktop events, and the platform tag on the document. */ +export function createDesktopShellLifecycleSources( + bridge: DesktopShellLifecycleBridge = window.maka, + root: Pick = document.documentElement, +): ShellLifecycleSources { + return { + // Glass-material CSS (sidebar vibrancy passthrough) lights up only on + // macOS, where `BrowserWindow({ vibrancy: 'sidebar' })` paints the native + // blur behind the renderer; other platforms keep their opaque chrome. + tagDocumentPlatform() { + let cancelled = false; + void bridge.app + .info() + .then((info) => { + if (!cancelled) root.setAttribute('data-os', info.platform); + }) + .catch(() => { + /* swallow — leaves data-os unset, CSS falls back to opaque chrome */ + }); + return () => { + cancelled = true; + }; + }, + subscribeWindowCommands: (handler) => bridge.appWindow.subscribeCommand(handler), + subscribeConnectionEvents: (handler) => bridge.connections.subscribeEvents(handler), + subscribeRuntimeHostChanges: (handler) => bridge.runtimeHostProfiles.subscribeChanges(handler), + subscribeClientSettingsChanges: (handler) => bridge.settings.subscribeClientChanged(handler), + subscribeExternalSettingsChanges: (handler) => bridge.settings.subscribeExternalChanged(handler), + }; +} From 479ec0ecfac965d11a3af7d205331a7478490f23 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 19:23:14 +0800 Subject: [PATCH 6/7] refactor(desktop): read turn landmarks through Conversation services AppShell handed ConversationLifecycle an inline window.maka.sessions.listTurnLandmarks reader. Declare listTurnLandmarks on ConversationServices.sessions; the Desktop adapter already spreads the bridge's sessions namespace, so it supplies the function unchanged. The lifecycle passes it to the reading-position controller, and its prop is gone, so AppShell can no longer supply a reader. The controller never listed the reader as an effect dependency, so its identity becoming stable changes nothing. app-shell.tsx loses its last direct bridge path. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 6 ++---- .../main/__tests__/conversation-compaction.test.ts | 2 +- .../src/main/__tests__/conversation-owner.test.ts | 12 +++++++----- .../__tests__/session-reference-composer.test.ts | 1 + .../src/main/__tests__/shell-lifecycle.test.ts | 5 +++++ apps/desktop/src/renderer/app-shell.tsx | 1 - .../src/renderer/features/conversation/ports.ts | 5 +++++ .../src/renderer/features/conversation/testing.ts | 1 + .../conversation/ui/conversation-lifecycle.tsx | 3 +-- apps/desktop/stories/composer-slash-menu.stories.tsx | 1 + 10 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 3fe3ec596e..8b2ba8b101 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -604,9 +604,7 @@ }, "src/renderer/app-shell.tsx": { "importDeclarations": 49, - "bridgePaths": { - "window.maka.sessions.listTurnLandmarks": 1 - }, + "bridgePaths": {}, "environmentCapabilities": { "window.requestAnimationFrame": 3 }, @@ -715,7 +713,7 @@ "react": 1 }, "importSpecifiers": 71, - "nonTriviaTokens": 8920 + "nonTriviaTokens": 8898 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts index a4af12465b..c8d4e150f1 100644 --- a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts @@ -165,7 +165,7 @@ describe('Conversation owner context compaction', () => { return createElement(Fragment, null, createElement(ConversationLifecycle, { refreshSessions: async () => [], onExecutionBoundaryChanged() {}, showModelSetupToast() {}, onTurnCompleted() {}, - searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: async () => ({ landmarks: [] }), + searchTarget: null, clearSearchTarget() {}, })); } act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: diff --git a/apps/desktop/src/main/__tests__/conversation-owner.test.ts b/apps/desktop/src/main/__tests__/conversation-owner.test.ts index 400442c59e..2fb6906cb3 100644 --- a/apps/desktop/src/main/__tests__/conversation-owner.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-owner.test.ts @@ -19,13 +19,13 @@ import assert from 'node:assert/strict'; import { afterEach, describe, it } from 'node:test'; -import { act, createElement, Fragment, Profiler, useEffect, useState, type ComponentProps } from 'react'; +import { act, createElement, Fragment, Profiler, useEffect, useState } from 'react'; import { LocaleProvider, ToastProvider, type TransientUserMessageProjection } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; -import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, ConversationComposerRegion, useAppShellSessionUiState, type ConversationObservationServices } from '../../renderer/features/conversation/index.js'; +import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, ConversationComposerRegion, useAppShellSessionUiState, type ConversationObservationServices, type ConversationServices } from '../../renderer/features/conversation/index.js'; import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; @@ -40,7 +40,7 @@ const message = (id: string): StoredMessage => ({ type: 'user', id, text: id, tu function harness(options: { locale?: UiLocale; hasOlder?: boolean; - listTurnLandmarks?: ComponentProps['listTurnLandmarks']; + listTurnLandmarks?: ConversationServices['sessions']['listTurnLandmarks']; } = {}) { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); @@ -50,7 +50,9 @@ function harness(options: { publish(messages: StoredMessage[]): void; error(error: unknown): void; }> = []; const observations: Array<{ sessionId: string; closed: boolean; phase: Parameters[2]; fail: () => void }> = []; - const services = stubConversationServices(); + const services = stubConversationServices( + options.listTurnLandmarks ? { sessions: { listTurnLandmarks: options.listTurnLandmarks } } : {}, + ); services.observation.openTranscript = (sessionId, error) => { let messages: StoredMessage[] = []; let ready = false; @@ -108,7 +110,7 @@ function harness(options: { createElement(Profiler, { id: 'conversation-lifecycle', onRender: () => { lifecycleCommits += 1; } }, createElement(ConversationLifecycle, { refreshSessions: async () => [], onExecutionBoundaryChanged() {}, showModelSetupToast() {}, onTurnCompleted() {}, - searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: options.listTurnLandmarks ?? (async () => ({ landmarks: [] })), + searchTarget: null, clearSearchTarget() {}, })), visible ? createElement(ConversationTranscriptRegion[0]>, { surface: Transcript }) : null, createElement(ConversationComposerRegion[0]>, { surface: Composer }), diff --git a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts index dafe8e8593..f2c4d09ae0 100644 --- a/apps/desktop/src/main/__tests__/session-reference-composer.test.ts +++ b/apps/desktop/src/main/__tests__/session-reference-composer.test.ts @@ -62,6 +62,7 @@ const queueStubs = { retractQueueEntry: async () => undefined, reorderQueueEntries: async () => undefined, compact: async () => { throw new Error('Context compaction is not used in reference tests'); }, + listTurnLandmarks: async () => ({ landmarks: [] }), }; afterEach(async () => { diff --git a/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts b/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts index 82d32c632a..6d9d0312e3 100644 --- a/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts @@ -131,3 +131,8 @@ test('Desktop supplies the lifecycle events and tags the document; the effects r const effects = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell-effects.ts', import.meta.url)), 'utf8'); assert.deepEqual(effects.split('\n').filter((line) => /\bwindow\.maka\b/.test(line)), []); }); + +test('AppShell itself reaches no Desktop bridge path', () => { + const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); + assert.deepEqual(shell.split('\n').filter((line) => /\bwindow\.maka\b/.test(line)), []); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d7c5500e5a..3e0799bc74 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1548,7 +1548,6 @@ function AppShellContent({ showModelSetupToast={showModelSetupToast} onTurnCompleted={(sessionId) => { if (activeIdRef.current === sessionId) setPetCompletionNonce((current) => current + 1); }} searchTarget={searchScrollTarget} clearSearchTarget={() => setSearchScrollTarget(null)} - listTurnLandmarks={(sessionId, turnId) => window.maka.sessions.listTurnLandmarks(sessionId, turnId)} /> {/* Window chrome is frame-level hit-test only (not AppShell topNav): a transparent drag overlay so column surfaces paint to the window top. diff --git a/apps/desktop/src/renderer/features/conversation/ports.ts b/apps/desktop/src/renderer/features/conversation/ports.ts index 92c1a26c24..d85d5a61d8 100644 --- a/apps/desktop/src/renderer/features/conversation/ports.ts +++ b/apps/desktop/src/renderer/features/conversation/ports.ts @@ -77,6 +77,11 @@ export interface ConversationServices extends Pick< expectedQueueRevision: number, ): Promise; compact(sessionId: string): Promise; + /** Sampled prompt-rail landmarks, or where the one Turn `turnId` sits. */ + listTurnLandmarks( + sessionId: string, + turnId: string | null, + ): Promise<{ readonly landmarks: readonly import('./controller/transcript-reading-position-controller.js').TranscriptTurnLandmark[] }>; }; readonly runtimeHosts: { subscribeChanges(handler: (event: ConversationHostChange) => void): () => void; diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 6593c627ab..79f6244fcf 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -93,6 +93,7 @@ export function stubConversationServices( compact: async () => { throw new Error('Context compaction is not stubbed'); }, + listTurnLandmarks: async () => ({ landmarks: [] }), ...sessions, }, }; diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx index 45fba46da3..ad1373e185 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-lifecycle.tsx @@ -43,7 +43,6 @@ export function ConversationLifecycle(props: { onTurnCompleted(sessionId: string): void; searchTarget: { sessionId: string; turnId: string; nonce?: number } | null; clearSearchTarget(): void; - listTurnLandmarks: React.ComponentProps['listTurnLandmarks']; }) { const { workspace, commands, readingCommands, events, interactionHydration } = useConversationOwner(); const { ui, activeIdRef, transcriptRangeRef } = workspace; @@ -146,7 +145,7 @@ export function ConversationLifecycle(props: { currentSessionId={activeIdRef} rangeController={transcriptRangeRef} messages={view.messages} searchTarget={props.searchTarget} clearSearchTarget={props.clearSearchTarget} sessionUi={ui} landmarkSessionId={displayed?.shared || displayed?.localState === 'pending' ? null : displayed?.id ?? null} - listTurnLandmarks={props.listTurnLandmarks} setTurnIndex={workspace.setTurnIndex} + listTurnLandmarks={services.sessions.listTurnLandmarks} setTurnIndex={workspace.setTurnIndex} onRestoreError={(error, sessionId) => ui.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: transcriptErrorMessage(error, uiLocale, 'restore') }))} /> diff --git a/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index 168870f5b8..ff3c4876af 100644 --- a/apps/desktop/stories/composer-slash-menu.stories.tsx +++ b/apps/desktop/stories/composer-slash-menu.stories.tsx @@ -171,6 +171,7 @@ const conversationServices: ConversationServices = { compact: async () => { throw new Error('Context compaction is not used in slash menu stories'); }, + listTurnLandmarks: async () => ({ landmarks: [] }), }, runtimeHosts: { subscribeChanges: () => () => undefined }, skills: { listInvocable: loadProjection }, From deeff8d6b9c578c2c9799188cc24dc5ebf4b2718 Mon Sep 17 00:00:00 2001 From: chihumyum Date: Fri, 2 Oct 2026 22:10:48 +0800 Subject: [PATCH 7/7] refactor(desktop): require the root authorities' providers and test the lifecycle handlers Review follow-up for #5936: - The onboarding, WorkHub-enablement and shell-lifecycle contexts throw when their provider is missing, as createServicesContext does, instead of falling back to inert values that would, for example, hold the first-run gate closed. A catalog built without a source still accepts commits but fails when read through that source. Tests pass explicit fakes. - The event-to-reaction mapping moves into the pure createShellLifecycleHandlers beside ShellLifecycleHandlers, so it is unit-tested without loading app-shell-effects.ts. - AppShell imports WorkHubEnablementWatch from its contract; the WorkHub feature entry no longer re-exports it. Refs #4582 Generated-by: Claude Opus 5.5 --- apps/desktop/renderer-architecture.json | 8 +-- .../__tests__/onboarding-authority.test.ts | 6 ++ .../__tests__/session-catalog-source.test.ts | 6 ++ .../session-navigation-controller.test.ts | 41 ++++++----- .../main/__tests__/shell-lifecycle.test.ts | 58 +++++++++++++++ .../main/__tests__/workbar-controller.test.ts | 6 +- .../__tests__/workbar-provider-scope.test.ts | 17 +++-- .../main/__tests__/workhub-enablement.test.ts | 6 ++ .../desktop/src/renderer/app-shell-effects.ts | 46 +----------- apps/desktop/src/renderer/app-shell.tsx | 3 +- .../onboarding/onboarding-authority.ts | 22 +++--- .../session-catalog/session-catalog-state.ts | 7 +- .../application/contracts/shell-lifecycle.ts | 71 ++++++++++++++++--- .../workhub-workspace/workhub-enablement.ts | 12 ++-- .../src/renderer/features/workhub/index.ts | 1 - 15 files changed, 209 insertions(+), 101 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8b2ba8b101..48006dd797 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -468,8 +468,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./application/contracts/conversation-copy.js": 1, - "./application/contracts/session-catalog/session-change-effects.js": 1, + "./application/contracts/shell-lifecycle.js": 1, "./browser-storage": 1, "./theme": 1, "./titlebar-modal-sync": 1, @@ -477,7 +476,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 1140 + "nonTriviaTokens": 886 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -656,6 +655,7 @@ "./application/contracts/session-catalog/catalog-row-watch.js": 1, "./application/contracts/shell-lifecycle.js": 1, "./application/contracts/turn-presentation": 1, + "./application/contracts/workhub-workspace/workhub-enablement.js": 1, "./chat-composer-region": 1, "./chat-message-surface": 1, "./composer-defaults": 1, @@ -713,7 +713,7 @@ "react": 1 }, "importSpecifiers": 71, - "nonTriviaTokens": 8898 + "nonTriviaTokens": 8903 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/onboarding-authority.test.ts b/apps/desktop/src/main/__tests__/onboarding-authority.test.ts index 1de907ab2e..d20a634dfa 100644 --- a/apps/desktop/src/main/__tests__/onboarding-authority.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-authority.test.ts @@ -126,6 +126,12 @@ describe('createOnboardingAuthority', () => { assert.equal(fake.calls.at(-1), 'unsubscribe', 'the root is the subscriber that keeps the reads alive'); }); + it('fails without its provider instead of holding the first-run gate closed', () => { + const { root } = installReactRenderer(); + assert.throws(() => act(() => root.render(createElement(OnboardingProjectionRoot, { children: () => null }))), + /OnboardingAuthorityProvider is missing/); + }); + it('re-pulls after a skip lands, and not after a skip fails', async () => { let skipFails = false; const fake = source({ diff --git a/apps/desktop/src/main/__tests__/session-catalog-source.test.ts b/apps/desktop/src/main/__tests__/session-catalog-source.test.ts index 70c3070b75..7ceb6b2a4d 100644 --- a/apps/desktop/src/main/__tests__/session-catalog-source.test.ts +++ b/apps/desktop/src/main/__tests__/session-catalog-source.test.ts @@ -73,3 +73,9 @@ test('Desktop backs the catalog source with the Session bridge', async () => { source.subscribeChanges(handler); assert.deepEqual(calls, ['list', 'subscribe']); }); + +test('a catalog built without a source cannot be read through it', async () => { + const catalog = createSessionCatalogController(); + await assert.rejects(catalog.source.list(), /created without a source/); + assert.throws(() => catalog.source.subscribeChanges(() => {}), /created without a source/); +}); 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 fcbd7587ab..5ed13e42f5 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -213,10 +213,12 @@ function navigationTree( getSnapshot: () => EMPTY_STREAMING_SESSIONS, subscribe: () => () => undefined, }, + authorities: { workHub?: WorkHubEnablement; onboarding?: OnboardingAuthority } = {}, ) { return createElement(LocaleProvider, { locale: 'en', - children: createElement( + children: createElement(WorkHubEnablementProvider, { value: authorities.workHub ?? WORKHUB_OFF }, + createElement(OnboardingAuthorityProvider, { value: authorities.onboarding ?? onboardingWith(null) }, createElement( SessionNavigationServicesProvider, { services: fakeServices }, sibling, @@ -239,10 +241,20 @@ function navigationTree( }, child, ), - ), + ))), }); } +const WORKHUB_OFF: WorkHubEnablement = { isEnabled: () => false, subscribe: () => () => {} }; +function onboardingWith(snapshot: OnboardingSnapshot | null): OnboardingAuthority { + return { + getProjection: () => ({ snapshot, failed: false }), + subscribe: () => () => {}, + refresh: () => {}, + skipInitialOnboarding: async () => {}, + }; +} + const linkedCatalog = [ session('root', { projectId: 'project', cwd: '/repo' }), session('child', { @@ -628,20 +640,14 @@ describe('SessionNavigationProvider selection', () => { rail = useSessionRailData(); return null; } - const snapshot = { + const onboarding = onboardingWith({ sessionSendOutcomes: { remote: { kind: 'blocked', reason: 'connection_missing', connectionLocked: false } }, - } as unknown as OnboardingSnapshot; - const onboarding: OnboardingAuthority = { - getProjection: () => ({ snapshot, failed: false }), - subscribe: () => () => {}, - refresh: () => {}, - skipInitialOnboarding: async () => {}, - }; + } as unknown as OnboardingSnapshot); const catalog = createSessionCatalogController(); catalog.commitSessions(linkedCatalog); const { root } = installReactRenderer(); - await act(async () => root.render(createElement(OnboardingAuthorityProvider, { value: onboarding }, - navigationTree(catalog, { activeSessionId: 'root', workHubActive: false }, null, createElement(Rail))))); + await act(async () => root.render(navigationTree(catalog, { activeSessionId: 'root', workHubActive: false }, + null, createElement(Rail), undefined, { onboarding }))); assert.deepEqual([...(rail?.staleSessionIds ?? [])], ['remote']); await act(async () => root.unmount()); }); @@ -665,12 +671,11 @@ describe('SessionNavigationProvider selection', () => { const catalog = createSessionCatalogController(); catalog.commitSessions(linkedCatalog); const { root } = installReactRenderer(); - await act(async () => root.render(createElement(WorkHubEnablementProvider, { value: enablement }, - navigationTree(catalog, { - activeSessionId: 'root', - workHubActive: false, - workHubEntry: { active: false, label: 'WorkHub', onSelect: () => opened.push('workhub') }, - }, null, createElement(ChromeProbe))))); + await act(async () => root.render(navigationTree(catalog, { + activeSessionId: 'root', + workHubActive: false, + workHubEntry: { active: false, label: 'WorkHub', onSelect: () => opened.push('workhub') }, + }, null, createElement(ChromeProbe), undefined, { workHub: enablement }))); const workHubEntry = () => chrome?.workHubEntry; assert.equal(workHubEntry(), undefined); enabled = true; diff --git a/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts b/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts index 6d9d0312e3..0d729113c8 100644 --- a/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts @@ -24,6 +24,7 @@ import { act, createElement } from 'react'; import type { ConnectionEvent } from '@maka/core/connections'; import type { SessionChangedEvent } from '@maka/core/session'; import { + createShellLifecycleHandlers, ShellLifecycleSourcesProvider, ShellLifecycleSubscriptions, type ShellLifecycleHandlers, @@ -136,3 +137,60 @@ test('AppShell itself reaches no Desktop bridge path', () => { const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); assert.deepEqual(shell.split('\n').filter((line) => /\bwindow\.maka\b/.test(line)), []); }); + +test('the shell reacts to each lifecycle event with the same refreshes as before', async () => { + const calls: string[] = []; + const record = (name: string) => () => { calls.push(name); return Promise.resolve(); }; + const handlers = createShellLifecycleHandlers({ + uiLocale: 'en', + activeIdRef: { current: undefined }, + clearPendingTurnActionsForSession: () => {}, + createSession: () => { calls.push('createSession'); }, + handleConnectionEvent: (event) => { calls.push(`connection:${event.type}`); }, + openHelp: () => { calls.push('openHelp'); }, + openSettings: () => { calls.push('openSettings'); }, + refreshConnections: record('refreshConnections'), + refreshMemoryActive: record('refreshMemoryActive'), + refreshMessages: async () => true, + refreshProjects: record('refreshProjects'), + refreshShellSettings: record('refreshShellSettings'), + refreshSessions: async () => { calls.push('refreshSessions'); return []; }, + refreshChangedSession: async (sessionId) => { calls.push(`refreshChangedSession:${sessionId}`); }, + retireSession: (sessionId) => { calls.push(`retire:${sessionId}`); }, + retiredSessionIds: () => ['gone'], + isSessionRemoved: () => false, + sessionsRef: { current: [] }, + recordSessionChange: () => {}, + toastApi: { info() {} }, + }); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + for (const id of ['newTask', 'openSettings', 'openHelp'] as const) handlers.onWindowCommand({ id }); + handlers.onConnectionEvent({ type: 'connection_list_changed' } as ConnectionEvent); + handlers.onClientSettingsChanged(); + handlers.onExternalSettingsChanged(); + handlers.onSessionChange({ sessionId: 'a', reason: 'updated', ts: 1 } as SessionChangedEvent); + await settle(); + assert.deepEqual(calls.splice(0), [ + 'createSession', 'openSettings', 'openHelp', 'connection:connection_list_changed', + 'refreshShellSettings', 'refreshShellSettings', 'refreshConnections', 'refreshChangedSession:a', + ]); + handlers.onRuntimeHostChange({ readiness: 'connecting', isDefault: true }); + await settle(); + assert.deepEqual(calls.splice(0), ['refreshSessions', 'retire:gone'], 'a Host that is not ready only refreshes Sessions'); + handlers.onRuntimeHostChange({ readiness: 'ready', isDefault: true }); + await settle(); + assert.deepEqual(calls.splice(0), [ + 'refreshSessions', 'refreshShellSettings', 'refreshConnections', 'refreshProjects', 'refreshMemoryActive', 'retire:gone', + ]); +}); + +test('a composition without the lifecycle sources fails instead of going quiet', () => { + const { root } = installReactRenderer(); + const handler = () => {}; + const catalog = createSessionCatalogController({ list: async () => [], subscribeChanges: () => () => {} }); + assert.throws(() => act(() => root.render(createElement(SessionCatalogContext.Provider, { value: catalog, children: + createElement(ShellLifecycleSubscriptions, { + onWindowCommand: handler, onConnectionEvent: handler, onRuntimeHostChange: handler, + onClientSettingsChanged: handler, onExternalSettingsChanged: handler, onSessionChange: handler, + }) }))), /ShellLifecycleSourcesProvider is missing/); +}); diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 8fa1cbd896..e000448e4b 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -118,6 +118,8 @@ function ControllerProbe(props: ControllerProbeInput) { const connectedServices = new WeakSet(); +const WORKHUB_OFF = { isEnabled: () => false, subscribe: () => () => {} }; + function renderController( root: ReturnType['root'], services: WorkbarServices, @@ -143,7 +145,7 @@ function renderController( children: createElement( WorkbarServicesProvider, { services }, - createElement(ControllerProbe, input), + createElement(WorkHubEnablementProvider, { value: WORKHUB_OFF }, createElement(ControllerProbe, input)), ), }, ); @@ -318,7 +320,7 @@ function renderWorkBoardComposition( createElement( WorkbarServicesProvider, { services: workbarServices }, - createElement(WorkBoardCompositionProbe, { ownerRef }), + createElement(WorkHubEnablementProvider, { value: WORKHUB_OFF }, createElement(WorkBoardCompositionProbe, { ownerRef })), ), ), }), diff --git a/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts b/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts index 3183a1f159..add3623328 100644 --- a/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-provider-scope.test.ts @@ -23,6 +23,7 @@ import { act, createElement } from 'react'; import type { SessionSummary } from '@maka/core/session'; import { LocaleProvider, type ToastApi } from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { WorkHubEnablementProvider } from '../../renderer/application/contracts/workhub-workspace/workhub-enablement.js'; import { createFakeWorkbarServices, createWorkbarShellBridge, @@ -124,12 +125,16 @@ describe('WorkbarProvider render scope', () => { createElement(LocaleProvider, { locale: 'en', children: createElement( - WorkbarServicesProvider, - { services: createFakeWorkbarServices() }, - createElement(WorkbarShellRoot, { - children: (workbar: ShellIntent) => - createElement(ShellProbe, { workbar, input }), - }), + WorkHubEnablementProvider, + { value: { isEnabled: () => false, subscribe: () => () => {} } }, + createElement( + WorkbarServicesProvider, + { services: createFakeWorkbarServices() }, + createElement(WorkbarShellRoot, { + children: (workbar: ShellIntent) => + createElement(ShellProbe, { workbar, input }), + }), + ), ), }), ), diff --git a/apps/desktop/src/main/__tests__/workhub-enablement.test.ts b/apps/desktop/src/main/__tests__/workhub-enablement.test.ts index f7919e9701..2cd12cdd4e 100644 --- a/apps/desktop/src/main/__tests__/workhub-enablement.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-enablement.test.ts @@ -101,3 +101,9 @@ test('Desktop reads the client WorkHub switch; AppShell no longer reaches it', a const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); assert.deepEqual(shell.split('\n').filter((line) => /\bsettings\s*\.\s*(?:getClient|subscribeClientChanged)\b|\bworkHubEnabled\b/.test(line)), []); }); + +test('a composition without the WorkHub switch fails instead of reading off', () => { + const { root } = installReactRenderer(); + assert.throws(() => act(() => root.render(createElement(WorkHubEnablementWatch, { onEnabled() {}, onDisabled() {} }))), + /WorkHubEnablementProvider is missing/); +}); diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 911dfaf394..95d1b85d8f 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -24,13 +24,11 @@ import type { SessionSummary } from '@maka/core/session'; import type { ThemePalette, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from '@maka/ui'; -import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; import { applyTheme, applyThemePalette } from './theme'; import { startTitlebarModalSync } from './titlebar-modal-sync'; import { safeLocalStorageSet } from './browser-storage'; import type { NavigationState } from './nav-selection.js'; -import { handleSessionChangedEvent } from './application/contracts/session-catalog/session-change-effects.js'; -import type { ShellLifecycleHandlers } from './application/contracts/shell-lifecycle.js'; +import { createShellLifecycleHandlers } from './application/contracts/shell-lifecycle.js'; type RefBox = { current: T }; @@ -126,15 +124,11 @@ export function useAppShellBootstrapSubscriptions(options: { sessionsRef: RefBox; recordSessionChange(sessionId: string, ts: number): void; toastApi: ToastApi; -}): ShellLifecycleHandlers { +}) { const runDeferredStartupRefreshes = useEffectEvent(() => { void options.bootstrapSessions(); void options.applyE2eFixture(); }); - const refreshRuntimeHostSettingsMirrors = () => { - void options.refreshShellSettings(); - void options.refreshConnections(); - }; // 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 // the hook's default "stay silent while typing" rule. @@ -187,39 +181,5 @@ export function useAppShellBootstrapSubscriptions(options: { cleanupPendingRefs(); }; }, []); - return { - onConnectionEvent: options.handleConnectionEvent, - onRuntimeHostChange(event) { - void options.refreshSessions().then(() => { - options.retiredSessionIds(options.sessionsRef.current).forEach(options.retireSession); - }); - if (event.readiness !== 'ready') return; - if (!event.isDefault) return; - refreshRuntimeHostSettingsMirrors(); - void options.refreshProjects(); - void options.refreshMemoryActive('load'); - }, - onExternalSettingsChanged: refreshRuntimeHostSettingsMirrors, - onClientSettingsChanged: () => void options.refreshShellSettings(), - onSessionChange: (event) => - handleSessionChangedEvent(event, { - ...options, - notifyModelRebound: (modelId) => { - const copy = getDesktopConversationCopy(options.uiLocale).actions; - options.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(modelId)); - }, - }), - // PR-2088: the macOS application menu routes New Task / Settings / Keyboard - // Shortcuts here through one channel. The renderer already owns these - // implementations; the menu is only a second entry surface. The keydown - // path (useHotkeys above) stays active on every platform: on macOS AppKit - // resolves the menu accelerator before the web contents sees the keydown, - // so a real keypress dispatches exactly once, while CDP-injected test keys - // still reach this handler for the renderer path. - onWindowCommand(command) { - if (command.id === 'newTask') void options.createSession(); - else if (command.id === 'openSettings') options.openSettings(); - else if (command.id === 'openHelp') options.openHelp(); - }, - }; + return createShellLifecycleHandlers(options); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 3e0799bc74..a2dc848e3c 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -17,7 +17,8 @@ * under the License. */ -import { WorkHubControlOverlay, WorkHubDock, WorkHubEnablementWatch, WorkHubMainNavigation } from './features/workhub'; +import { WorkHubControlOverlay, WorkHubDock, WorkHubMainNavigation } from './features/workhub'; +import { WorkHubEnablementWatch } from './application/contracts/workhub-workspace/workhub-enablement.js'; import { RuntimeHostHandoffOverlay } from './features/runtime-host-management/index.js'; import { useCallback, diff --git a/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts b/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts index 8ec85b5583..2076d7c3f8 100644 --- a/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts +++ b/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts @@ -319,15 +319,19 @@ export function createOnboardingAuthority(source: OnboardingSource): OnboardingA }; } -const IDLE: OnboardingAuthority = { - getProjection: () => INITIAL, - subscribe: () => () => {}, - refresh: () => {}, - skipInitialOnboarding: async () => {}, -}; -const AuthorityContext = createContext(IDLE); +const AuthorityContext = createContext(null); export const OnboardingAuthorityProvider = AuthorityContext.Provider; +/** + * A missing provider fails here: an idle stand-in would hold the first-run + * gate (and the launch overlay) closed forever without saying why. + */ +function useOnboardingAuthority(): OnboardingAuthority { + const authority = useContext(AuthorityContext); + if (!authority) throw new Error('OnboardingAuthorityProvider is missing'); + return authority; +} + export type OnboardingShellProjection = OnboardingProjection & Pick; @@ -340,7 +344,7 @@ export type OnboardingShellProjection = export function OnboardingProjectionRoot(props: { children(onboarding: OnboardingShellProjection): ReactNode; }) { - const authority = useContext(AuthorityContext); + const authority = useOnboardingAuthority(); const projection = useSyncExternalStore(authority.subscribe, authority.getProjection); return props.children({ ...projection, @@ -353,6 +357,6 @@ const selectSendOutcomes = (authority: OnboardingAuthority) => authority.getProj /** Per-Session send outcomes, for readers that need nothing else from onboarding. */ export function useOnboardingSessionSendOutcomes(): Readonly> | undefined { - const authority = useContext(AuthorityContext); + const authority = useOnboardingAuthority(); return useSyncExternalStore(authority.subscribe, () => selectSendOutcomes(authority)); } diff --git a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts index f7b8f2569c..ee9b2148a6 100644 --- a/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts +++ b/apps/desktop/src/renderer/application/contracts/session-catalog/session-catalog-state.ts @@ -65,10 +65,11 @@ export interface SessionCatalogSource { subscribeChanges(handler: (event: SessionChangedEvent) => void): () => void; } -/** A catalog that is only ever committed to, as in tests and stories. */ +const NO_SOURCE = 'This session catalog was created without a source'; +/** A catalog that is only ever committed to, as in tests and stories; reading through it fails. */ const DETACHED_SOURCE: SessionCatalogSource = { - list: async () => [], - subscribeChanges: () => () => {}, + list: () => Promise.reject(new Error(NO_SOURCE)), + subscribeChanges: () => { throw new Error(NO_SOURCE); }, }; export function createSessionCatalogController(source: SessionCatalogSource = DETACHED_SOURCE) { diff --git a/apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts b/apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts index ac50b1b74e..026bea67c3 100644 --- a/apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts +++ b/apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts @@ -29,6 +29,9 @@ import { createContext, useContext, useEffect, useEffectEvent } from 'react'; import type { ConnectionEvent } from '@maka/core/connections'; import type { SessionChangedEvent } from '@maka/core/session'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { getDesktopConversationCopy } from './conversation-copy.js'; +import { handleSessionChangedEvent } from './session-catalog/session-change-effects.js'; import { useSessionCatalogController } from './session-catalog/session-catalog-state.js'; export interface ShellWindowCommand { @@ -60,20 +63,70 @@ export interface ShellLifecycleHandlers { onSessionChange(event: SessionChangedEvent): void; } -const DETACHED: ShellLifecycleSources = { - tagDocumentPlatform: () => () => {}, - subscribeWindowCommands: () => () => {}, - subscribeConnectionEvents: () => () => {}, - subscribeRuntimeHostChanges: () => () => {}, - subscribeClientSettingsChanges: () => () => {}, - subscribeExternalSettingsChanges: () => () => {}, -}; -const SourcesContext = createContext(DETACHED); +/** What the shell's reactions call; the Session-change part is the catalog's change effects. */ +export interface ShellLifecycleReactions + extends Omit[1], 'notifyModelRebound'> { + uiLocale: UiLocale; + createSession(): Promise | void; + openSettings(): void; + openHelp(): void; + handleConnectionEvent(event: ConnectionEvent): void; + refreshConnections(): Promise; + refreshMemoryActive(failureContext?: 'load'): Promise; + refreshShellSettings(): Promise; + toastApi: { info(title: string, description?: string): void }; +} + +/** The shell's reaction to each event, built per render so each reads current state. */ +export function createShellLifecycleHandlers(reactions: ShellLifecycleReactions): ShellLifecycleHandlers { + const refreshRuntimeHostSettingsMirrors = () => { + void reactions.refreshShellSettings(); + void reactions.refreshConnections(); + }; + return { + onConnectionEvent: reactions.handleConnectionEvent, + onRuntimeHostChange(event) { + void reactions.refreshSessions().then(() => { + reactions.retiredSessionIds(reactions.sessionsRef.current).forEach(reactions.retireSession); + }); + if (event.readiness !== 'ready') return; + if (!event.isDefault) return; + refreshRuntimeHostSettingsMirrors(); + void reactions.refreshProjects(); + void reactions.refreshMemoryActive('load'); + }, + onExternalSettingsChanged: refreshRuntimeHostSettingsMirrors, + onClientSettingsChanged: () => void reactions.refreshShellSettings(), + onSessionChange: (event) => + handleSessionChangedEvent(event, { + ...reactions, + notifyModelRebound: (modelId) => { + const copy = getDesktopConversationCopy(reactions.uiLocale).actions; + reactions.toastApi.info(copy.modelReboundTitle, copy.modelReboundDescription(modelId)); + }, + }), + // PR-2088: the macOS application menu routes New Task / Settings / Keyboard + // Shortcuts here through one channel. The renderer already owns these + // implementations; the menu is only a second entry surface. The keydown + // path (the shell's useHotkeys) stays active on every platform: on macOS + // AppKit resolves the menu accelerator before the web contents sees the + // keydown, so a real keypress dispatches exactly once, while CDP-injected + // test keys still reach this handler for the renderer path. + onWindowCommand(command) { + if (command.id === 'newTask') void reactions.createSession(); + else if (command.id === 'openSettings') reactions.openSettings(); + else if (command.id === 'openHelp') reactions.openHelp(); + }, + }; +} + +const SourcesContext = createContext(null); export const ShellLifecycleSourcesProvider = SourcesContext.Provider; /** Subscribes for the shell's lifetime; each event reaches the shell's latest handler. */ export function ShellLifecycleSubscriptions(handlers: ShellLifecycleHandlers) { const sources = useContext(SourcesContext); + if (!sources) throw new Error('ShellLifecycleSourcesProvider is missing'); const catalog = useSessionCatalogController(); const onWindowCommand = useEffectEvent(handlers.onWindowCommand); const onConnectionEvent = useEffectEvent(handlers.onConnectionEvent); diff --git a/apps/desktop/src/renderer/application/contracts/workhub-workspace/workhub-enablement.ts b/apps/desktop/src/renderer/application/contracts/workhub-workspace/workhub-enablement.ts index 7d577c5e7c..6ebe8ccf94 100644 --- a/apps/desktop/src/renderer/application/contracts/workhub-workspace/workhub-enablement.ts +++ b/apps/desktop/src/renderer/application/contracts/workhub-workspace/workhub-enablement.ts @@ -70,23 +70,25 @@ export function createWorkHubEnablement(source: WorkHubEnablementSource): WorkHu }; } -const DISABLED: WorkHubEnablement = { isEnabled: () => false, subscribe: () => () => {} }; -const EnablementContext = createContext(DISABLED); +const EnablementContext = createContext(null); export const WorkHubEnablementProvider = EnablementContext.Provider; /** Invocation-time reads, for commands that must not act while WorkHub is off. */ export function useWorkHubEnablement(): WorkHubEnablement { - return useContext(EnablementContext); + const enablement = useContext(EnablementContext); + // A composition without the switch is a bug, not a WorkHub that is quietly off. + if (!enablement) throw new Error('WorkHubEnablementProvider is missing'); + return enablement; } export function useWorkHubEnabled(): boolean { - const enablement = useContext(EnablementContext); + const enablement = useWorkHubEnablement(); return useSyncExternalStore(enablement.subscribe, enablement.isEnabled); } /** Navigation the shell owns follows the switch: turning it on opens WorkHub, turning it off leaves it. */ export function WorkHubEnablementWatch(props: { onEnabled(): void; onDisabled(): void }) { - const enablement = useContext(EnablementContext); + const enablement = useWorkHubEnablement(); const follow = useEffectEvent((enabled: boolean) => (enabled ? props.onEnabled() : props.onDisabled())); useEffect(() => { let followed = false; diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts index cf4ca50316..a0422e555d 100644 --- a/apps/desktop/src/renderer/features/workhub/index.ts +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -25,7 +25,6 @@ export { WorkHubDock } from './ui/workhub-dock.js'; export { WorkHubSurfaceSwitch } from './ui/workhub-surface-switch.js'; export { WorkHubControlOverlay } from './ui/control-overlay.js'; export { WorkHubMainNavigation } from './ui/main-navigation.js'; -export { WorkHubEnablementWatch } from '../../application/contracts/workhub-workspace/workhub-enablement.js'; export { startWorkHubCoordinationLifecycle, WorkHubModelConfigurationRequiredError,