From 796daa50922b4c62a9f5f35f4b0dec74768bf5de Mon Sep 17 00:00:00 2001 From: chihumyum Date: Sat, 19 Sep 2026 19:26:06 +0800 Subject: [PATCH] refactor(desktop): move sharing dialog ownership below AppShell Move the collaboration target and sharing surface into its feature owner, inject Desktop services, and keep only modal visibility and stable open commands in the shell. Cover target isolation, invitation confirmation, access mutations, and polling cleanup. Generated-by: Codex --- apps/desktop/renderer-architecture.json | 51 +-- .../session-collaboration-dialog.test.ts | 326 ++++++++++++++++++ .../session-collaboration-join-dialog.test.ts | 4 + ...ion-collaboration-services-adapter.test.ts | 62 ++++ .../session-turn-request-composer.test.ts | 3 + apps/desktop/src/renderer/app-shell.tsx | 22 +- .../features/session-collaboration/README.md | 51 +++ .../use-session-collaboration-dialog.ts | 35 +- .../features/session-collaboration/index.ts | 8 +- .../model/dialog-projection.ts | 35 ++ .../features/session-collaboration/ports.ts | 23 ++ .../features/session-collaboration/testing.ts | 35 ++ .../ui/session-collaboration-dialog-root.tsx | 39 +++ .../ui}/session-collaboration-dialog.tsx | 74 ++-- .../create-session-collaboration-services.ts | 15 +- .../stories/shared-session-guest.stories.tsx | 2 + docs/astryx-surface-file-inventory.md | 5 +- docs/astryx-surface-file-inventory.paths | 3 +- scripts/check-app-shell-hooks.mjs | 1 - 19 files changed, 663 insertions(+), 131 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-collaboration-dialog.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-collaboration-services-adapter.test.ts create mode 100644 apps/desktop/src/renderer/features/session-collaboration/README.md create mode 100644 apps/desktop/src/renderer/features/session-collaboration/model/dialog-projection.ts create mode 100644 apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog-root.tsx rename apps/desktop/src/renderer/{ => features/session-collaboration/ui}/session-collaboration-dialog.tsx (87%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2ac4a8cf0b..45b4d1c634 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -99,7 +99,6 @@ "src/renderer/project-path-display.ts", "src/renderer/remote-project-directory-dialog.tsx", "src/renderer/scroll-motion-policy.ts", - "src/renderer/session-collaboration-dialog.tsx", "src/renderer/session-copy-attempt.ts", "src/renderer/session-error-presentation.ts", "src/renderer/session-read-state.ts", @@ -267,6 +266,13 @@ "ownerSymbol": "OverlaysRoot", "count": 1 }, + { + "implementation": "src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts", + "symbol": "useSessionCollaborationDialog", + "owner": "src/renderer/features/session-collaboration/ui/session-collaboration-dialog-root.tsx", + "ownerSymbol": "SessionCollaborationDialogRoot", + "count": 1 + }, { "implementation": "src/renderer/features/task-entry/controller/use-task-entry-controller.ts", "symbol": "useTaskEntryController", @@ -665,7 +671,7 @@ "nonTriviaTokens": 1243 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 59, + "importDeclarations": 58, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -710,7 +716,6 @@ "useOnboardingSnapshot": 1, "usePlanModeState": 1, "useRef": 14, - "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, @@ -781,7 +786,6 @@ "./pending-session-view": 1, "./plan-mode-panel": 1, "./scroll-motion-policy": 1, - "./session-collaboration-dialog": 1, "./session-workspace-errors": 1, "./settings/provider-brand-marks": 1, "./settings/provider-display": 1, @@ -811,8 +815,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 95, - "nonTriviaTokens": 12681 + "importSpecifiers": 94, + "nonTriviaTokens": 12667 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, @@ -1820,41 +1824,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/session-collaboration-dialog.tsx": { - "bridgePaths": { - "window.maka.localRuntimeHostRemoteAccess.getSnapshot": 2, - "window.maka.sessionCollaboration.decideTurnRequest": 1, - "window.maka.sessionCollaboration.getAccess": 1, - "window.maka.sessionCollaboration.getTurnRequests": 1, - "window.maka.sessionCollaboration.prepareInvitation": 1, - "window.maka.sessionCollaboration.revokeGrant": 1, - "window.maka.sessionCollaboration.revokePrincipal": 1 - }, - "environmentCapabilities": { - "navigator.clipboard.writeText": 1, - "window.clearTimeout": 1, - "window.setTimeout": 1 - }, - "hookCalls": { - "useEffect": 1, - "useState": 6, - "useToast": 1, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/operation-diagnostics.js": 1, - "./features/session-collaboration": 1, - "./locales/session-collaboration-copy.js": 1, - "@astryxdesign/core": 1, - "@astryxdesign/core/Dialog": 1, - "@astryxdesign/core/Layout": 1, - "@maka/ui": 1, - "react": 1 - } - }, "src/renderer/session-copy-attempt.ts": { "bridgePaths": {}, "environmentCapabilities": { diff --git a/apps/desktop/src/main/__tests__/session-collaboration-dialog.test.ts b/apps/desktop/src/main/__tests__/session-collaboration-dialog.test.ts new file mode 100644 index 0000000000..ebfbcb8e65 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-collaboration-dialog.test.ts @@ -0,0 +1,326 @@ +/* + * 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, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { CollaborationAccessQueryResult, SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; +import { SettingsNavigationProvider } from '../../renderer/application/contracts/settings-presentation/settings-navigation.js'; +import { + createFakeSessionCollaborationServices, + SessionCollaborationDialogRoot, + SessionCollaborationServicesProvider, + type PreparedSessionInvitation, + type SessionCollaborationDialogProjection, + type SessionCollaborationServices, +} from '../../renderer/features/session-collaboration/testing.js'; + +const originalGlobals = Object.fromEntries([ + 'document', 'window', 'HTMLElement', 'HTMLIFrameElement', 'Event', 'Node', 'CSS', + 'getComputedStyle', 'matchMedia', 'requestAnimationFrame', 'cancelAnimationFrame', + 'IS_REACT_ACT_ENVIRONMENT', +].map((key) => [key, (globalThis as unknown as Record)[key]])); +let root: Root | undefined; +let latest: SessionCollaborationDialogProjection; +let frameRenders = 0; +const settingsSections: string[] = []; +const animationFrames = new Map(); +let nextFrameId = 0; +const remoteSession = { id: 'session-a', name: 'Task A', profileKind: 'remote' }; +const principal = { + principalId: 'guest:alice', displayName: 'Alice', status: 'pending' as const, + createdAt: '2026-01-01T00:00:00Z', +}; +const invitation: PreparedSessionInvitation = { + invitationCode: 'one-time-code', principalId: principal.principalId, + expiresAt: '2099-01-01T00:00:00Z', grants: [], connectivity: { kind: 'configured' }, +}; + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + root = undefined; + frameRenders = 0; + settingsSections.length = 0; + animationFrames.clear(); + Object.assign(globalThis, originalGlobals); +}); + +function ShellFrame({ dialog }: { dialog: SessionCollaborationDialogProjection }) { + latest = dialog; + frameRenders += 1; + return createElement('main', null, 'shell'); +} + +async function mount(overrides: Partial = {}, strict = false) { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, media, onchange: null, addListener() {}, removeListener() {}, + addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false, + }); + const getComputedStyle = () => ({ getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration; + Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} }); + Object.defineProperty(window, 'maka', { + configurable: true, + get() { throw new Error('The sharing feature must use injected services'); }, + }); + Object.assign(window.HTMLElement.prototype, { + showModal(this: HTMLElement) { this.setAttribute('open', ''); }, + close(this: HTMLElement) { this.removeAttribute('open'); }, + }); + Object.assign(globalThis, { + document, window, matchMedia, getComputedStyle, + HTMLElement: window.HTMLElement, HTMLIFrameElement: window.HTMLIFrameElement ?? class {}, + Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value }, + requestAnimationFrame: (callback: FrameRequestCallback) => { + const id = ++nextFrameId; + animationFrames.set(id, callback); + return id; + }, + cancelAnimationFrame: (id: number) => { animationFrames.delete(id); }, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + root = createRoot(container); + const feature = createElement(SessionCollaborationDialogRoot, { + children: (dialog) => createElement(ShellFrame, { dialog }), + }); + await act(async () => { + root!.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ToastProvider, { + children: createElement(SettingsNavigationProvider, { + navigation: { openSettingsSection: (section) => { settingsSections.push(section); } }, + children: createElement(SessionCollaborationServicesProvider, { + services: createFakeSessionCollaborationServices(overrides), + children: strict ? createElement(StrictMode, null, feature) : feature, + }), + }), + }), + }), + })); + }); + return document; +} + +async function open(session = remoteSession) { + await act(async () => latest.openSession(session)); +} + +async function click(document: Document, label: string) { + const button = [...document.querySelectorAll('button')] + .find((candidate) => candidate.textContent === label); + assert.ok(button, `missing button: ${label}`); + assert.equal(button.disabled, false, `disabled button: ${label}`); + await act(async () => button.click()); + // Toast confirmation deliberately opens/resolves on the next browser frame. + await act(async () => { + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + for (const callback of callbacks) callback(0); + }); +} + +test('owns the dialog below the shell and keeps target changes and polling reader-local', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const reads: string[] = []; + const document = await mount({ getAccess: async (sessionId) => { + reads.push(sessionId); + return { principals: [], grants: [] }; + } }); + assert.equal(latest.isOpen, false); + assert.deepEqual(reads, []); + const opener = latest.openSession; + await open(); + assert.equal(latest.isOpen, true); + assert.match(document.body.textContent, /Task A/u); + const openProjection = latest; + const openRenders = frameRenders; + await act(async () => t.mock.timers.tick(2_000)); + assert.deepEqual(reads, ['session-a', 'session-a']); + assert.equal(frameRenders, openRenders, 'polling does not re-render the shell'); + await open({ ...remoteSession, id: 'session-b', name: 'Task B' }); + assert.equal(latest, openProjection, 'target data is not part of the shell projection'); + assert.equal(frameRenders, openRenders); + assert.match(document.body.textContent, /Task B/u); + await click(document, 'Done'); + assert.equal(latest.isOpen, false); + assert.equal(latest.openSession, opener); + const readCount = reads.length; + await act(async () => t.mock.timers.tick(10_000)); + assert.equal(reads.length, readCount, 'closing releases the poll timer'); +}); + +test('does not publish an old target read or restart its poll after target replacement', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let resolveOld!: (access: CollaborationAccessQueryResult) => void; + const reads: string[] = []; + const document = await mount({ getAccess: (sessionId) => { + reads.push(sessionId); + return sessionId === 'session-a' + ? new Promise((resolve) => { resolveOld = resolve; }) + : Promise.resolve({ principals: [], grants: [] }); + } }); + await open(); + await open({ ...remoteSession, id: 'session-b', name: 'Task B' }); + await act(async () => resolveOld({ principals: [principal], grants: [] })); + assert.doesNotMatch(document.body.textContent, /Alice/u); + await act(async () => t.mock.timers.tick(2_000)); + assert.deepEqual(reads, ['session-a', 'session-b', 'session-b']); +}); + +test('cleans up in-flight polling on unmount under StrictMode', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const resolvers: Array<(access: CollaborationAccessQueryResult) => void> = []; + let reads = 0; + await mount({ getAccess: () => { + reads += 1; + return new Promise((resolve) => resolvers.push(resolve)); + } }, true); + await open(); + assert.ok(reads > 0); + await act(async () => root!.unmount()); + root = undefined; + const atUnmount = reads; + await act(async () => { for (const resolve of resolvers) resolve({ principals: [], grants: [] }); }); + await act(async () => t.mock.timers.tick(10_000)); + assert.equal(reads, atUnmount); +}); + +test('closes local sharing and opens the existing Settings destination when remote access is off', async () => { + const document = await mount({ + isLocalRemoteAccessEnabled: async () => false, + getAccess: async () => assert.fail('local access must be enabled before querying grants'), + prepareInvitation: async () => assert.fail('must not prepare before enabling remote access'), + }); + await open({ ...remoteSession, profileKind: 'local' }); + await click(document, 'Create invitation'); + assert.equal(latest.isOpen, false); + assert.deepEqual(settingsSections, ['projects']); +}); + +test('rechecks local remote access immediately before invitation creation', async () => { + let enabled = true; + const document = await mount({ + isLocalRemoteAccessEnabled: async () => enabled, + prepareInvitation: async () => assert.fail('access was disabled after the projection loaded'), + }); + await open({ ...remoteSession, profileKind: 'local' }); + enabled = false; + await click(document, 'Create invitation'); + assert.equal(latest.isOpen, false); + assert.deepEqual(settingsSections, ['projects']); +}); + +test('requires explicit insecure confirmation, then copies the injected invitation', async () => { + const prepares: unknown[][] = []; + const copied: string[] = []; + const document = await mount({ + isLocalRemoteAccessEnabled: async () => assert.fail('remote Sessions do not use local access'), + getAccess: async () => ({ principals: [principal], grants: [] }), + prepareInvitation: async (...args) => { + prepares.push(args); + return args[2] + ? { kind: 'prepared', invitation } + : { kind: 'insecure_confirmation_required' }; + }, + writeInvitationClipboard: async (text) => { copied.push(text); }, + }); + await open(); + const shellRenders = frameRenders; + await click(document, 'Create invitation'); + assert.deepEqual(prepares, [['session-a', 'observe', false]]); + await click(document, 'Accept risk and create'); + assert.deepEqual(prepares, [['session-a', 'observe', false], ['session-a', 'observe', true]]); + await click(document, 'Copy invitation'); + assert.deepEqual(copied, ['one-time-code']); + assert.equal(frameRenders, shellRenders, 'invitation and toast updates stay below the shell'); +}); + +test('keeps grant revocation and turn decisions bound to the dialog target', async () => { + const calls: unknown[][] = []; + const grant = { + grantId: 'grant-a', principalId: principal.principalId, sessionId: remoteSession.id, + createdAt: principal.createdAt, kind: 'session_turn_request' as const, + }; + const request: SessionTurnAccessRequest = { + requestId: 'request-a', principalId: principal.principalId, grantId: grant.grantId, + intent: { sessionId: remoteSession.id, turnId: 'turn-a', content: { text: 'Please run' } }, + createdAt: principal.createdAt, state: { kind: 'pending' }, + }; + const document = await mount({ + getAccess: async () => ({ principals: [principal], grants: [grant] }), + getTurnRequests: async () => ({ canRequestTurns: false, requests: [request] }), + revokeGrant: async (...args) => { calls.push(['grant', ...args]); return { revoked: true }; }, + revokePrincipal: async (...args) => { calls.push(['principal', ...args]); return { revoked: true }; }, + decideTurnRequest: async (...args) => { calls.push(['decision', ...args]); return { kind: 'not_found' }; }, + }); + await open(); + await click(document, 'Revoke Turn requests'); + await click(document, 'Revoke'); + await click(document, 'Approve'); + await click(document, 'Reject'); + assert.deepEqual(calls, [ + ['grant', 'session-a', 'grant-a'], ['principal', 'session-a', 'guest:alice'], + ['decision', 'session-a', 'request-a', 'approve'], ['decision', 'session-a', 'request-a', 'reject'], + ]); +}); + +test('discards the previous invitation immediately when the target changes', async () => { + const document = await mount({ + getAccess: async () => ({ principals: [principal], grants: [] }), + prepareInvitation: async () => ({ kind: 'prepared', invitation }), + }); + await open(); + await click(document, 'Create invitation'); + assert.equal(document.querySelector('textarea')?.value, 'one-time-code'); + await open({ ...remoteSession, id: 'session-b', name: 'Task B' }); + assert.equal(document.querySelector('textarea') === null, true, 'a new target has no previous invitation'); + assert.match(document.body.textContent, /Task B/u); +}); + +test('does not retry invitation creation when insecure confirmation is declined', async () => { + let attempts = 0; + const document = await mount({ + prepareInvitation: async () => { + attempts += 1; + return { kind: 'insecure_confirmation_required' }; + }, + }); + await open(); + await click(document, 'Create invitation'); + const confirmation = document.querySelector('.maka-confirm-modal'); + assert.ok(confirmation); + const cancel = [...confirmation.querySelectorAll('button')] + .find((button) => button.textContent === 'Done'); + assert.ok(cancel); + await act(async () => cancel.click()); + await act(async () => { + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + for (const callback of callbacks) callback(0); + }); + assert.equal(attempts, 1); + assert.equal(latest.isOpen, true); + assert.equal(document.querySelector('textarea') === null, true, 'declining does not create an invitation'); +}); diff --git a/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts b/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts index ce48d83b32..3781becb98 100644 --- a/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/session-collaboration-join-dialog.test.ts @@ -27,6 +27,7 @@ import { SessionCollaborationJoinDialog, SessionCollaborationNavigation, SessionCollaborationServicesProvider, + createFakeSessionCollaborationServices, type SessionCollaborationServices, } from '../../renderer/features/session-collaboration/testing.js'; @@ -61,6 +62,7 @@ afterEach(async () => { test('keeps loading progress visible while an irreversible import settles', async () => { let reportProgress: Parameters[1]; const services: SessionCollaborationServices = { + ...createFakeSessionCollaborationServices(), importInvitation: async (_input, onProgress) => { reportProgress = onProgress; return new Promise(() => undefined); @@ -125,6 +127,7 @@ test('closes as a retained background recovery instead of reporting a failed joi let imported = 0; let closed = 0; const services: SessionCollaborationServices = { + ...createFakeSessionCollaborationServices(), importInvitation: async () => ({ kind: 'recovering', mountId: 'shared-1' }), cancelImport: async () => 'settling', readInvitationClipboard: async () => '', @@ -186,6 +189,7 @@ test('identifies a retained shared task and its selected peer transport', async let retried: string | undefined; let opened: string | undefined; const services: SessionCollaborationServices = { + ...createFakeSessionCollaborationServices(), importInvitation: async () => ({ kind: 'error', reason: 'incompatible_host', message: 'raw compatibility details' }), cancelImport: async () => 'settling', readInvitationClipboard: async () => '', diff --git a/apps/desktop/src/main/__tests__/session-collaboration-services-adapter.test.ts b/apps/desktop/src/main/__tests__/session-collaboration-services-adapter.test.ts new file mode 100644 index 0000000000..554e116481 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-collaboration-services-adapter.test.ts @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + createDesktopSessionCollaborationServices, + type DesktopSessionCollaborationBridge, +} from '../../renderer/platform/desktop/create-session-collaboration-services.js'; + +test('preserves sharing bridge arguments and adapts remote access and clipboard capabilities', async () => { + const calls: Array<{ name: string; args: unknown[] }> = []; + const result = { marker: 'bridge-result' }; + let state = 'off'; + const sessionCollaboration = new Proxy({}, { + get: (_target, property) => (...args: unknown[]) => { + calls.push({ name: String(property), args }); + return Promise.resolve(result); + }, + }); + const services = createDesktopSessionCollaborationServices({ + sessionCollaboration, + localRuntimeHostRemoteAccess: { getSnapshot: async () => ({ state }) }, + } as unknown as DesktopSessionCollaborationBridge, { + writeText: async (text) => { calls.push({ name: 'clipboard', args: [text] }); }, + }); + assert.equal(await services.isLocalRemoteAccessEnabled(), false); + state = 'on'; + assert.equal(await services.isLocalRemoteAccessEnabled(), true); + assert.equal(await services.getAccess('session-a'), result); + assert.equal(await services.prepareInvitation('session-a', 'request_turn', false), result); + assert.equal(await services.prepareInvitation('session-a', 'request_turn', true), result); + assert.equal(await services.revokeGrant('session-a', 'grant-a'), result); + assert.equal(await services.revokePrincipal('session-a', 'principal-a'), result); + assert.equal(await services.decideTurnRequest('session-a', 'request-a', 'approve'), result); + await services.writeInvitationClipboard('invitation-code'); + assert.deepEqual(calls, [ + { name: 'getAccess', args: ['session-a'] }, + { name: 'prepareInvitation', args: ['session-a', 'request_turn', false] }, + { name: 'prepareInvitation', args: ['session-a', 'request_turn', true] }, + { name: 'revokeGrant', args: ['session-a', 'grant-a'] }, + { name: 'revokePrincipal', args: ['session-a', 'principal-a'] }, + { name: 'decideTurnRequest', args: ['session-a', 'request-a', 'approve'] }, + { name: 'clipboard', args: ['invitation-code'] }, + ]); +}); diff --git a/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts b/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts index b52d950552..71c5d23280 100644 --- a/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts +++ b/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts @@ -26,6 +26,7 @@ import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; import { SessionCollaborationServicesProvider, + createFakeSessionCollaborationServices, SessionTurnRequestComposer, type SessionCollaborationServices, } from '../../renderer/features/session-collaboration/testing.js'; @@ -79,6 +80,7 @@ test('keeps a newer Guest draft across remount when an old request settles later state: { kind: 'pending' }, }; const services: SessionCollaborationServices = { + ...createFakeSessionCollaborationServices(), importInvitation: async () => ({ kind: 'connected', mountId: 'unused' }), cancelImport: async () => 'cancelled', readInvitationClipboard: async () => '', @@ -174,6 +176,7 @@ test('resumes an in-flight Guest request across remount without submitting it tw state: { kind: 'pending' }, }; const services: SessionCollaborationServices = { + ...createFakeSessionCollaborationServices(), importInvitation: async () => ({ kind: 'connected', mountId: 'unused' }), cancelImport: async () => 'cancelled', readInvitationClipboard: async () => '', diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 7f1e013930..e7bc84515e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -93,8 +93,8 @@ import * as TaskEntry from './features/task-entry'; import type { TaskEntryShellProjection } from './features/task-entry'; import * as Overlays from './features/overlays/index.js'; import type { OverlaysShellProjection } from './features/overlays/index.js'; -import { SessionCollaborationDialog } from './session-collaboration-dialog'; import * as SessionCollaboration from './features/session-collaboration'; +import type { SessionCollaborationDialogProjection } from './features/session-collaboration'; import { NEW_TASK_PENDING_KEY } from './pending-items'; import { desktopSlashCommandAvailability, @@ -210,7 +210,6 @@ type ComposerImportOwner = { * assistant stream slot when the primary post-commit signal is missed. */ const SETTLE_FALLBACK_GRACE_MS = 1000; -const { useSessionCollaborationDialog } = SessionCollaboration; export function AppShell() { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); @@ -248,9 +247,13 @@ export function AppShell() { {(taskEntry) => ( {(overlays) => ( - + + {(sharedSessionDialog) => ( + + )} + )} )} @@ -277,6 +280,7 @@ const SESSION_RAIL = ; function AppShellContent({ taskEntry, overlays, + sharedSessionDialog, uiLocale, uiLocaleOverride, setUiLocaleOverride, @@ -284,13 +288,13 @@ function AppShellContent({ }: { taskEntry: TaskEntryShellProjection; overlays: OverlaysShellProjection; + sharedSessionDialog: SessionCollaborationDialogProjection; uiLocale: UiLocale; uiLocaleOverride: UiLocale | null; setUiLocaleOverride: Dispatch>; setUiLocalePreference: Dispatch>; }) { const toastApi = useToast(); - const sharedSessionDialog = useSessionCollaborationDialog(); const previousInterruptionShownRef = useRef(false); const { authoritativeSessionIds, @@ -2640,12 +2644,6 @@ function AppShellContent({ - openSettingsSection('projects')} - onClose={sharedSessionDialog.close} - /> - + +# Session collaboration + +This slice owns sharing and joining Sessions, Guest access management, and +Owner approval of Guest Turn requests. + +## Sharing dialog ownership + +`SessionCollaborationDialogRoot` is the only production owner of +`useSessionCollaborationDialog`, registered in `controllerOwners`. The public +entry exports the root and its narrow shell projection, never the controller. + +The root owns the target and mounts the sharing dialog. The shell receives a +stable `openSession` command, the localized action label, and `isOpen` for its +existing modal coordination. Target changes and dialog polling, invitations, +and access mutations do not invalidate that projection. Opening and closing +still update the shell because its shortcuts and Workbar need modal visibility. + +A dialog instance belongs to one target Session. Changing targets remounts it, +clears the previous invitation and access state, and releases its polling loop. +Closing or unmounting ignores late projection reads and stops polling. + +The dialog obtains its capabilities from `SessionCollaborationServices`. +The Desktop adapter owns bridge and clipboard access. Remote access is checked +before reading local sharing controls and again before creating an invitation; +unencrypted connections still require explicit confirmation. Opening remote +access Settings closes sharing first and uses the shared Settings navigation +contract to select `projects`. + +`testing.ts` provides injectable fake services. The sharing tests mount the +production root and dialog without Electron, exercising modal projection, +polling cleanup, invitation confirmation/copying, access revocation, and Turn +approval/rejection. Session settings and Plan ownership are separate work. diff --git a/apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts b/apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts index d01c3109cd..ddca8e7f03 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts +++ b/apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts @@ -17,36 +17,33 @@ * under the License. */ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useUiLocale } from '@maka/ui'; import { getSessionCollaborationCopy } from '../../../locales/session-collaboration-copy.js'; -export interface SessionCollaborationDialogTarget { - readonly sessionId: string; - readonly sessionName: string; - readonly requiresRemoteAccess: boolean; -} +import type { + SessionCollaborationDialogProjection, + SessionCollaborationDialogTarget, +} from '../model/dialog-projection.js'; export function useSessionCollaborationDialog() { const [target, setTarget] = useState(); const shareActionLabel = getSessionCollaborationCopy(useUiLocale()).shareAction; - - return { - target, - shareActionLabel, - isOpen: target !== undefined, - open: setTarget, - openSession(session: { - readonly id: string; - readonly name: string; - readonly profileKind: string; - }) { + const openSession = useCallback( + (session) => { setTarget({ sessionId: session.id, sessionName: session.name, requiresRemoteAccess: session.profileKind === 'local', }); }, - close: () => setTarget(undefined), - }; + [], + ); + const close = useCallback(() => setTarget(undefined), []); + const isOpen = target !== undefined; + const shell = useMemo( + () => ({ isOpen, shareActionLabel, openSession }), + [isOpen, shareActionLabel, openSession], + ); + return { target, close, shell }; } diff --git a/apps/desktop/src/renderer/features/session-collaboration/index.ts b/apps/desktop/src/renderer/features/session-collaboration/index.ts index 762e7fa58d..2f63e696aa 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/index.ts +++ b/apps/desktop/src/renderer/features/session-collaboration/index.ts @@ -18,17 +18,13 @@ */ export { SessionCollaborationServicesProvider } from './services-context'; -export { useSessionCollaborationDialog } from './controller/use-session-collaboration-dialog'; +export { SessionCollaborationDialogRoot } from './ui/session-collaboration-dialog-root.js'; +export type { SessionCollaborationDialogProjection } from './model/dialog-projection.js'; export { SessionCollaborationJoinDialog } from './ui/session-collaboration-join-dialog'; export { SessionCollaborationNavigation } from './ui/session-collaboration-navigation'; -export { SessionGuestAliasAction } from './ui/session-collaboration-alias-action'; export { SessionGuestTurnActionBoundary } from './ui/session-guest-turn-actions'; export { SessionTurnRequestComposer } from './ui/session-turn-request-composer'; export { SessionTurnRequestApprovalForSession } from './ui/session-turn-request-approval'; export { SessionTurnRequestBadge } from './ui/session-turn-request-badge'; export { SessionTurnRequestInboxProvider } from './turn-request-inbox-context'; -export { - describeTurnRequestIntent, - turnRequestStateLabel, -} from './model/turn-request-inbox'; export type { SessionCollaborationServices } from './ports'; diff --git a/apps/desktop/src/renderer/features/session-collaboration/model/dialog-projection.ts b/apps/desktop/src/renderer/features/session-collaboration/model/dialog-projection.ts new file mode 100644 index 0000000000..37d52b06db --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/model/dialog-projection.ts @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface SessionCollaborationDialogTarget { + readonly sessionId: string; + readonly sessionName: string; + readonly requiresRemoteAccess: boolean; +} + +/** The shell needs modal coordination and an opener, but not the target. */ +export interface SessionCollaborationDialogProjection { + readonly isOpen: boolean; + readonly shareActionLabel: string; + openSession(session: { + readonly id: string; + readonly name: string; + readonly profileKind: string; + }): void; +} diff --git a/apps/desktop/src/renderer/features/session-collaboration/ports.ts b/apps/desktop/src/renderer/features/session-collaboration/ports.ts index a432698832..b3eafbe324 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/ports.ts +++ b/apps/desktop/src/renderer/features/session-collaboration/ports.ts @@ -24,6 +24,10 @@ import type { SessionCollaborationMountSummary, } from '../../../shared/session-collaboration.js'; import type { + CollaborationAccessQueryResult, + CollaborationInvitationPrepareResult, + CollaborationGrantRevokeResult, + CollaborationPrincipalRevokeResult, CollaborationTurnRequestAcknowledgeResult, CollaborationTurnRequestDecideResult, CollaborationTurnRequestQueryResult, @@ -31,6 +35,12 @@ import type { SessionTurnAccessRequest, } from '@maka/runtime-host/protocol'; +export type PreparedSessionInvitation = CollaborationInvitationPrepareResult & { + readonly connectivity: + | { readonly kind: 'peer'; readonly coordinationRelayCount: number } + | { readonly kind: 'configured' }; +}; + export type { SessionCollaborationCancelResult, SessionCollaborationImportPhase, @@ -39,6 +49,19 @@ export type { } from '../../../shared/session-collaboration.js'; export interface SessionCollaborationServices { + isLocalRemoteAccessEnabled(): Promise; + getAccess(sessionId: string): Promise; + prepareInvitation( + sessionId: string, + preset: 'observe' | 'request_turn', + allowInsecure: boolean, + ): Promise< + | { readonly kind: 'prepared'; readonly invitation: PreparedSessionInvitation } + | { readonly kind: 'insecure_confirmation_required' } + >; + revokeGrant(sessionId: string, grantId: string): Promise; + revokePrincipal(sessionId: string, principalId: string): Promise; + writeInvitationClipboard(text: string): Promise; importInvitation(input: { readonly code: string; readonly allowInsecure: boolean; diff --git a/apps/desktop/src/renderer/features/session-collaboration/testing.ts b/apps/desktop/src/renderer/features/session-collaboration/testing.ts index 0768269e19..4e9b235b13 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/testing.ts +++ b/apps/desktop/src/renderer/features/session-collaboration/testing.ts @@ -17,6 +17,8 @@ * under the License. */ +import type { SessionCollaborationServices } from './ports.js'; + export { groupPendingTurnRequests, describeOwnerTurnRequestIntent, @@ -31,3 +33,36 @@ export { SessionCollaborationJoinDialog } from './ui/session-collaboration-join- export { SessionCollaborationNavigation } from './ui/session-collaboration-navigation.js'; export { SessionTurnRequestComposer } from './ui/session-turn-request-composer.js'; export { sessionCollaborationImportErrorMessage } from './ui/session-collaboration-join-dialog.js'; +export { SessionCollaborationDialogRoot } from './ui/session-collaboration-dialog-root.js'; +export type { SessionCollaborationDialogProjection } from './model/dialog-projection.js'; +export type { PreparedSessionInvitation } from './ports.js'; + +export function createFakeSessionCollaborationServices( + overrides: Partial = {}, +): SessionCollaborationServices { + return { + isLocalRemoteAccessEnabled: async () => true, + getAccess: async () => ({ principals: [], grants: [] }), + prepareInvitation: async () => { throw new Error('Fake prepareInvitation is not configured'); }, + revokeGrant: async () => ({ revoked: true }), + revokePrincipal: async () => ({ revoked: true }), + writeInvitationClipboard: async () => undefined, + importInvitation: async () => { throw new Error('Fake importInvitation is not configured'); }, + cancelImport: async () => 'cancelled', + readInvitationClipboard: async () => '', + listMounts: async () => [], + subscribeMountChanges: () => () => undefined, + removeMount: async () => undefined, + retryMount: async () => undefined, + renameMount: async () => undefined, + renamePrincipal: async () => ({ renamed: true }), + requestTurn: async () => { throw new Error('Fake requestTurn is not configured'); }, + getTurnRequests: async () => ({ canRequestTurns: false, requests: [] }), + acknowledgeTurnRequest: async () => ({ acknowledged: false }), + withdrawTurnRequest: async () => ({ withdrawn: false }), + getPendingTurnRequests: async () => [], + decideTurnRequest: async () => ({ kind: 'not_found' }), + createOperationId: () => 'operation-1', + ...overrides, + }; +} diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog-root.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog-root.tsx new file mode 100644 index 0000000000..431b571d91 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog-root.tsx @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo, type ReactNode } from 'react'; +import { useSessionCollaborationDialog } from '../controller/use-session-collaboration-dialog.js'; +import type { SessionCollaborationDialogProjection } from '../model/dialog-projection.js'; +import { SessionCollaborationDialog } from './session-collaboration-dialog.js'; + +/** Owns the sharing target; the shell reads only the modal-open projection. */ +export function SessionCollaborationDialogRoot(props: { + readonly children: (dialog: SessionCollaborationDialogProjection) => ReactNode; +}) { + const { target, close, shell } = useSessionCollaborationDialog(); + const frame = useMemo(() => props.children(shell), [props.children, shell]); + return ( + <> + {frame} + {target ? ( + + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog.tsx similarity index 87% rename from apps/desktop/src/renderer/session-collaboration-dialog.tsx rename to apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog.tsx index 99f4305907..811537f807 100644 --- a/apps/desktop/src/renderer/session-collaboration-dialog.tsx +++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog.tsx @@ -30,73 +30,52 @@ import { useToast, useUiLocale, } from '@maka/ui'; -import { reportUnexpectedError } from './application/contracts/operation-diagnostics.js'; +import { reportUnexpectedError } from '../../../application/contracts/operation-diagnostics.js'; +import { useSettingsNavigation } from '../../../application/contracts/settings-presentation/settings-navigation.js'; import type { CollaborationAccessQueryResult, - CollaborationInvitationPrepareResult, SessionCollaborationGrant, SessionTurnAccessRequest, } from '@maka/runtime-host/protocol'; -import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js'; +import { getSessionCollaborationCopy } from '../../../locales/session-collaboration-copy.js'; import { describeTurnRequestIntent, turnRequestStateLabel, - SessionGuestAliasAction, -} from './features/session-collaboration'; +} from '../model/turn-request-inbox.js'; +import { SessionGuestAliasAction } from './session-collaboration-alias-action.js'; +import { useSessionCollaborationServices } from '../services-context.js'; +import type { PreparedSessionInvitation } from '../ports.js'; +import type { SessionCollaborationDialogTarget } from '../model/dialog-projection.js'; -type Props = { - readonly target?: { - readonly sessionId: string; - readonly sessionName: string; - readonly requiresRemoteAccess: boolean; - }; - readonly onOpenRemoteAccessSettings: () => void; +type Props = SessionCollaborationDialogTarget & { readonly onClose: () => void; }; -type ShareSessionDialogProps = NonNullable & Omit; - type CollaborationAuthorityState = | 'loading' | 'available' | 'remote_access_off' | 'unavailable'; -type PreparedInvitation = CollaborationInvitationPrepareResult & { - readonly connectivity: - | { readonly kind: 'peer'; readonly coordinationRelayCount: number } - | { readonly kind: 'configured' }; -}; - export function SessionCollaborationDialog(props: Props) { - if (!props.target) return null; - return ( - - ); -} - -function ShareSessionDialog(props: ShareSessionDialogProps) { + const services = useSessionCollaborationServices(); + const settingsNavigation = useSettingsNavigation(); const copy = getSessionCollaborationCopy(useUiLocale()); const toast = useToast(); const [preset, setPreset] = useState<'observe' | 'request_turn'>('observe'); const [access, setAccess] = useState(); - const [invitation, setInvitation] = useState(); + const [invitation, setInvitation] = useState(); const [turnRequests, setTurnRequests] = useState(); const [authorityState, setAuthorityState] = useState('loading'); const [working, setWorking] = useState(false); async function readProjection() { if (props.requiresRemoteAccess) { - const remoteAccess = await window.maka.localRuntimeHostRemoteAccess.getSnapshot(); - if (remoteAccess.state !== 'on') return { kind: 'remote_access_off' } as const; + if (!(await services.isLocalRemoteAccessEnabled())) return { kind: 'remote_access_off' } as const; } const [nextAccess, nextRequests] = await Promise.all([ - window.maka.sessionCollaboration.getAccess(props.sessionId), - window.maka.sessionCollaboration.getTurnRequests(props.sessionId), + services.getAccess(props.sessionId), + services.getTurnRequests(props.sessionId), ]); return { kind: 'available', @@ -132,7 +111,7 @@ function ShareSessionDialog(props: ShareSessionDialogProps) { useEffect(() => { let disposed = false; - let timer: number | undefined; + let timer: ReturnType | undefined; const poll = async () => { try { const projection = await readProjection(); @@ -140,15 +119,15 @@ function ShareSessionDialog(props: ShareSessionDialogProps) { } catch { if (!disposed) setAuthorityState('unavailable'); } finally { - if (!disposed) timer = window.setTimeout(() => void poll(), 2_000); + if (!disposed) timer = setTimeout(() => void poll(), 2_000); } }; void poll(); return () => { disposed = true; - if (timer !== undefined) window.clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); }; - }, [props.sessionId, props.requiresRemoteAccess]); + }, [props.sessionId, props.requiresRemoteAccess, services]); async function createInvitation(allowInsecure = false): Promise { setWorking(true); @@ -159,13 +138,12 @@ function ShareSessionDialog(props: ShareSessionDialogProps) { } if (authorityState !== 'available') return; if (props.requiresRemoteAccess) { - const access = await window.maka.localRuntimeHostRemoteAccess.getSnapshot(); - if (access.state !== 'on') { + if (!(await services.isLocalRemoteAccessEnabled())) { openRemoteAccessSettings(); return; } } - const created = await window.maka.sessionCollaboration.prepareInvitation( + const created = await services.prepareInvitation( props.sessionId, preset, allowInsecure, @@ -193,13 +171,13 @@ function ShareSessionDialog(props: ShareSessionDialogProps) { function openRemoteAccessSettings(): void { props.onClose(); toast.info(copy.enableRemoteAccessTitle, copy.enableRemoteAccessBody); - props.onOpenRemoteAccessSettings(); + settingsNavigation.openSettingsSection('projects'); } async function copyInvitation(): Promise { if (!invitation) return; try { - await navigator.clipboard.writeText(invitation.invitationCode); + await services.writeInvitationClipboard(invitation.invitationCode); toast.success(copy.copied); } catch (error) { reportUnexpectedError('session-collaboration:copy', error); @@ -210,7 +188,7 @@ function ShareSessionDialog(props: ShareSessionDialogProps) { async function revokePrincipal(principalId: string): Promise { setWorking(true); try { - await window.maka.sessionCollaboration.revokePrincipal(props.sessionId, principalId); + await services.revokePrincipal(props.sessionId, principalId); await refresh(); } catch (error) { reportUnexpectedError('session-collaboration:revoke-principal', error); @@ -223,7 +201,7 @@ function ShareSessionDialog(props: ShareSessionDialogProps) { async function revokeGrant(grant: SessionCollaborationGrant): Promise { setWorking(true); try { - await window.maka.sessionCollaboration.revokeGrant( + await services.revokeGrant( props.sessionId, grant.grantId, ); @@ -242,7 +220,7 @@ function ShareSessionDialog(props: ShareSessionDialogProps) { ): Promise { setWorking(true); try { - await window.maka.sessionCollaboration.decideTurnRequest( + await services.decideTurnRequest( props.sessionId, request.requestId, decision, diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts index 3f25ebb682..92073d7a10 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-session-collaboration-services.ts @@ -22,13 +22,26 @@ import type { SessionCollaborationServices } from '../../features/session-collab export type DesktopSessionCollaborationBridge = Pick< MakaBridge, - 'sessionCollaboration' + 'sessionCollaboration' | 'localRuntimeHostRemoteAccess' >; export function createDesktopSessionCollaborationServices( bridge: DesktopSessionCollaborationBridge = window.maka, + clipboard: Pick = { + writeText: (text) => navigator.clipboard.writeText(text), + }, ): SessionCollaborationServices { return { + isLocalRemoteAccessEnabled: async () => + (await bridge.localRuntimeHostRemoteAccess.getSnapshot()).state === 'on', + getAccess: (sessionId) => bridge.sessionCollaboration.getAccess(sessionId), + prepareInvitation: (sessionId, preset, allowInsecure) => + bridge.sessionCollaboration.prepareInvitation(sessionId, preset, allowInsecure), + revokeGrant: (sessionId, grantId) => + bridge.sessionCollaboration.revokeGrant(sessionId, grantId), + revokePrincipal: (sessionId, principalId) => + bridge.sessionCollaboration.revokePrincipal(sessionId, principalId), + writeInvitationClipboard: (text) => clipboard.writeText(text), importInvitation: (input, onProgress) => bridge.sessionCollaboration.importInvitation(input, onProgress), cancelImport: (operationId) => bridge.sessionCollaboration.cancelImport(operationId), diff --git a/apps/desktop/stories/shared-session-guest.stories.tsx b/apps/desktop/stories/shared-session-guest.stories.tsx index 9687141135..4ecdc55a2f 100644 --- a/apps/desktop/stories/shared-session-guest.stories.tsx +++ b/apps/desktop/stories/shared-session-guest.stories.tsx @@ -25,6 +25,7 @@ import { SessionTurnRequestComposer, type SessionCollaborationServices, } from '../src/renderer/features/session-collaboration/index.js'; +import { createFakeSessionCollaborationServices } from '../src/renderer/features/session-collaboration/testing.js'; const SESSION_ID = 'shared-session-story'; const REQUESTS: readonly SessionTurnAccessRequest[] = [ @@ -70,6 +71,7 @@ function services( ) => Promise<{ readonly withdrawn: boolean }> = async () => ({ withdrawn: true }), ): SessionCollaborationServices { return { + ...createFakeSessionCollaborationServices(), importInvitation: async () => { throw new Error('unused'); }, diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ec9d9404ef..b719a0371c 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 293 files — blocker 0, reimplementation 0, polish 4, aligned 289. +**Totals:** 294 files — blocker 0, reimplementation 0, polish 4, aligned 290. ## Exclusions (explicit) @@ -95,6 +95,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/session-collaboration/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-alias-action.tsx` | shell-chrome-or-panel | Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | +| `apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog-root.tsx` | dialog-overlay | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog.tsx` | dialog-overlay | Banner, Button, Dialog, DialogHeader, FormLayout, Layout, LayoutContent, LayoutFooter, SegmentedControl, SegmentedControlItem, Text, TextArea | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, FormLayout, Layout, LayoutContent, LayoutFooter) | aligned | | `apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx` | dialog-overlay | Badge, Banner, Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter, List, ListItem, TextArea, Tooltip, VStack | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, FormLayout, HStack, Layout) | aligned | | `apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-navigation.tsx` | shell-chrome-or-panel | SideNavItem | aligned — uses Astryx (SideNavItem) | aligned | | `apps/desktop/src/renderer/features/session-collaboration/ui/session-guest-turn-actions.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | @@ -139,7 +141,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/plan-mode-panel.tsx` | shell-chrome-or-panel | Badge, Banner, Button, Collapsible | aligned — uses Astryx (Badge, Banner, Button, Collapsible) | aligned | | `apps/desktop/src/renderer/reference-shell.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/remote-project-directory-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, DropdownMenu, DropdownMenuItem, HStack, Layout, LayoutContent, LayoutFooter, Text | aligned — uses Astryx (Button, Dialog, DialogHeader, DropdownMenu, DropdownMenuItem, HStack, Layout, LayoutContent) | aligned | -| `apps/desktop/src/renderer/session-collaboration-dialog.tsx` | dialog-overlay | Banner, Button, Dialog, DialogHeader, FormLayout, Layout, LayoutContent, LayoutFooter, SegmentedControl, SegmentedControlItem, Text, TextArea | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, FormLayout, Layout, LayoutContent, LayoutFooter) | aligned | | `apps/desktop/src/renderer/settings/about-settings-page.tsx` | settings-page | Banner, Button, Link, Text, VStack | aligned — uses Astryx (Banner, Button, Link, Text, VStack) | aligned | | `apps/desktop/src/renderer/settings/appearance-settings-page.tsx` | settings-page | Button, Grid, HStack, NumberInput, SelectableCard, Switch, Text, VStack | aligned — uses Astryx (Button, Grid, HStack, NumberInput, SelectableCard, Switch, Text, VStack) | aligned | | `apps/desktop/src/renderer/settings/bot-chat-detail.tsx` | settings-module | Banner, Button, Card, FormLayout, MetadataList, MetadataListItem, SegmentedControl, SegmentedControlItem, Selector, StatusDot, Switch, Text, TextArea, TextInput, VStack | aligned — uses Astryx (Banner, Button, Card, FormLayout, MetadataList, MetadataListItem, SegmentedControl, SegmentedControlItem) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 2608f95dda..e76a9b5373 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -65,6 +65,8 @@ apps/desktop/src/renderer/features/session-bundle/session-bundle-tasks.tsx apps/desktop/src/renderer/features/session-collaboration/services-context.tsx apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-alias-action.tsx +apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog-root.tsx +apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-dialog.tsx apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-navigation.tsx apps/desktop/src/renderer/features/session-collaboration/ui/session-guest-turn-actions.tsx @@ -109,7 +111,6 @@ apps/desktop/src/renderer/onboarding-hero.tsx apps/desktop/src/renderer/plan-mode-panel.tsx apps/desktop/src/renderer/reference-shell.css apps/desktop/src/renderer/remote-project-directory-dialog.tsx -apps/desktop/src/renderer/session-collaboration-dialog.tsx apps/desktop/src/renderer/settings/about-settings-page.tsx apps/desktop/src/renderer/settings/appearance-settings-page.tsx apps/desktop/src/renderer/settings/bot-chat-detail.tsx diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 5277d098ce..110da5f88d 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -133,7 +133,6 @@ export const ALLOWED = { // replaces put three `useState`, four effects and a `useStableActions` // facade on this fiber. useSessionNavigationReads: 1, - useSessionCollaborationDialog: 1, useSessionSettingIntent: 1, useShellAppearance: 1, useShellChatModel: 1,