You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/25 08:12:36 UTC
(maka) branch main updated: refactor(desktop): move sharing dialog ownership below AppShell (#5505)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new b47fb74feb refactor(desktop): move sharing dialog ownership below AppShell (#5505)
b47fb74feb is described below
commit b47fb74febdcfc90e2b119abc96e3c2e1993acf3
Author: yum <ya...@chihum.dev>
AuthorDate: Fri Sep 25 16:12:31 2026 +0800
refactor(desktop): move sharing dialog ownership below AppShell (#5505)
Generated-by: Codex
---
apps/desktop/renderer-architecture.json | 51 +---
.../__tests__/session-collaboration-dialog.test.ts | 326 +++++++++++++++++++++
.../session-collaboration-join-dialog.test.ts | 4 +
.../session-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 ++++
.../controller/use-session-collaboration-dialog.ts | 35 +--
.../features/session-collaboration/index.ts | 8 +-
.../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, 637 insertions(+), 157 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index cd82bd38b3..56c539279c 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -98,7 +98,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",
@@ -266,6 +265,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",
@@ -664,7 +670,7 @@
"nonTriviaTokens": 1243
},
"src/renderer/app-shell.tsx": {
- "importDeclarations": 59,
+ "importDeclarations": 58,
"bridgePaths": {
"window.maka.attachments": 1,
"window.maka.attachments.readBytes": 1,
@@ -709,7 +715,6 @@
"useOnboardingSnapshot": 1,
"usePlanModeState": 1,
"useRef": 14,
- "useSessionCollaborationDialog": 1,
"useSessionEventHealthPolling": 1,
"useSessionNavigationReads": 1,
"useSessionSettingIntent": 1,
@@ -780,7 +785,6 @@
"./plan-mode-panel": 1,
"./platform/desktop/default-runtime-host-operation.js": 1,
"./scroll-motion-policy": 1,
- "./session-collaboration-dialog": 1,
"./session-workspace-errors": 1,
"./settings/provider-brand-marks": 1,
"./settings/provider-display": 1,
@@ -810,8 +814,8 @@
"@maka/ui": 1,
"react": 1
},
- "importSpecifiers": 95,
- "nonTriviaTokens": 12663
+ "importSpecifiers": 94,
+ "nonTriviaTokens": 12649
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 0,
@@ -1808,41 +1812,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<string, unknown>)[key]]));
+let root: Root | undefined;
+let latest: SessionCollaborationDialogProjection;
+let frameRenders = 0;
+const settingsSections: string[] = [];
+const animationFrames = new Map<number, FrameRequestCallback>();
+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<SessionCollaborationServices> = {}, strict = false) {
+ const { document, window } = parseHTML('<html><body><div id="root"></div></body></html>');
+ 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<HTMLButtonElement>('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<HTMLButtonElement>('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<SessionCollaborationServices['importInvitation']>[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 841687800a..3485cffc30 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<UiLocalePreference>('auto');
const [uiLocaleOverride, setUiLocaleOverride] = useState<UiLocale | null>(null);
@@ -248,9 +247,13 @@ export function AppShell() {
{(taskEntry) => (
<Overlays.OverlaysRoot>
{(overlays) => (
- <AppShellContent
- {...{ taskEntry, overlays, uiLocale, uiLocaleOverride, setUiLocaleOverride, setUiLocalePreference }}
- />
+ <SessionCollaboration.SessionCollaborationDialogRoot>
+ {(sharedSessionDialog) => (
+ <AppShellContent
+ {...{ taskEntry, overlays, sharedSessionDialog, uiLocale, uiLocaleOverride, setUiLocaleOverride, setUiLocalePreference }}
+ />
+ )}
+ </SessionCollaboration.SessionCollaborationDialogRoot>
)}
</Overlays.OverlaysRoot>
)}
@@ -277,6 +280,7 @@ const SESSION_RAIL = <SessionListPanel />;
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<SetStateAction<UiLocale | null>>;
setUiLocalePreference: Dispatch<SetStateAction<UiLocalePreference>>;
}) {
const toastApi = useToast();
- const sharedSessionDialog = useSessionCollaborationDialog();
const previousInterruptionShownRef = useRef(false);
const {
authoritativeSessionIds,
@@ -2637,12 +2641,6 @@ function AppShellContent({
<Goals.GoalHost />
<TaskEntry.TaskEntryHost />
<RuntimeHostSshTerminalDialog />
- <SessionCollaborationDialog
- target={sharedSessionDialog.target}
- onOpenRemoteAccessSettings={() => openSettingsSection('projects')}
- onClose={sharedSessionDialog.close}
- />
-
<AppShellOverlays
closeSettings={closeSettings}
themePref={themePref}
diff --git a/apps/desktop/src/renderer/features/session-collaboration/README.md b/apps/desktop/src/renderer/features/session-collaboration/README.md
new file mode 100644
index 0000000000..1e861ee4e5
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-collaboration/README.md
@@ -0,0 +1,51 @@
+<!--
+ 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.
+-->
+
+# 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<SessionCollaborationDialogTarget>();
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<SessionCollaborationDialogProjection['openSession']>(
+ (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<SessionCollaborationDialogProjection>(
+ () => ({ 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/controller/use-session-collaboration-dialog.ts b/apps/desktop/src/renderer/features/session-collaboration/model/dialog-projection.ts
similarity index 54%
copy from apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts
copy to apps/desktop/src/renderer/features/session-collaboration/model/dialog-projection.ts
index d01c3109cd..37d52b06db 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/controller/use-session-collaboration-dialog.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/model/dialog-projection.ts
@@ -17,36 +17,19 @@
* under the License.
*/
-import { 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;
}
-export function useSessionCollaborationDialog() {
- const [target, setTarget] = useState<SessionCollaborationDialogTarget>();
- const shareActionLabel = getSessionCollaborationCopy(useUiLocale()).shareAction;
-
- return {
- target,
- shareActionLabel,
- isOpen: target !== undefined,
- open: setTarget,
- openSession(session: {
- readonly id: string;
- readonly name: string;
- readonly profileKind: string;
- }) {
- setTarget({
- sessionId: session.id,
- sessionName: session.name,
- requiresRemoteAccess: session.profileKind === 'local',
- });
- },
- close: () => setTarget(undefined),
- };
+/** 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<boolean>;
+ getAccess(sessionId: string): Promise<CollaborationAccessQueryResult>;
+ 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<CollaborationGrantRevokeResult>;
+ revokePrincipal(sessionId: string, principalId: string): Promise<CollaborationPrincipalRevokeResult>;
+ writeInvitationClipboard(text: string): Promise<void>;
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> = {},
+): 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 ? (
+ <SessionCollaborationDialog key={target.sessionId} {...target} onClose={close} />
+ ) : 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<Props['target']> & Omit<Props, 'target'>;
-
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 (
- <ShareSessionDialog
- {...props.target}
- onOpenRemoteAccessSettings={props.onOpenRemoteAccessSettings}
- onClose={props.onClose}
- />
- );
-}
-
-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<CollaborationAccessQueryResult>();
- const [invitation, setInvitation] = useState<PreparedInvitation>();
+ const [invitation, setInvitation] = useState<PreparedSessionInvitation>();
const [turnRequests, setTurnRequests] = useState<readonly SessionTurnAccessRequest[]>();
const [authorityState, setAuthorityState] = useState<CollaborationAuthorityState>('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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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<void> {
setWorking(true);
try {
- await window.maka.sessionCollaboration.revokeGrant(
+ await services.revokeGrant(
props.sessionId,
grant.grantId,
);
@@ -242,7 +220,7 @@ function ShareSessionDialog(props: ShareSessionDialogProps) {
): Promise<void> {
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<Clipboard, 'writeText'> = {
+ 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 87be1d8f3e..da88e64fcf 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:** 297 files — blocker 0, reimplementation 0, polish 4, aligned 293.
+**Totals:** 298 files — blocker 0, reimplementation 0, polish 4, aligned 294.
## 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 |
@@ -140,7 +142,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 cd9fe33ccc..d8944ec5d0 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
@@ -110,7 +112,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,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org