diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 0fdf034948..88703c8816 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -167,7 +167,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", @@ -614,30 +613,20 @@ }, "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": {}, "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, @@ -645,7 +634,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 1361 + "nonTriviaTokens": 886 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -708,14 +697,8 @@ "nonTriviaTokens": 2157 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 43, - "bridgePaths": { - "window.maka.attachments.readBytes": 1, - "window.maka.onboarding.setMilestone": 1, - "window.maka.sessions.listTurnLandmarks": 1, - "window.maka.settings.getClient": 1, - "window.maka.settings.subscribeClientChanged": 1 - }, + "importDeclarations": 42, + "bridgePaths": {}, "environmentCapabilities": { "window.requestAnimationFrame": 3 }, @@ -729,10 +712,9 @@ "useAppShellSessionUiReads": 1, "useAppShellSessionWorkspace": 1, "useAppShellTurnPresentation": 1, - "useEffect": 2, + "useEffect": 1, "useLayoutEffect": 1, - "useOnboardingSnapshot": 1, - "useRef": 5, + "useRef": 4, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, "useShellAppearance": 1, @@ -742,7 +724,7 @@ "useShellMemoryPill": 1, "useShellResume": 1, "useStableActions": 1, - "useState": 6, + "useState": 5, "useSystemUiLocale": 1, "useToast": 1, "useTurnActionRegistry": 1 @@ -759,7 +741,10 @@ "./app-shell-overlays": 1, "./application/contracts/conversation-copy": 1, "./application/contracts/desktop-slash-command.js": 1, + "./application/contracts/onboarding/onboarding-authority.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, @@ -795,7 +780,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, @@ -814,14 +798,12 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 63, - "nonTriviaTokens": 8221 + "importSpecifiers": 60, + "nonTriviaTokens": 8037 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, - "bridgePaths": { - "window.maka.sessions.list": 1 - }, + "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { "useExternalStoreSelector": 1, @@ -844,7 +826,7 @@ "react": 1 }, "importSpecifiers": 5, - "nonTriviaTokens": 481 + "nonTriviaTokens": 479 }, "src/renderer/use-app-shell-session-ui-reads.ts": { "importDeclarations": 0, @@ -3401,24 +3383,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__/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 0f45cb27f2..24db5a463f 100644 --- a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts @@ -18,24 +18,29 @@ */ 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, StagedComposer, StagedQuoteChatView, PlanProvider, PlanServicesProvider, - type PlanServices, type ComposerStagingSubmission, + type PlanServices, type ComposerStagingServices, type ComposerStagingSubmission, } from '../../renderer/features/conversation/index.js'; import { createRevisionAwareOnSend, createStagedFollowUp, useComposerStaging, type RevisionSendPorts, } from '../../renderer/features/conversation/testing.js'; import { createChatActions } from '../../renderer/features/conversation/testing.js'; import { createRevisionActions, type TurnRevisionDraft } from '../../renderer/features/conversation/testing.js'; +import { createDesktopComposerStagingServices } from '../../renderer/platform/desktop/create-composer-staging-services.js'; import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION, windowSubmissionServices } 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', @@ -394,3 +399,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/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts index b7733caea8..31cd9a2b0a 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -146,7 +146,7 @@ function harness(options: { createElement(ConversationLifecycle, { refreshSessions: async () => [], onExecutionBoundaryChanged() {}, showModelSetupToast() {}, onTurnCompleted() {}, - searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: async () => ({ landmarks: [] }), + searchTarget: null, clearSearchTarget() {}, }), createElement(ConversationComposerRegion, { surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true, diff --git a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts index 8d1109ce7c..7d4330fba2 100644 --- a/apps/desktop/src/main/__tests__/conversation-compaction.test.ts +++ b/apps/desktop/src/main/__tests__/conversation-compaction.test.ts @@ -167,7 +167,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 2180de3e3f..c17929d573 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'; import { withComposerSubmission } from './composer-submission-fixture.js'; @@ -41,7 +41,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(); @@ -51,7 +51,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; @@ -109,7 +111,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__/use-onboarding-snapshot.test.ts b/apps/desktop/src/main/__tests__/onboarding-authority.test.ts similarity index 63% 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..d20a634dfa 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,163 @@ */ 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('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({ + 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 +311,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 +322,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 +353,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 +377,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 +388,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 +398,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 +422,6 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh-CN', ); const pull1 = poller.pull(); const pull2 = poller.pull(); @@ -308,7 +452,6 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh-CN', ); void poller.pull(); void poller.pull(); @@ -339,7 +482,6 @@ describe('createOnboardingSnapshotPoller', () => { /* not expected */ }, }, - () => 'zh-CN', ); const pull = poller.pull(); poller.dispose(); @@ -361,9 +503,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 +527,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 +545,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-catalog-source.test.ts b/apps/desktop/src/main/__tests__/session-catalog-source.test.ts new file mode 100644 index 0000000000..7ceb6b2a4d --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-catalog-source.test.ts @@ -0,0 +1,81 @@ +/* + * 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']); +}); + +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 73a055016c..5ed13e42f5 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,15 @@ 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'; +import { + OnboardingAuthorityProvider, + type OnboardingAuthority, + type OnboardingSnapshot, +} from '../../renderer/application/contracts/onboarding/onboarding-authority.js'; const EMPTY_STREAMING_SESSIONS = new Set(); @@ -192,17 +202,23 @@ 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> = { 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, @@ -214,7 +230,6 @@ function navigationTree( hiddenSessionIds, projectScopes: [localProjectScope], streamingSessions, - sessionSendOutcomes: {}, ports: ports(linkedCatalog, shell.activeSessionId), commandsRef: { current: null }, selection: { section: 'sessions' }, @@ -226,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', { @@ -608,6 +633,63 @@ 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 onboarding = onboardingWith({ + sessionSendOutcomes: { remote: { kind: 'blocked', reason: 'connection_missing', connectionLocked: false } }, + } as unknown as OnboardingSnapshot); + const catalog = createSessionCatalogController(); + catalog.commitSessions(linkedCatalog); + const { root } = installReactRenderer(); + 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()); + }); + + 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(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; + 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__/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 new file mode 100644 index 0000000000..0d729113c8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/shell-lifecycle.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { + createShellLifecycleHandlers, + 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)), []); +}); + +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__/task-readiness-owner.test.ts b/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts index b64affb07d..7f14e2fcd4 100644 --- a/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts +++ b/apps/desktop/src/main/__tests__/task-readiness-owner.test.ts @@ -26,6 +26,11 @@ import { act, createElement, type ComponentProps, type ReactNode } from 'react'; import type { TaskSubmissionReadinessSnapshot } from '@maka/core/task-submission-readiness'; import { LocaleProvider } from '@maka/ui'; import * as Conversation from '../../renderer/features/conversation/index.js'; +import { + OnboardingAuthorityProvider, + type OnboardingAuthority, + type OnboardingSnapshot, +} from '../../renderer/application/contracts/onboarding/onboarding-authority.js'; import { TaskReadinessNoticeConsumer, TaskReadinessProvider, @@ -113,19 +118,34 @@ function noticeRecorder() { type OwnerProps = Omit[0], 'children' | 'openSessionWorkspaceRecovery'> & { openSessionWorkspaceRecovery?: (sessionId: string) => void; + /** Delivered as the onboarding authority's snapshot; a new value reads again. */ + refreshKey: unknown; }; +/** An onboarding authority whose snapshot is the given key, so a new key is a new snapshot. */ +function onboardingWith(snapshot: unknown): OnboardingAuthority { + return { + getProjection: () => ({ snapshot: snapshot as OnboardingSnapshot, failed: false }), + subscribe: () => () => {}, + refresh: () => {}, + skipInitialOnboarding: async () => {}, + }; +} + const ignoreRecovery = () => {}; -function owner(services: TaskReadinessServices, props: OwnerProps, children: ReactNode) { +function owner(services: TaskReadinessServices, { refreshKey, ...props }: OwnerProps, children: ReactNode) { return createElement(LocaleProvider, { locale: 'en', - children: createElement(TaskReadinessServicesProvider, { - services, - children: createElement(TaskReadinessProvider, { - openSessionWorkspaceRecovery: ignoreRecovery, - ...props, - children, + children: createElement(OnboardingAuthorityProvider, { + value: onboardingWith(refreshKey), + children: createElement(TaskReadinessServicesProvider, { + services, + children: createElement(TaskReadinessProvider, { + openSessionWorkspaceRecovery: ignoreRecovery, + ...props, + children, + }), }), }), }); diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 02030d18af..e000448e4b 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'; @@ -117,6 +118,8 @@ function ControllerProbe(props: ControllerProbeInput) { const connectedServices = new WeakSet(); +const WORKHUB_OFF = { isEnabled: () => false, subscribe: () => () => {} }; + function renderController( root: ReturnType['root'], services: WorkbarServices, @@ -142,7 +145,7 @@ function renderController( children: createElement( WorkbarServicesProvider, { services }, - createElement(ControllerProbe, input), + createElement(WorkHubEnablementProvider, { value: WORKHUB_OFF }, createElement(ControllerProbe, input)), ), }, ); @@ -317,7 +320,7 @@ function renderWorkBoardComposition( createElement( WorkbarServicesProvider, { services: workbarServices }, - createElement(WorkBoardCompositionProbe, { ownerRef }), + createElement(WorkHubEnablementProvider, { value: WORKHUB_OFF }, createElement(WorkBoardCompositionProbe, { ownerRef })), ), ), }), @@ -765,8 +768,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__/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 new file mode 100644 index 0000000000..2cd12cdd4e --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-enablement.test.ts @@ -0,0 +1,109 @@ +/* + * 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)), []); +}); + +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/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/README.md b/apps/desktop/src/renderer/README.md index 941a7482bc..ee0a1bd553 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -279,18 +279,16 @@ or reason, which stays with review. | `AppShell` | `useState` | `uiLocaleOverride` | `LocaleProvider`; E2E locale override | AppShell | a runtime locale override above every region | locale | — | | `AppShell` | `useSystemUiLocale` | `systemUiLocale` | `resolveUiLocale` for `LocaleProvider` | AppShell | read the OS locale and its changes | locale | — | | `AppShellContent` | `useActiveExecutionBoundary` | `activeExecutionBoundary` | Composer permission control; the Composer submission owner reloads it after a boundary answer | Conversation | read and reload the owner Session's execution boundary | — | M3 | -| `AppShellContent` | `useAppShellBootstrapSubscriptions` | Main change subscriptions | Session, connection, Host-profile and settings refreshers; app-window commands | legacy `app-shell-effects.ts` | subscribe to Main change events and dispatch them to region refreshers | — | M5 | -| `AppShellContent` | `useAppShellHostEffects` | platform tag and titlebar modal sync | ``; titlebar | legacy `app-shell-effects.ts` | read the platform once and observe top-layer modals; the `app.info` read moves behind an adapter in M5 | layout | — | +| `AppShellContent` | `useAppShellBootstrapSubscriptions` | Main change subscriptions | Session, connection, Host-profile and settings refreshers; app-window commands | legacy `app-shell-effects.ts` | startup refreshes, the global shortcuts and the handlers `ShellLifecycleSubscriptions` subscribes with the injected `ShellLifecycleSources`; no bridge access | application lifecycle | — | +| `AppShellContent` | `useAppShellHostEffects` | titlebar modal sync | titlebar | legacy `app-shell-effects.ts` | observe top-layer modals; the `data-os` platform tag is applied by `ShellLifecycleSources` | layout | — | | `AppShellContent` | `useAppShellNavRefSync` | `navSelectionRef` | ownership checks of async results | AppShell | mirror the navigation selection into a ref | navigation | — | | `AppShellContent` | `useAppShellPersistenceEffects` | theme and navigation persistence | `` theme class and palette; stored navigation | legacy `app-shell-effects.ts` | apply the theme preference and palette; persist the navigation state | layout | — | | `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name; project picker and commands | legacy `use-project-context.ts` | read the owner Session's project and run project commands | — | M5 | | `AppShellContent` | `useAppShellSessionUiReads` | displayed Session chrome | interaction, queue, live-turn and execution chrome; Composer props | Conversation (transitional reader) | fixed-purpose reads of the displayed and owner Session | — | M3 | | `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection and the catalog controller | navigation | — | | `AppShellContent` | `useAppShellTurnPresentation` | `deriveTurnPresentation` | `ChatView` turn footer | application contract `turn-presentation` | derive turn presentation from the transcript projection and pending turn actions | — | M3 | -| `AppShellContent` | `useEffect` | `setWorkHubEnabled`: WorkHub enablement subscription | `workHubEnabled`, `workHubActive` | AppShell | read the client WorkHub setting and follow its changes | — | M5 | -| `AppShellContent` | `useEffect` | `defaultHostConnections`: onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding snapshot | — | M5 | +| `AppShellContent` | `useEffect` | `defaultHostConnections`: onboarding connection seed | default-Host connection projection | AppShell | seed default-Host connections from the onboarding authority's read-only projection | — | M5 | | `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | turn footer, Module Hub, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | -| `AppShellContent` | `useOnboardingSnapshot` | onboarding snapshot | hero, connection seed, readiness, send outcomes | legacy `use-onboarding-snapshot.ts` | read onboarding state from Main | — | M5 | | `AppShellContent` | `useSessionNavigationReads` | rail reads | command palette sessions, titlebar parent, `--maka-sidenav-width` | Session Navigation | revision navigation, the active parent Session and the rail layout | navigation | — | | `AppShellContent` | `useSessionSettingIntent` | selected-Session setting overlay | Composer model and mode controls; new-task settings for creation | Session Settings | an equality-selected overlay read, the new-task settings and setting commands | — | M3 | | `AppShellContent` | `useShellAppearance` | appearance settings | theme, palette, user label, Workbar toggle position, locale update gate | legacy `use-shell-appearance.ts` | read and write client appearance settings | layout | — | @@ -304,7 +302,6 @@ or reason, which stays with review. | `AppShellContent` | `useStableActions` | `createAppShellE2eFixtureActions` | E2E fixture command | AppShell | apply test fixtures across navigation, rail, Workbar and appearance | cross-region command | — | | `AppShellContent` | `useState` | `petCompletionNonce` | custom pet companion | AppShell | a counter the transcript bumps when the active Turn completes | cross-region command | — | | `AppShellContent` | `useState` | `navigationState` | navigation sections; stored navigation | AppShell | the selected section and each hub's module | navigation | — | -| `AppShellContent` | `useState` | `workHubEnabled` | WorkHub dock; Workbar input | AppShell | the client WorkHub setting | — | M5 | | `AppShellContent` | `useState` | `workHubActive` | WorkHub or Session surface | AppShell | whether the WorkHub surface is shown | navigation | — | | `AppShellContent` | `useToast` | `toastApi` | toasts of every legacy action | Astryx toast provider | show toasts | cross-region command | — | | `AppShellContent` | `useTurnActionRegistry` | pending turn actions | turn footer disabled mask; the Composer submission owner's Turn branch; bootstrap clears | legacy `use-turn-action-registry.ts` | pending action keys per Session | — | M3 | diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 2f824de62c..95d1b85d8f 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -20,20 +20,15 @@ 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'; -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 { - DesktopRuntimeHostProfileChangedEvent, - WindowCommand, -} from '../preload/bridge-contract.js'; +import { createShellLifecycleHandlers } from './application/contracts/shell-lifecycle.js'; type RefBox = { current: T }; @@ -61,27 +56,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 @@ -154,45 +129,6 @@ export function useAppShellBootstrapSubscriptions(options: { 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 +170,16 @@ 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 createShellLifecycleHandlers(options); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e9655d986b..596f8b2115 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -18,6 +18,7 @@ */ 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, @@ -82,7 +83,12 @@ import * as SessionCollaboration from './features/session-collaboration'; import type { SessionCollaborationDialogProjection } from './features/session-collaboration'; import { NEW_TASK_PENDING_KEY } from './pending-items'; import { desktopSlashCommandAvailability } from './application/contracts/desktop-slash-command.js'; -import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; +import { + getOnboardingActivationCandidate, + 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'; @@ -111,10 +117,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 { useStableActions } from './use-stable-actions'; @@ -184,9 +187,13 @@ export function AppShell() { {(workbar) => ( - + + {(onboarding) => ( + + )} + )} @@ -220,6 +227,7 @@ function AppShellContent({ overlays, sharedSessionDialog, workbar: { bridge, commands, selectors, LiveContextUsageProbe }, + onboarding, uiLocale, uiLocaleOverride, setUiLocaleOverride, @@ -229,6 +237,7 @@ function AppShellContent({ overlays: OverlaysShellProjection; sharedSessionDialog: SessionCollaborationDialogProjection; workbar: WorkbarShellProjection; + onboarding: OnboardingShellProjection; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; @@ -284,7 +293,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; @@ -313,32 +321,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 { @@ -672,7 +657,6 @@ function AppShellContent({ [shellCopy], ); const openWorkHub = useCallback(() => { - if (!workHubEnabledRef.current) return; overlays.commands.closeSettings(); setNavSelection({ section: 'sessions' }); setWorkHubActive(true); @@ -733,7 +717,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 / @@ -746,18 +730,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. @@ -999,7 +983,7 @@ function AppShellContent({ navSelectionRef, }); useAppShellHostEffects(); - useAppShellBootstrapSubscriptions({ + const shellLifecycle = useAppShellBootstrapSubscriptions({ uiLocale, activeIdRef, applyE2eFixture, @@ -1236,7 +1220,7 @@ function AppShellContent({ // on catalog moves. - @@ -1328,7 +1312,7 @@ function AppShellContent({ + + { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} /> { 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. @@ -1455,7 +1440,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} @@ -1468,11 +1452,7 @@ function AppShellContent({ onSelect={setNavSelection} onOpenSettings={openSettings} onNew={createSession} - workHubEntry={workHubEnabled ? { - active: workHubActive, - label: 'WorkHub', - onSelect: openWorkHub, - } : undefined} + workHubEntry={{ active: workHubActive, label: 'WorkHub', onSelect: openWorkHub }} projectActions={projectRowActions} onNewProject={ taskEntry.selectors.canAddProject @@ -1504,7 +1484,7 @@ function AppShellContent({ commands.toggleTool('inspector')} onToggleWorkbar={commands.toggleRightPanel} onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} /> - + { if (activeId) openSessionInChat(activeId, turnId); }} - onReadAttachmentBytes={window.maka.attachments.readBytes} onOpenLinkedSession={openSessionInChat} scrollTargetTurn={ activeId && searchScrollTarget?.sessionId === activeId @@ -1712,14 +1691,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..817e722876 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/onboarding/onboarding-authority.ts @@ -0,0 +1,368 @@ +/* + * 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 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; + +/** + * 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 = useOnboardingAuthority(); + const projection = useSyncExternalStore(authority.subscribe, authority.getProjection); + return props.children({ + ...projection, + refresh: authority.refresh, + skipInitialOnboarding: authority.skipInitialOnboarding, + }); +} + +/** The current snapshot, for readers that check something again whenever onboarding changes. */ +export function useCurrentOnboardingSnapshot(): OnboardingSnapshot | null { + const authority = useOnboardingAuthority(); + return useSyncExternalStore(authority.subscribe, () => authority.getProjection().snapshot); +} + +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 = 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 5a08672ded..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 @@ -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,24 @@ 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; +} + +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: () => Promise.reject(new Error(NO_SOURCE)), + subscribeChanges: () => { throw new Error(NO_SOURCE); }, +}; + +export function createSessionCatalogController(source: SessionCatalogSource = DETACHED_SOURCE) { const state = createObservableState({ sessions: [], revision: 0, @@ -79,6 +97,7 @@ export function createSessionCatalogController() { }; return { + source, getState: state.getState, subscribe: state.subscribe, isAutomaticQueryBlocked(sessionId: string): boolean { diff --git a/apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts b/apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts new file mode 100644 index 0000000000..026bea67c3 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/shell-lifecycle.ts @@ -0,0 +1,150 @@ +/* + * 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 root's application lifecycle events (#4582 M5). + * + * AppShell still reacts to these across regions — Session and Host refreshes, + * the settings mirrors, connection projections, the window menu — so the + * reactions stay a documented root lifecycle. What leaves the shell is the + * environment: Desktop supplies these sources at composition, and + * `ShellLifecycleSubscriptions` is the one place that subscribes to them. + */ + +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 { + readonly id: 'newTask' | 'openSettings' | 'openHelp'; +} + +export interface ShellRuntimeHostChange { + readonly readiness: 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; + readonly isDefault: boolean; +} + +export interface ShellLifecycleSources { + /** Tags the document with the host OS for platform chrome; returns a cancel. */ + tagDocumentPlatform(): () => 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; +} + +/** 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); + 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/application/contracts/workhub-workspace/workhub-enablement.ts b/apps/desktop/src/renderer/application/contracts/workhub-workspace/workhub-enablement.ts new file mode 100644 index 0000000000..6ebe8ccf94 --- /dev/null +++ b/apps/desktop/src/renderer/application/contracts/workhub-workspace/workhub-enablement.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 { createContext, useContext, useEffect, useEffectEvent, useSyncExternalStore } from 'react'; + +/** Where the client-owned WorkHub switch is read; Desktop supplies it at composition. */ +export interface WorkHubEnablementSource { + read(): Promise; + 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 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 { + 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 = 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 = useWorkHubEnablement(); + 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/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index b227a8cf16..9ccf3277e3 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -57,6 +57,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/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 1a808c08b0..6d47ddf72a 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -72,6 +72,19 @@ import { createSessionCatalogController, SessionCatalogContext, } from '../application/contracts/session-catalog/session-catalog-state.js'; +import { createDesktopSessionCatalogSource } from '../platform/desktop/session-catalog-sync.js'; +import { + createWorkHubEnablement, + 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'; +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(); @@ -84,7 +97,14 @@ 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()), + // 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(), @@ -116,6 +136,9 @@ export function DesktopFeatureServicesProvider(props: { }) { return ( + + + @@ -163,6 +186,9 @@ export function DesktopFeatureServicesProvider(props: { + + + ); } diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 442a301e9c..025f376269 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -183,6 +183,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/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/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/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index 51816a21ab..6d4baf98cd 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/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/src/renderer/features/conversation/ui/task-readiness-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx index 7431c56a15..58502edd93 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/task-readiness-provider.tsx @@ -24,6 +24,7 @@ import { useTaskSubmissionReadiness } from '../controller/use-task-submission-re import { deriveTaskReadinessNotice } from '../model/task-readiness-notice.js'; import type { ConversationNewTaskTarget } from '../ports.js'; import { useTaskReadinessServices, type TaskReadinessRequest } from '../readiness-services.js'; +import { useCurrentOnboardingSnapshot } from '../../../application/contracts/onboarding/onboarding-authority.js'; interface TaskReadinessOwner { readonly snapshot: TaskSubmissionReadinessSnapshot | undefined; @@ -39,12 +40,11 @@ const TaskReadinessContext = createContext(undef * `ComposerStagingProvider`, so section switches and transcript unmounts * neither restart nor drop the read. AppShell supplies the request projection, * the targets and the stable workspace-recovery commands; it receives no - * snapshot. + * snapshot. A new onboarding snapshot from the application onboarding + * authority reads again. */ export function TaskReadinessProvider(props: { readonly request: TaskReadinessRequest; - /** Any new value reads again; AppShell passes the onboarding snapshot. */ - readonly refreshKey: unknown; readonly sessionId?: string; readonly newTaskTarget?: ConversationNewTaskTarget; /** A workspace blocker on this Session opens its workspace recovery. */ @@ -57,7 +57,7 @@ export function TaskReadinessProvider(props: { const { snapshot, refresh } = useTaskSubmissionReadiness( useTaskReadinessServices(), props.request, - props.refreshKey, + useCurrentOnboardingSnapshot(), props.sessionId, props.newTaskTarget, ); diff --git a/apps/desktop/src/renderer/features/session-navigation/README.md b/apps/desktop/src/renderer/features/session-navigation/README.md index 489ee4f183..dfae1bcf52 100644 --- a/apps/desktop/src/renderer/features/session-navigation/README.md +++ b/apps/desktop/src/renderer/features/session-navigation/README.md @@ -75,6 +75,14 @@ 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. +- 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 810140aa61..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,8 +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 { @@ -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; @@ -96,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; /** @@ -126,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( @@ -145,6 +148,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 +325,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 604033f9f9..e3ff59a5cf 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 '../../../application/contracts/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/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-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/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), + }; +} 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/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/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) => 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/apps/desktop/stories/composer-slash-menu.stories.tsx b/apps/desktop/stories/composer-slash-menu.stories.tsx index 430f4c884e..93f0309da0 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 }, @@ -214,6 +215,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'; 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 07b54d46eb..0dcb8d175c 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -114,9 +114,8 @@ export const ALLOWED = { useAppShellSessionUiReads: 1, useAppShellSessionWorkspace: 1, useAppShellTurnPresentation: 1, - useEffect: 2, + useEffect: 1, useLayoutEffect: 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 @@ -137,7 +136,7 @@ export const ALLOWED = { useShellMemoryPill: 1, useShellResume: 1, useStableActions: 1, - useState: 4, + useState: 3, useToast: 1, // The last of the three `useKeyedPendingRegistry` call sites this entry // replaces: #4113 moved the other two onto the session UI store, which is