You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/03 06:31:58 UTC
(maka) branch main updated (9d4002b382 -> 6cfb832e52)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
from 9d4002b382 fix(ui): stop the restored draft caret from taking focus (#4602)
new c0fa4647c5 fix(desktop): stop misreporting shared Session joins (#4601)
new 6cfb832e52 feat(desktop): complete shared Session Guest controls (#4608)
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
apps/desktop/renderer-architecture.json | 42 +-
.../main/__tests__/app-shell-turn-actions.test.ts | 18 +-
.../__tests__/runtime-host-desktop-manager.test.ts | 22 +-
.../runtime-host-guest-session-mounts.test.ts | 14 +-
.../session-collaboration-join-dialog.test.ts | 70 +++-
.../session-turn-request-composer.test.ts | 317 +++++++++++++++
.../session-turn-request-inbox-model.test.ts | 37 +-
apps/desktop/src/main/runtime-host-boot.ts | 2 +-
apps/desktop/src/main/runtime-host-client.ts | 7 +
.../main/runtime-host-collaboration-ipc-main.ts | 6 +
.../src/main/runtime-host-desktop-manager.ts | 32 +-
.../src/main/runtime-host-guest-session-mounts.ts | 33 +-
apps/desktop/src/preload/bridge-contract.d.ts | 13 +-
apps/desktop/src/preload/preload.ts | 25 +-
.../desktop/src/renderer/app-shell-turn-actions.ts | 20 +-
.../src/renderer/app-shell-turn-view-model.ts | 6 +-
apps/desktop/src/renderer/app-shell.tsx | 36 +-
.../controller/use-session-collaboration-dialog.ts | 4 +
.../controller/use-turn-request-inbox.ts | 6 +-
.../features/session-collaboration/index.ts | 6 +
.../model/turn-request-inbox.ts | 71 +++-
.../features/session-collaboration/ports.ts | 22 +
.../features/session-collaboration/testing.ts | 3 +
.../turn-request-inbox-context.tsx | 19 +-
.../ui/session-collaboration-join-dialog.tsx | 5 +
.../ui/session-guest-turn-actions.tsx | 161 ++++++++
.../ui/session-turn-request-approval.tsx | 47 ++-
.../ui/session-turn-request-composer.tsx | 451 +++++++++++++++++++++
.../src/renderer/locales/conversation-copy.ts | 5 +-
.../renderer/locales/session-collaboration-copy.ts | 20 +
.../create-session-collaboration-services.ts | 8 +
.../src/renderer/session-collaboration-dialog.tsx | 7 +-
.../src/renderer/session-turn-request-composer.tsx | 227 -----------
apps/desktop/src/renderer/styles/astryx-mount.css | 9 +
apps/desktop/src/renderer/styles/shell-layout.css | 60 ++-
.../src/renderer/use-turn-action-registry.ts | 6 +-
apps/desktop/src/shared/session-collaboration.d.ts | 1 +
apps/desktop/stories/app-shell.stories.tsx | 1 -
.../stories/shared-session-guest.stories.tsx | 168 ++++++++
docs/astryx-surface-file-inventory.md | 5 +-
docs/astryx-surface-file-inventory.paths | 3 +-
.../runtime-host-operator-command.test.ts | 1 +
.../session-collaboration-authority.test.ts | 138 ++++++-
packages/runtime-host/src/protocol/index.ts | 5 +-
.../src/protocol/session-collaboration.ts | 67 ++-
.../runtime-host/src/server/access-authority.ts | 64 ++-
.../src/server/access-credential-store.ts | 1 +
.../src/server/execution-composition.ts | 1 +
packages/runtime-host/src/server/host-kernel.ts | 9 +
.../session-turn-access-request-coordinator.ts | 67 +--
50 files changed, 1955 insertions(+), 413 deletions(-)
create mode 100644 apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts
create mode 100644 apps/desktop/src/renderer/features/session-collaboration/ui/session-guest-turn-actions.tsx
create mode 100644 apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-composer.tsx
delete mode 100644 apps/desktop/src/renderer/session-turn-request-composer.tsx
create mode 100644 apps/desktop/stories/shared-session-guest.stories.tsx
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/02: feat(desktop): complete shared Session Guest controls (#4608)
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 6cfb832e5271d58ef2d7b14c18474680a4350aa1
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Sep 3 14:31:45 2026 +0800
feat(desktop): complete shared Session Guest controls (#4608)
Generated-by: Codex
---
apps/desktop/renderer-architecture.json | 42 +-
.../main/__tests__/app-shell-turn-actions.test.ts | 18 +-
.../session-collaboration-join-dialog.test.ts | 12 +
.../session-turn-request-composer.test.ts | 317 +++++++++++++++
.../session-turn-request-inbox-model.test.ts | 37 +-
apps/desktop/src/main/runtime-host-client.ts | 7 +
.../main/runtime-host-collaboration-ipc-main.ts | 6 +
apps/desktop/src/preload/bridge-contract.d.ts | 13 +-
apps/desktop/src/preload/preload.ts | 25 +-
.../desktop/src/renderer/app-shell-turn-actions.ts | 20 +-
.../src/renderer/app-shell-turn-view-model.ts | 6 +-
apps/desktop/src/renderer/app-shell.tsx | 36 +-
.../controller/use-session-collaboration-dialog.ts | 4 +
.../controller/use-turn-request-inbox.ts | 6 +-
.../features/session-collaboration/index.ts | 6 +
.../model/turn-request-inbox.ts | 71 +++-
.../features/session-collaboration/ports.ts | 22 +
.../features/session-collaboration/testing.ts | 3 +
.../turn-request-inbox-context.tsx | 19 +-
.../ui/session-guest-turn-actions.tsx | 161 ++++++++
.../ui/session-turn-request-approval.tsx | 47 ++-
.../ui/session-turn-request-composer.tsx | 451 +++++++++++++++++++++
.../src/renderer/locales/conversation-copy.ts | 5 +-
.../renderer/locales/session-collaboration-copy.ts | 16 +
.../create-session-collaboration-services.ts | 8 +
.../src/renderer/session-collaboration-dialog.tsx | 7 +-
.../src/renderer/session-turn-request-composer.tsx | 227 -----------
apps/desktop/src/renderer/styles/astryx-mount.css | 9 +
apps/desktop/src/renderer/styles/shell-layout.css | 60 ++-
.../src/renderer/use-turn-action-registry.ts | 6 +-
apps/desktop/stories/app-shell.stories.tsx | 1 -
.../stories/shared-session-guest.stories.tsx | 168 ++++++++
docs/astryx-surface-file-inventory.md | 5 +-
docs/astryx-surface-file-inventory.paths | 3 +-
.../runtime-host-operator-command.test.ts | 1 +
.../session-collaboration-authority.test.ts | 138 ++++++-
packages/runtime-host/src/protocol/index.ts | 5 +-
.../src/protocol/session-collaboration.ts | 67 ++-
.../runtime-host/src/server/access-authority.ts | 64 ++-
.../src/server/access-credential-store.ts | 1 +
.../src/server/execution-composition.ts | 1 +
packages/runtime-host/src/server/host-kernel.ts | 9 +
.../session-turn-access-request-coordinator.ts | 67 +--
43 files changed, 1815 insertions(+), 382 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index fbbc2ae3d0..387101dc9c 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -121,7 +121,6 @@
"src/renderer/session-read-state.ts",
"src/renderer/session-status-presentation.ts",
"src/renderer/session-trace-refresh.ts",
- "src/renderer/session-turn-request-composer.tsx",
"src/renderer/session-workspace-actions.ts",
"src/renderer/session-workspace-errors.ts",
"src/renderer/settings/about-settings-page.tsx",
@@ -763,7 +762,7 @@
"@maka/ui": 1
},
"importSpecifiers": 10,
- "nonTriviaTokens": 654
+ "nonTriviaTokens": 650
},
"src/renderer/app-shell-turn-view-model.ts": {
"importDeclarations": 7,
@@ -787,10 +786,10 @@
"react": 1
},
"importSpecifiers": 18,
- "nonTriviaTokens": 1425
+ "nonTriviaTokens": 1410
},
"src/renderer/app-shell.tsx": {
- "importDeclarations": 105,
+ "importDeclarations": 103,
"bridgePaths": {
"window.maka.app.installUpdate": 1,
"window.maka.app.retryUpdateDownload": 1,
@@ -923,7 +922,6 @@
"./live-content-seed": 1,
"./live-turn-reconciler": 1,
"./locales/conversation-copy": 1,
- "./locales/session-collaboration-copy": 1,
"./locales/shell-copy": 1,
"./locales/shell-remaining-copy.js": 1,
"./model-connection-errors": 1,
@@ -933,7 +931,6 @@
"./plan-mode-panel": 1,
"./scroll-motion-policy": 1,
"./session-collaboration-dialog": 1,
- "./session-turn-request-composer.js": 1,
"./session-workspace-errors": 1,
"./settings/provider-brand-marks": 1,
"./settings/provider-display": 1,
@@ -983,8 +980,8 @@
"@maka/ui/icons": 1,
"react": 1
},
- "importSpecifiers": 186,
- "nonTriviaTokens": 15725
+ "importSpecifiers": 184,
+ "nonTriviaTokens": 15692
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
@@ -2386,8 +2383,8 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
+ "./features/session-collaboration": 1,
"./locales/session-collaboration-copy.js": 1,
- "./session-turn-request-composer.js": 1,
"@astryxdesign/core": 1,
"@astryxdesign/core/Dialog": 1,
"@astryxdesign/core/Layout": 1,
@@ -2502,33 +2499,6 @@
"@maka/core/events": 1
}
},
- "src/renderer/session-turn-request-composer.tsx": {
- "bridgePaths": {
- "window.maka.sessionCollaboration.acknowledgeTurnRequest": 1,
- "window.maka.sessionCollaboration.getTurnRequests": 2,
- "window.maka.sessionCollaboration.requestTurn": 1
- },
- "environmentCapabilities": {
- "window.clearTimeout": 1,
- "window.setTimeout": 1
- },
- "hookCalls": {
- "useEffect": 1,
- "useRef": 2,
- "useState": 6,
- "useToast": 1,
- "useUiLocale": 1
- },
- "lifecycleMethods": {},
- "unresolvedDependencies": 0,
- "actionFactories": [],
- "dependencyPaths": {
- "./locales/session-collaboration-copy.js": 1,
- "@maka/runtime-host/protocol": 1,
- "@maka/ui": 1,
- "react": 1
- }
- },
"src/renderer/session-workspace-actions.ts": {
"bridgePaths": {
"window.maka.sessions.queryCancelledMessages": 1
diff --git a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts
index dca60bfefa..244751ccd5 100644
--- a/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts
+++ b/apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts
@@ -38,18 +38,20 @@ test('preserves a Branch copy identity after an ambiguous failure and completes
const actions = createAppShellTurnActions({
uiLocale: 'en',
activeIdRef: { current: 'branch-action-source' },
- addPendingTurnAction: (key) => {
- if (pending.has(key)) return false;
- pending.add(key);
- return true;
- },
- clearPendingTurnAction: (key) => {
- pending.delete(key);
+ turnActionRegistry: {
+ addKey: (key) => {
+ if (pending.has(key)) return false;
+ pending.add(key);
+ return true;
+ },
+ clearKey: (key) => {
+ pending.delete(key);
+ },
+ keyOf: (sessionId, turnId, actionId) => `${sessionId}:${turnId}:${actionId}`,
},
openSessionInChat: (sessionId) => {
opened.push(sessionId);
},
- pendingKeyOf: (sessionId, turnId, actionId) => `${sessionId}:${turnId}:${actionId}`,
refreshMessages: async () => true,
refreshSessions: async () => [],
setMessages: () => undefined,
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 6027f80f36..d66c187749 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
@@ -68,6 +68,12 @@ test('keeps loading progress visible while an irreversible import settles', asyn
readInvitationClipboard: async () => '',
listMounts: async () => [],
removeMount: async () => undefined,
+ requestTurn: async () => {
+ throw new Error('unused');
+ },
+ getTurnRequests: async () => ({ canRequestTurns: false, requests: [] }),
+ acknowledgeTurnRequest: async () => ({ acknowledged: false }),
+ withdrawTurnRequest: async () => ({ withdrawn: false }),
getPendingTurnRequests: async () => [],
decideTurnRequest: async () => {
throw new Error('unused');
@@ -119,6 +125,12 @@ test('closes as a retained background recovery instead of reporting a failed joi
readInvitationClipboard: async () => '',
listMounts: async () => [],
removeMount: async () => undefined,
+ requestTurn: async () => {
+ throw new Error('unused');
+ },
+ getTurnRequests: async () => ({ canRequestTurns: false, requests: [] }),
+ acknowledgeTurnRequest: async () => ({ acknowledged: false }),
+ withdrawTurnRequest: async () => ({ withdrawn: false }),
getPendingTurnRequests: async () => [],
decideTurnRequest: async () => {
throw new Error('unused');
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
new file mode 100644
index 0000000000..68bf76d55d
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/session-turn-request-composer.test.ts
@@ -0,0 +1,317 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+import { act, createElement } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { parseHTML } from 'linkedom';
+import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui';
+import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import {
+ SessionCollaborationServicesProvider,
+ SessionTurnRequestComposer,
+ type SessionCollaborationServices,
+} from '../../renderer/features/session-collaboration/testing.js';
+
+const originalGlobals = {
+ document: globalThis.document,
+ window: globalThis.window,
+ HTMLElement: globalThis.HTMLElement,
+ HTMLIFrameElement: globalThis.HTMLIFrameElement,
+ Event: globalThis.Event,
+ Node: globalThis.Node,
+ CSS: globalThis.CSS,
+ getComputedStyle: globalThis.getComputedStyle,
+ matchMedia: globalThis.matchMedia,
+ requestAnimationFrame: globalThis.requestAnimationFrame,
+ cancelAnimationFrame: globalThis.cancelAnimationFrame,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
+ .IS_REACT_ACT_ENVIRONMENT,
+};
+
+let mountedRoot: Root | undefined;
+
+afterEach(async () => {
+ if (mountedRoot) await act(() => mountedRoot?.unmount());
+ mountedRoot = undefined;
+ Object.assign(globalThis, originalGlobals);
+});
+
+test('keeps a newer Guest draft across remount when an old request settles later', async () => {
+ let sessionOneQueryCount = 0;
+ let resolveOldReconciliation: ((requests: {
+ readonly canRequestTurns: boolean;
+ readonly requests: readonly SessionTurnAccessRequest[];
+ }) => void) | undefined;
+ const oldReconciliation = new Promise<{
+ readonly canRequestTurns: boolean;
+ readonly requests: readonly SessionTurnAccessRequest[];
+ }>((resolve) => {
+ resolveOldReconciliation = resolve;
+ });
+ const accepted: SessionTurnAccessRequest = {
+ requestId: 'request-1',
+ principalId: 'guest-1',
+ grantId: 'grant-1',
+ intent: {
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ content: { text: 'submitted text' },
+ },
+ createdAt: '2026-09-03T00:00:00.000Z',
+ state: { kind: 'pending' },
+ };
+ const services: SessionCollaborationServices = {
+ importInvitation: async () => ({ kind: 'connected', mountId: 'unused' }),
+ cancelImport: async () => 'cancelled',
+ readInvitationClipboard: async () => '',
+ listMounts: async () => [],
+ removeMount: async () => undefined,
+ requestTurn: async () => {
+ throw new Error('connection lost after dispatch');
+ },
+ getTurnRequests: async (sessionId) => {
+ if (sessionId !== 'session-1') {
+ return { canRequestTurns: true, requests: [] };
+ }
+ sessionOneQueryCount += 1;
+ if (sessionOneQueryCount === 2) return oldReconciliation;
+ return { canRequestTurns: true, requests: [] };
+ },
+ acknowledgeTurnRequest: async () => ({ acknowledged: false }),
+ withdrawTurnRequest: async () => ({ withdrawn: false }),
+ getPendingTurnRequests: async () => [],
+ decideTurnRequest: async () => {
+ throw new Error('unused');
+ },
+ createOperationId: () => 'turn-1',
+ };
+ const { document } = installDom(() => undefined);
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ mountedRoot = createRoot(container);
+
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-1'));
+ await Promise.resolve();
+ });
+
+ const textbox = document.querySelector<HTMLElement>('[role="textbox"]');
+ assert.ok(textbox);
+ await edit(textbox, 'submitted text');
+ await pressEnter(textbox);
+ assert.equal(sessionOneQueryCount, 2);
+
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-2'));
+ await Promise.resolve();
+ });
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-1'));
+ await Promise.resolve();
+ });
+ const remountedTextbox = document.querySelector<HTMLElement>('[role="textbox"]');
+ assert.ok(remountedTextbox);
+ await edit(remountedTextbox, 'next draft');
+
+ await act(async () => {
+ resolveOldReconciliation?.({ canRequestTurns: true, requests: [accepted] });
+ await Promise.resolve();
+ });
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-2'));
+ await Promise.resolve();
+ });
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-1'));
+ await Promise.resolve();
+ });
+
+ assert.equal(
+ document.querySelector<HTMLElement>('[role="textbox"]')?.textContent,
+ 'next draft',
+ );
+});
+
+test('resumes an in-flight Guest request across remount without submitting it twice', async () => {
+ let sessionQueryCount = 0;
+ let requestCount = 0;
+ let resolveRequest: ((request: SessionTurnAccessRequest) => void) | undefined;
+ const requestResult = new Promise<SessionTurnAccessRequest>((resolve) => {
+ resolveRequest = resolve;
+ });
+ const accepted: SessionTurnAccessRequest = {
+ requestId: 'request-resumed',
+ principalId: 'guest-1',
+ grantId: 'grant-1',
+ intent: {
+ sessionId: 'session-resumed',
+ turnId: 'turn-resumed',
+ content: { text: 'one request only' },
+ },
+ createdAt: '2026-09-03T00:00:00.000Z',
+ state: { kind: 'pending' },
+ };
+ const services: SessionCollaborationServices = {
+ importInvitation: async () => ({ kind: 'connected', mountId: 'unused' }),
+ cancelImport: async () => 'cancelled',
+ readInvitationClipboard: async () => '',
+ listMounts: async () => [],
+ removeMount: async () => undefined,
+ requestTurn: async () => {
+ requestCount += 1;
+ return requestResult;
+ },
+ getTurnRequests: async (sessionId) => {
+ if (sessionId !== 'session-resumed') {
+ return { canRequestTurns: true, requests: [] };
+ }
+ sessionQueryCount += 1;
+ return {
+ canRequestTurns: true,
+ requests: sessionQueryCount === 1 ? [] : [accepted],
+ };
+ },
+ acknowledgeTurnRequest: async () => ({ acknowledged: false }),
+ withdrawTurnRequest: async () => ({ withdrawn: false }),
+ getPendingTurnRequests: async () => [],
+ decideTurnRequest: async () => {
+ throw new Error('unused');
+ },
+ createOperationId: () => 'turn-resumed',
+ };
+ const { document } = installDom(() => undefined);
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ mountedRoot = createRoot(container);
+
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-resumed'));
+ await Promise.resolve();
+ });
+ const textbox = document.querySelector<HTMLElement>('[role="textbox"]');
+ assert.ok(textbox);
+ await edit(textbox, 'one request only');
+ await pressEnter(textbox);
+ assert.equal(requestCount, 1);
+
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-away'));
+ await Promise.resolve();
+ });
+ await act(async () => {
+ mountedRoot?.render(renderComposer(services, 'session-resumed'));
+ await Promise.resolve();
+ });
+
+ assert.equal(
+ document.querySelector<HTMLElement>('[role="textbox"]')?.textContent,
+ '',
+ );
+ assert.equal(requestCount, 1);
+
+ await act(async () => {
+ resolveRequest?.(accepted);
+ await Promise.resolve();
+ });
+});
+
+function renderComposer(services: SessionCollaborationServices, sessionId: string) {
+ return createElement(LocaleProvider, {
+ locale: 'en',
+ children: createElement(AstryxLocaleProvider, {
+ children: createElement(ToastProvider, {
+ children: createElement(SessionCollaborationServicesProvider, {
+ services,
+ children: createElement(SessionTurnRequestComposer, { sessionId }),
+ }),
+ }),
+ }),
+ });
+}
+
+function installDom(captureRefresh: (refresh: () => void) => void): { document: Document } {
+ const parsed = parseHTML('<html><body><div id="root"></div></body></html>');
+ const { document, window } = parsed;
+ const nativeSetTimeout = globalThis.setTimeout;
+ const nativeClearTimeout = globalThis.clearTimeout;
+ const refreshTimer = 2_000_000_001;
+ 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() {},
+ setTimeout(handler: TimerHandler, timeout?: number) {
+ if (timeout === 2_000 && typeof handler === 'function') {
+ captureRefresh(handler as () => void);
+ return refreshTimer;
+ }
+ return nativeSetTimeout(handler as (...args: unknown[]) => void, timeout) as unknown as number;
+ },
+ clearTimeout(handle: number) {
+ if (handle !== refreshTimer) nativeClearTimeout(handle);
+ },
+ });
+ Object.assign(globalThis, {
+ document,
+ window,
+ matchMedia,
+ HTMLElement: window.HTMLElement,
+ HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
+ Event: window.Event,
+ Node: window.Node,
+ CSS: { escape: (value: string) => value },
+ getComputedStyle,
+ requestAnimationFrame: (callback: FrameRequestCallback) => nativeSetTimeout(callback, 0),
+ cancelAnimationFrame: (handle: number) => clearTimeout(handle),
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ return { document };
+}
+
+async function edit(textbox: HTMLElement, value: string): Promise<void> {
+ await act(async () => {
+ textbox.textContent = value;
+ textbox.dispatchEvent(new Event('input', { bubbles: true }));
+ await Promise.resolve();
+ });
+}
+
+async function pressEnter(textbox: HTMLElement): Promise<void> {
+ await act(async () => {
+ const event = new Event('keydown', { bubbles: true });
+ Object.defineProperty(event, 'key', { value: 'Enter' });
+ textbox.dispatchEvent(event);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
diff --git a/apps/desktop/src/main/__tests__/session-turn-request-inbox-model.test.ts b/apps/desktop/src/main/__tests__/session-turn-request-inbox-model.test.ts
index eba12f0ea8..5cb34ce1da 100644
--- a/apps/desktop/src/main/__tests__/session-turn-request-inbox-model.test.ts
+++ b/apps/desktop/src/main/__tests__/session-turn-request-inbox-model.test.ts
@@ -21,6 +21,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
import {
+ describeOwnerTurnRequestIntent,
+ describeTurnRequestIntent,
groupPendingTurnRequests,
samePendingTurnRequests,
turnRequestPreview,
@@ -45,7 +47,40 @@ test('Turn-request inbox keeps only actionable requests and detects new arrivals
assert.deepEqual(unseenTurnRequests([first, second], new Set(['request-1'])), [second]);
assert.equal(samePendingTurnRequests([first, second], [first, second]), true);
assert.equal(samePendingTurnRequests([first], [second]), false);
- assert.equal(turnRequestPreview(first.intent.content.text), 'Review this change');
+ assert.equal(
+ turnRequestPreview(describeTurnRequestIntent(first.intent, 'Regenerate response')),
+ 'Review this change',
+ );
+ assert.equal(
+ describeTurnRequestIntent(
+ {
+ sessionId: 'session-1',
+ turnId: 'turn-regenerated',
+ sourceTurnId: 'turn-original',
+ },
+ 'Regenerate response',
+ ),
+ 'Regenerate response',
+ );
+ assert.equal(
+ describeOwnerTurnRequestIntent(
+ {
+ sessionId: 'session-1',
+ turnId: 'turn-regenerated',
+ sourceTurnId: 'turn-original',
+ },
+ [{
+ type: 'user',
+ id: 'message-original',
+ turnId: 'turn-original',
+ ts: 1,
+ text: 'Explain the failed deployment',
+ displayText: 'Why did deployment fail?',
+ }],
+ 'Regenerate response',
+ ),
+ 'Regenerate response: Why did deployment fail?',
+ );
});
function request(
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 54e5932172..5c67b9ff82 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -117,6 +117,7 @@ import {
type CollaborationTurnRequestAcknowledgeResult,
type CollaborationTurnRequestDecideResult,
type CollaborationTurnRequestQueryResult,
+ type CollaborationTurnRequestWithdrawResult,
type SessionCollaborationGrantKind,
type SessionTurnAccessRequest,
type SessionTurnRequestIntent,
@@ -353,6 +354,12 @@ export class DesktopRuntimeHostClient {
return this.request('collaboration.turn-request.acknowledge', { requestId });
}
+ withdrawCollaborationTurnRequest(
+ requestId: string,
+ ): Promise<CollaborationTurnRequestWithdrawResult> {
+ return this.request('collaboration.turn-request.withdraw', { requestId });
+ }
+
decideCollaborationTurnRequest(
requestId: string,
decision: 'approve' | 'reject',
diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
index c025db70b8..502d5b9b23 100644
--- a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts
@@ -36,6 +36,7 @@ export function registerRuntimeHostCollaborationIpc(
| 'createCollaborationTurnRequest'
| 'decideCollaborationTurnRequest'
| 'queryCollaborationTurnRequests'
+ | 'withdrawCollaborationTurnRequest'
| 'queryCollaborationAccess'
| 'revokeCollaborationGrant'
| 'revokeCollaborationPrincipal'
@@ -111,6 +112,11 @@ export function registerRuntimeHostCollaborationIpc(
(_event, requestId: unknown) =>
client.acknowledgeCollaborationTurnRequest(requiredId(requestId, 'Turn request')),
);
+ ipcMain.handle(
+ 'session-collaboration:turn-request:withdraw',
+ (_event, requestId: unknown) =>
+ client.withdrawCollaborationTurnRequest(requiredId(requestId, 'Turn request')),
+ );
ipcMain.handle(
'session-collaboration:turn-request:decide',
(_event, requestId: unknown, decision: unknown) =>
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 8c4c5431d9..dae56b81e8 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -120,6 +120,7 @@ import type {
CollaborationTurnRequestDecideResult,
CollaborationTurnRequestQueryResult,
SessionTurnAccessRequest,
+ CollaborationTurnRequestWithdrawResult,
} from '@maka/runtime-host/protocol';
import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client';
import type {
@@ -767,7 +768,13 @@ export interface MakaBridge {
removeMount(mountId: string): Promise<void>;
requestTurn(
sessionId: string,
- input: { readonly turnId: string; readonly text: string },
+ input:
+ | { readonly kind: 'start'; readonly turnId: string; readonly text: string }
+ | {
+ readonly kind: 'regenerate';
+ readonly turnId: string;
+ readonly sourceTurnId: string;
+ },
): Promise<SessionTurnAccessRequest>;
getTurnRequests(sessionId: string): Promise<CollaborationTurnRequestQueryResult>;
/** Pending Owner decisions across every connected Owner Runtime Host. */
@@ -776,6 +783,10 @@ export interface MakaBridge {
sessionId: string,
requestId: string,
): Promise<CollaborationTurnRequestAcknowledgeResult>;
+ withdrawTurnRequest(
+ sessionId: string,
+ requestId: string,
+ ): Promise<CollaborationTurnRequestWithdrawResult>;
decideTurnRequest(
sessionId: string,
requestId: string,
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 10949441a2..da59cbf5de 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -225,6 +225,7 @@ import {
type OperationOutcome,
type OperationOutput,
type CollaborationTurnRequestQueryResult,
+ type CollaborationTurnRequestWithdrawResult,
type SessionTurnAccessRequest,
} from '@maka/runtime-host/protocol';
import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client';
@@ -1338,11 +1339,17 @@ const makaBridge = {
return ipcRenderer.invoke(
'session-collaboration:turn-request:create',
session.scope,
- {
- sessionId: session.sessionId,
- turnId: input.turnId,
- content: { text: input.text },
- },
+ input.kind === 'start'
+ ? {
+ sessionId: session.sessionId,
+ turnId: input.turnId,
+ content: { text: input.text },
+ }
+ : {
+ sessionId: session.sessionId,
+ turnId: input.turnId,
+ sourceTurnId: input.sourceTurnId,
+ },
);
},
async getTurnRequests(sessionId) {
@@ -1383,6 +1390,14 @@ const makaBridge = {
requestId,
);
},
+ async withdrawTurnRequest(sessionId, requestId) {
+ const session = await runtimeHostSessionRef(sessionId);
+ return ipcRenderer.invoke(
+ 'session-collaboration:turn-request:withdraw',
+ session.scope,
+ requestId,
+ ) as Promise<CollaborationTurnRequestWithdrawResult>;
+ },
async decideTurnRequest(sessionId, requestId, decision) {
const session = await runtimeHostSessionRef(sessionId);
return ipcRenderer.invoke(
diff --git a/apps/desktop/src/renderer/app-shell-turn-actions.ts b/apps/desktop/src/renderer/app-shell-turn-actions.ts
index 80ac9e0a11..4aca3e4490 100644
--- a/apps/desktop/src/renderer/app-shell-turn-actions.ts
+++ b/apps/desktop/src/renderer/app-shell-turn-actions.ts
@@ -50,10 +50,12 @@ export interface AppShellTurnActions {
export function createAppShellTurnActions(deps: {
uiLocale: UiLocale;
activeIdRef: RefBox<string | undefined>;
- addPendingTurnAction: (key: string) => boolean;
- clearPendingTurnAction: (key: string) => void;
+ turnActionRegistry: {
+ addKey(key: string): boolean;
+ clearKey(key: string): void;
+ keyOf(sessionId: string, turnId: string, actionId: string): string;
+ };
openSessionInChat: (sessionId: string, turnId?: string) => void;
- pendingKeyOf: (sessionId: string, turnId: string, actionId: TurnFooterActionMeta['id']) => string;
refreshMessages: (sessionId: string) => Promise<boolean>;
refreshSessions: () => Promise<DesktopSessionSummary[]>;
setMessages: MessageListUpdater;
@@ -62,10 +64,8 @@ export function createAppShellTurnActions(deps: {
const {
uiLocale,
activeIdRef,
- addPendingTurnAction,
- clearPendingTurnAction,
+ turnActionRegistry,
openSessionInChat,
- pendingKeyOf,
refreshMessages,
refreshSessions,
setMessages,
@@ -73,15 +73,15 @@ export function createAppShellTurnActions(deps: {
} = deps;
const copy = getDesktopConversationCopy(uiLocale).actions;
- async function handleTurnFooterAction(turnId: string, actionId: TurnFooterActionMeta['id']): Promise<void> {
+ async function handleTurnFooterAction(turnId: string, actionId: TurnFooterActionMeta['id']) {
if (actionId === 'copy') return; // handled in-component
const sessionId = activeIdRef.current;
if (!sessionId) return;
- const key = pendingKeyOf(sessionId, turnId, actionId);
+ const key = turnActionRegistry.keyOf(sessionId, turnId, actionId);
// Ref-backed guard blocks same-frame double clicks before React has
// committed the disabled state. State alone is too late here because
// retry/regenerate IPC returns after starting the stream asynchronously.
- if (!addPendingTurnAction(key)) return;
+ if (!turnActionRegistry.addKey(key)) return;
try {
if (actionId === 'regenerate') {
await window.maka.sessions.regenerateTurn(sessionId, {
@@ -125,7 +125,7 @@ export function createAppShellTurnActions(deps: {
);
}
} finally {
- clearPendingTurnAction(key);
+ turnActionRegistry.clearKey(key);
}
}
diff --git a/apps/desktop/src/renderer/app-shell-turn-view-model.ts b/apps/desktop/src/renderer/app-shell-turn-view-model.ts
index a8061ceaf5..09c6c450a8 100644
--- a/apps/desktop/src/renderer/app-shell-turn-view-model.ts
+++ b/apps/desktop/src/renderer/app-shell-turn-view-model.ts
@@ -45,7 +45,6 @@ export interface AppShellTurnPresentationContext {
activeId: string | undefined;
pendingTurnActions: ReadonlySet<string>;
uiLocale: UiLocale;
- pendingKeyOf(sessionId: string, turnId: string, actionId: TurnFooterActionMeta['id']): string;
}
export interface AppShellTurnPresentationDerivation {
@@ -129,7 +128,10 @@ export function createAppShellTurnPresentationDerivation(): AppShellTurnPresenta
const lineageEntry = lineage.get(turn.turnId);
const pendingForTurn = new Set<TurnFooterActionMeta['id']>();
for (const id of PENDING_ACTION_IDS) {
- if (context.activeId && context.pendingTurnActions.has(context.pendingKeyOf(context.activeId, turn.turnId, id))) {
+ if (
+ context.activeId &&
+ context.pendingTurnActions.has(`${context.activeId}:${turn.turnId}:${id}`)
+ ) {
pendingForTurn.add(id);
}
}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 84a2984fa1..a847a21794 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -105,9 +105,7 @@ import {
import { TaskEntryHost, useTaskEntryController } from './features/task-entry';
import { useNewTaskChoice } from './use-new-task-choice';
import { SessionCollaborationDialog } from './session-collaboration-dialog';
-import { SessionTurnRequestComposer } from './session-turn-request-composer.js';
import * as SessionCollaboration from './features/session-collaboration';
-import { getSessionCollaborationCopy } from './locales/session-collaboration-copy';
import { NEW_TASK_PENDING_KEY } from './pending-items';
import { parseDesktopSlashCommand } from './desktop-slash-command';
import {
@@ -648,7 +646,6 @@ function AppShellContent({
setUiLocalePreference,
});
const shellCopy = getShellCopy(uiLocale).app;
- const sessionCollaborationCopy = getSessionCollaborationCopy(uiLocale);
const previousInterruptionCopy =
getShellRemainingCopy(uiLocale).previousMainProcessInterruption;
const desktopConversationCopy = getDesktopConversationCopy(uiLocale);
@@ -967,9 +964,6 @@ function AppShellContent({
// Session-row mutations live in Session Navigation; the per-session mode and
// model claims live in the session UI store.
const turnActionRegistry = useTurnActionRegistry();
- const pendingTurnActions = turnActionRegistry.keys;
- const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) =>
- `${sessionId}:${turnId}:${actionId}`;
// A hoisted declaration on purpose: `dropDisplayEvents` is destructured
// hundreds of lines below, and the rail does not need this identity held
@@ -1079,8 +1073,7 @@ function AppShellContent({
// keeps the props a memoized TurnView reads stable (#2030).
const deriveTurnPresentation = useAppShellTurnPresentation({
activeId,
- pendingTurnActions,
- pendingKeyOf,
+ pendingTurnActions: turnActionRegistry.keys,
uiLocale,
});
@@ -1838,10 +1831,8 @@ function AppShellContent({
const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, {
uiLocale,
activeIdRef,
- addPendingTurnAction: turnActionRegistry.addKey,
- clearPendingTurnAction: turnActionRegistry.clearKey,
+ turnActionRegistry,
openSessionInChat,
- pendingKeyOf,
refreshMessages,
refreshSessions,
setMessages,
@@ -2694,9 +2685,7 @@ function AppShellContent({
<ComposerMentionsProvider {...composerMentionsSurface}>
<SessionCollaboration.SessionTurnRequestInboxProvider
sessions={sessions}
- toast={toastApi}
onOpenSession={openSession}
- copy={sessionCollaborationCopy}
>
<div
className="appFrame agents-layout-root"
@@ -2778,7 +2767,7 @@ function AppShellContent({
activeDesktopSession.profileKind === 'environment'
? undefined
: {
- label: getSessionCollaborationCopy(uiLocale).shareAction,
+ label: sharedSessionDialog.shareActionLabel,
onClick: () => sharedSessionDialog.openSession(activeDesktopSession),
}
}
@@ -2899,6 +2888,8 @@ function AppShellContent({
{!sharedSessionActive && activeId ? (
<SessionCollaboration.SessionTurnRequestApprovalForSession
sessionId={activeId}
+ messages={messages}
+ onOpenSession={openSessionInChat}
/>
) : null}
{!sharedSessionActive && navSelection.section === 'sessions' &&
@@ -2923,7 +2914,9 @@ function AppShellContent({
/>
) : null}
{sharedSessionActive && activeId ? (
- <SessionTurnRequestComposer key={activeId} sessionId={activeId} />
+ <SessionCollaboration.SessionTurnRequestComposer
+ sessionId={activeId}
+ />
) : (
<ChatComposerRegion
workspacePicker={workspacePicker}
@@ -3085,6 +3078,13 @@ function AppShellContent({
}
>
{navSelection.section === 'sessions' ? (
+ <SessionCollaboration.SessionGuestTurnActionBoundary
+ sessionId={sharedSessionActive ? activeId : undefined}
+ deriveTurnPresentation={deriveTurnPresentation}
+ ownerTurnFooterAction={handleTurnFooterAction}
+ turnActionRegistry={turnActionRegistry}
+ >
+ {(turnActions) => (
<ChatMessageSurface
sessionUiController={sessionUiController}
activeSessionId={activeId}
@@ -3117,8 +3117,8 @@ function AppShellContent({
messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined}
messageLoadRetryPending={activeId ? messageRetryPendingBySession[activeId] === true : false}
onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined}
- deriveTurnPresentation={deriveTurnPresentation}
- onTurnFooterAction={sharedSessionActive ? undefined : handleTurnFooterAction}
+ deriveTurnPresentation={turnActions.deriveTurnPresentation}
+ onTurnFooterAction={turnActions.onTurnFooterAction}
onSwitchToBypassAndRetry={sharedSessionActive ? undefined : handleSwitchToBypassAndRetry}
onEditUserMessage={sharedSessionActive ? undefined : (turnId) => { void beginEditUserMessage(turnId); }}
safeResumeAction={!sharedSessionActive && activeId ? {
@@ -3242,6 +3242,8 @@ function AppShellContent({
}}
conversationItems={planConversationItems}
/>
+ )}
+ </SessionCollaboration.SessionGuestTurnActionBoundary>
) : null}
</ChatSurfaceLayout>
)}
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 87de07e793..d01c3109cd 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
@@ -18,6 +18,8 @@
*/
import { useState } from 'react';
+import { useUiLocale } from '@maka/ui';
+import { getSessionCollaborationCopy } from '../../../locales/session-collaboration-copy.js';
export interface SessionCollaborationDialogTarget {
readonly sessionId: string;
@@ -27,9 +29,11 @@ export interface SessionCollaborationDialogTarget {
export function useSessionCollaborationDialog() {
const [target, setTarget] = useState<SessionCollaborationDialogTarget>();
+ const shareActionLabel = getSessionCollaborationCopy(useUiLocale()).shareAction;
return {
target,
+ shareActionLabel,
isOpen: target !== undefined,
open: setTarget,
openSession(session: {
diff --git a/apps/desktop/src/renderer/features/session-collaboration/controller/use-turn-request-inbox.ts b/apps/desktop/src/renderer/features/session-collaboration/controller/use-turn-request-inbox.ts
index e7618ac395..eb2180299e 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/controller/use-turn-request-inbox.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/controller/use-turn-request-inbox.ts
@@ -22,6 +22,7 @@ import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
import type { ToastApi } from '@maka/ui';
import {
groupPendingTurnRequests,
+ describeTurnRequestIntent,
samePendingTurnRequests,
turnRequestPreview,
unseenTurnRequests,
@@ -40,6 +41,7 @@ export function useSessionTurnRequestInbox(input: {
readonly newTurnRequestSummary: (count: number) => string;
readonly reviewTurnRequest: string;
readonly turnRequests: string;
+ readonly regenerateRequest: string;
};
}) {
const services = useSessionCollaborationServices();
@@ -67,7 +69,9 @@ export function useSessionTurnRequestInbox(input: {
variant: 'warning',
title: copy.newTurnRequestTitle(unseen.length),
description: unseen.length === 1
- ? `${sessionName} · ${turnRequestPreview(first.intent.content.text)}`
+ ? `${sessionName} · ${turnRequestPreview(
+ describeTurnRequestIntent(first.intent, copy.regenerateRequest),
+ )}`
: copy.newTurnRequestSummary(unseen.length),
duration: 10_000,
action: {
diff --git a/apps/desktop/src/renderer/features/session-collaboration/index.ts b/apps/desktop/src/renderer/features/session-collaboration/index.ts
index 5ffb61409b..ff9b86258e 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/index.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/index.ts
@@ -20,7 +20,13 @@
export { SessionCollaborationServicesProvider } from './services-context';
export { useSessionCollaborationDialog } from './controller/use-session-collaboration-dialog';
export { SessionCollaborationJoinDialog } from './ui/session-collaboration-join-dialog';
+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/turn-request-inbox.ts b/apps/desktop/src/renderer/features/session-collaboration/model/turn-request-inbox.ts
index 387297f4ff..7b3a544d6e 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/model/turn-request-inbox.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/model/turn-request-inbox.ts
@@ -17,7 +17,11 @@
* under the License.
*/
-import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import type {
+ SessionTurnAccessRequest,
+ SessionTurnRequestIntent,
+} from '@maka/runtime-host/protocol';
+import { userFacingText, type StoredMessage } from '@maka/core/session';
export function groupPendingTurnRequests(
requests: readonly SessionTurnAccessRequest[],
@@ -51,12 +55,75 @@ export function samePendingTurnRequests(
return candidate !== undefined &&
request.requestId === candidate.requestId &&
request.intent.sessionId === candidate.intent.sessionId &&
- request.intent.content.text === candidate.intent.content.text;
+ sameTurnRequestIntent(request.intent, candidate.intent);
});
}
+export function describeTurnRequestIntent(
+ intent: SessionTurnRequestIntent,
+ regenerateLabel: string,
+): string {
+ return 'content' in intent ? intent.content.text : regenerateLabel;
+}
+
+export function describeOwnerTurnRequestIntent(
+ intent: SessionTurnRequestIntent,
+ messages: readonly StoredMessage[],
+ regenerateLabel: string,
+): string {
+ if ('content' in intent) return intent.content.text;
+ const sourceUserMessage = messages.find(
+ (message): message is Extract<StoredMessage, { type: 'user' }> =>
+ message.type === 'user' && message.turnId === intent.sourceTurnId,
+ );
+ const sourceText = sourceUserMessage
+ ? userFacingText(sourceUserMessage)
+ : messages.find(
+ (message): message is Extract<StoredMessage, { type: 'assistant' }> =>
+ message.type === 'assistant' && message.turnId === intent.sourceTurnId,
+ )?.text;
+ return sourceText?.trim() ? `${regenerateLabel}: ${sourceText.trim()}` : regenerateLabel;
+}
+
export function turnRequestPreview(text: string, maxLength = 120): string {
const collapsed = text.replace(/\s+/gu, ' ').trim();
if (collapsed.length <= maxLength) return collapsed;
return `${collapsed.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
}
+
+export function turnRequestStateLabel(
+ request: SessionTurnAccessRequest,
+ copy: {
+ readonly turnRequestPending: string;
+ readonly turnRequestRejected: string;
+ readonly turnRequestApproved: string;
+ readonly turnRequestStarted: string;
+ readonly turnRequestBlocked: string;
+ readonly turnRequestFailed: string;
+ },
+): string {
+ if (request.state.kind === 'pending') return copy.turnRequestPending;
+ if (request.state.kind === 'rejected') return copy.turnRequestRejected;
+ if (request.state.admission === 'pending') return copy.turnRequestApproved;
+ if (request.state.admission === 'started') return copy.turnRequestStarted;
+ if (request.state.admission === 'blocked') return copy.turnRequestBlocked;
+ return copy.turnRequestFailed;
+}
+
+function sameTurnRequestIntent(
+ left: SessionTurnRequestIntent,
+ right: SessionTurnRequestIntent,
+): boolean {
+ if ('content' in left) {
+ return (
+ 'content' in right &&
+ left.turnId === right.turnId &&
+ left.content.text === right.content.text
+ );
+ }
+ return (
+ !('content' in right) &&
+ left.turnId === right.turnId &&
+ left.sourceTurnId === right.sourceTurnId
+ );
+}
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ports.ts b/apps/desktop/src/renderer/features/session-collaboration/ports.ts
index 7e65b3a5ac..40675c265a 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/ports.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/ports.ts
@@ -24,7 +24,10 @@ import type {
SessionCollaborationMountSummary,
} from '../../../shared/session-collaboration.js';
import type {
+ CollaborationTurnRequestAcknowledgeResult,
CollaborationTurnRequestDecideResult,
+ CollaborationTurnRequestQueryResult,
+ CollaborationTurnRequestWithdrawResult,
SessionTurnAccessRequest,
} from '@maka/runtime-host/protocol';
@@ -45,6 +48,25 @@ export interface SessionCollaborationServices {
readInvitationClipboard(): Promise<string>;
listMounts(): Promise<readonly SessionCollaborationMountSummary[]>;
removeMount(mountId: string): Promise<void>;
+ requestTurn(
+ sessionId: string,
+ input:
+ | { readonly kind: 'start'; readonly turnId: string; readonly text: string }
+ | {
+ readonly kind: 'regenerate';
+ readonly turnId: string;
+ readonly sourceTurnId: string;
+ },
+ ): Promise<SessionTurnAccessRequest>;
+ getTurnRequests(sessionId: string): Promise<CollaborationTurnRequestQueryResult>;
+ acknowledgeTurnRequest(
+ sessionId: string,
+ requestId: string,
+ ): Promise<CollaborationTurnRequestAcknowledgeResult>;
+ withdrawTurnRequest(
+ sessionId: string,
+ requestId: string,
+ ): Promise<CollaborationTurnRequestWithdrawResult>;
getPendingTurnRequests(): Promise<readonly SessionTurnAccessRequest[]>;
decideTurnRequest(
sessionId: string,
diff --git a/apps/desktop/src/renderer/features/session-collaboration/testing.ts b/apps/desktop/src/renderer/features/session-collaboration/testing.ts
index ce7ae39442..d5b04f48b6 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/testing.ts
+++ b/apps/desktop/src/renderer/features/session-collaboration/testing.ts
@@ -19,6 +19,8 @@
export {
groupPendingTurnRequests,
+ describeOwnerTurnRequestIntent,
+ describeTurnRequestIntent,
samePendingTurnRequests,
turnRequestPreview,
unseenTurnRequests,
@@ -26,3 +28,4 @@ export {
export type { SessionCollaborationServices } from './ports.js';
export { SessionCollaborationServicesProvider } from './services-context.js';
export { SessionCollaborationJoinDialog } from './ui/session-collaboration-join-dialog.js';
+export { SessionTurnRequestComposer } from './ui/session-turn-request-composer.js';
diff --git a/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx
index 2b7af3eb61..9a4c34458f 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx
+++ b/apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx
@@ -18,7 +18,8 @@
*/
import { createContext, useContext, useMemo, type ReactNode } from 'react';
-import type { ToastApi } from '@maka/ui';
+import { useToast, useUiLocale } from '@maka/ui';
+import { getSessionCollaborationCopy } from '../../locales/session-collaboration-copy.js';
import { useSessionTurnRequestInbox } from './controller/use-turn-request-inbox.js';
export interface SessionTurnRequestInboxCopy {
@@ -28,6 +29,9 @@ export interface SessionTurnRequestInboxCopy {
readonly reviewTurnRequest: string;
readonly turnRequests: string;
readonly ownerTurnRequestTitle: string;
+ readonly ownerRegenerateRequestTitle: string;
+ readonly regenerateRequest: string;
+ readonly viewSourceTurn: string;
readonly reject: string;
readonly approve: string;
readonly moreTurnRequests: (count: number) => string;
@@ -42,20 +46,23 @@ const SessionTurnRequestInboxContext = createContext<SessionTurnRequestInbox | n
export function SessionTurnRequestInboxProvider(props: {
readonly sessions: readonly { readonly id: string; readonly name: string }[];
- readonly toast: ToastApi;
readonly onOpenSession: (sessionId: string) => void;
- readonly copy: SessionTurnRequestInboxCopy;
readonly children?: ReactNode;
}) {
- const inbox = useSessionTurnRequestInbox(props);
+ const copy = getSessionCollaborationCopy(useUiLocale());
+ const inbox = useSessionTurnRequestInbox({
+ ...props,
+ toast: useToast(),
+ copy,
+ });
const value = useMemo<SessionTurnRequestInbox>(
- () => ({ ...inbox, copy: props.copy }),
+ () => ({ ...inbox, copy }),
[
inbox.decide,
inbox.requests,
inbox.requestsBySession,
inbox.workingRequestIds,
- props.copy,
+ copy,
],
);
return (
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-guest-turn-actions.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-guest-turn-actions.tsx
new file mode 100644
index 0000000000..15d87c77af
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-guest-turn-actions.tsx
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useRef, type ReactNode } from 'react';
+import {
+ type TurnFooterActionMeta,
+ type TurnPresentation,
+ type TurnPresentationDeriver,
+ useToast,
+ useUiLocale,
+} from '@maka/ui';
+import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
+import { getSessionCollaborationCopy } from '../../../locales/session-collaboration-copy.js';
+import {
+ getShellCopy,
+ localizedShellErrorMessage,
+} from '../../../locales/shell-copy.js';
+import { useSessionCollaborationServices } from '../services-context.js';
+
+type TurnFooterActionHandler = (
+ turnId: string,
+ actionId: TurnFooterActionMeta['id'],
+) => void | Promise<void>;
+
+interface GuestTurnActions {
+ readonly deriveTurnPresentation: TurnPresentationDeriver;
+ readonly onTurnFooterAction: TurnFooterActionHandler;
+}
+
+export function SessionGuestTurnActionBoundary(props: {
+ readonly sessionId: string | undefined;
+ readonly deriveTurnPresentation: TurnPresentationDeriver;
+ readonly ownerTurnFooterAction: TurnFooterActionHandler;
+ readonly turnActionRegistry: {
+ readonly addKey: (key: string) => boolean;
+ readonly clearKey: (key: string) => void;
+ readonly keyOf: (sessionId: string, turnId: string, actionId: string) => string;
+ };
+ readonly children: (actions: GuestTurnActions) => ReactNode;
+}) {
+ const locale = useUiLocale();
+ const toast = useToast();
+ const services = useSessionCollaborationServices();
+ const attemptsRef = useRef(new Map<string, string>());
+ const presentationRef = useRef<{
+ readonly source: TurnPresentation;
+ readonly tooltip: string;
+ readonly result: TurnPresentation;
+ } | undefined>(undefined);
+ const collaborationCopy = getSessionCollaborationCopy(locale);
+ const requestTooltip = getDesktopConversationCopy(locale).footer.requestRegenerate;
+
+ const deriveGuestTurnPresentation: TurnPresentationDeriver = (turns) => {
+ const source = props.deriveTurnPresentation(turns);
+ const cached = presentationRef.current;
+ if (cached?.source === source && cached.tooltip === requestTooltip) return cached.result;
+ const result = guestTurnPresentation(source, requestTooltip);
+ presentationRef.current = { source, tooltip: requestTooltip, result };
+ return result;
+ };
+
+ const requestGuestRegeneration: TurnFooterActionHandler = async (turnId, actionId) => {
+ if (actionId !== 'regenerate') {
+ await props.ownerTurnFooterAction(turnId, actionId);
+ return;
+ }
+ if (!props.sessionId) return;
+ const sessionId = props.sessionId;
+ const pendingKey = props.turnActionRegistry.keyOf(sessionId, turnId, actionId);
+ if (!props.turnActionRegistry.addKey(pendingKey)) return;
+ const attemptKey = `${sessionId}\u0000${turnId}`;
+ const requestTurnId =
+ attemptsRef.current.get(attemptKey) ?? services.createOperationId();
+ attemptsRef.current.set(attemptKey, requestTurnId);
+ let outcomeKnown = false;
+ try {
+ await services.requestTurn(sessionId, {
+ kind: 'regenerate',
+ turnId: requestTurnId,
+ sourceTurnId: turnId,
+ });
+ outcomeKnown = true;
+ toast.success(
+ collaborationCopy.turnRequests,
+ collaborationCopy.regenerateRequestSent,
+ );
+ } catch (error) {
+ try {
+ const projection = await services.getTurnRequests(sessionId);
+ outcomeKnown = true;
+ if (projection.requests.some((request) => request.intent.turnId === requestTurnId)) {
+ toast.success(
+ collaborationCopy.turnRequests,
+ collaborationCopy.regenerateRequestSent,
+ );
+ return;
+ }
+ } catch {
+ // Preserve the Turn identity so the next explicit click is idempotent.
+ }
+ toast.error(
+ collaborationCopy.turnRequests,
+ localizedShellErrorMessage(
+ error,
+ getShellCopy(locale).app.tryAgainLater,
+ locale,
+ ),
+ );
+ } finally {
+ if (outcomeKnown) attemptsRef.current.delete(attemptKey);
+ props.turnActionRegistry.clearKey(pendingKey);
+ }
+ };
+
+ return props.children(
+ props.sessionId
+ ? {
+ deriveTurnPresentation: deriveGuestTurnPresentation,
+ onTurnFooterAction: requestGuestRegeneration,
+ }
+ : {
+ deriveTurnPresentation: props.deriveTurnPresentation,
+ onTurnFooterAction: props.ownerTurnFooterAction,
+ },
+ );
+}
+
+function guestTurnPresentation(
+ source: TurnPresentation,
+ requestTooltip: string,
+): TurnPresentation {
+ return {
+ ...source,
+ footerActionsByTurn: Object.fromEntries(
+ Object.entries(source.footerActionsByTurn).map(([turnId, actions]) => [
+ turnId,
+ actions.flatMap((action) => {
+ if (action.id === 'branch') return [];
+ if (action.id !== 'regenerate' || !action.enabled) return [action];
+ return [{ ...action, tooltip: requestTooltip }];
+ }),
+ ]),
+ ),
+ };
+}
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
index 5af9737403..f290e4591b 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
@@ -18,68 +18,103 @@
*/
import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import type { StoredMessage } from '@maka/core/session';
import { HoverCard } from '@astryxdesign/core/HoverCard';
import { Banner, Button } from '@maka/ui';
import {
useSessionTurnRequestInboxContext,
type SessionTurnRequestInboxCopy,
} from '../turn-request-inbox-context.js';
+import { describeOwnerTurnRequestIntent } from '../model/turn-request-inbox.js';
-export function SessionTurnRequestApprovalForSession(props: { readonly sessionId: string }) {
+export function SessionTurnRequestApprovalForSession(props: {
+ readonly sessionId: string;
+ readonly messages: readonly StoredMessage[];
+ readonly onOpenSession: (sessionId: string, turnId?: string) => void;
+}) {
const inbox = useSessionTurnRequestInboxContext();
return (
<SessionTurnRequestApproval
requests={inbox.requestsBySession.get(props.sessionId) ?? []}
+ messages={props.messages}
workingRequestIds={inbox.workingRequestIds}
copy={inbox.copy}
onDecide={inbox.decide}
+ onOpenSource={(turnId) => props.onOpenSession(props.sessionId, turnId)}
/>
);
}
export function SessionTurnRequestApproval(props: {
readonly requests: readonly SessionTurnAccessRequest[];
+ readonly messages: readonly StoredMessage[];
readonly workingRequestIds: ReadonlySet<string>;
readonly copy: Pick<
SessionTurnRequestInboxCopy,
- 'ownerTurnRequestTitle' | 'reject' | 'approve' | 'moreTurnRequests'
+ | 'ownerTurnRequestTitle'
+ | 'ownerRegenerateRequestTitle'
+ | 'regenerateRequest'
+ | 'viewSourceTurn'
+ | 'reject'
+ | 'approve'
+ | 'moreTurnRequests'
>;
readonly onDecide: (
request: SessionTurnAccessRequest,
decision: 'approve' | 'reject',
) => void | Promise<void>;
+ readonly onOpenSource: (turnId: string) => void;
}) {
const request = props.requests[0];
const copy = props.copy;
if (!request) return null;
const working = props.workingRequestIds.has(request.requestId);
+ const sourceTurnId = 'content' in request.intent
+ ? undefined
+ : request.intent.sourceTurnId;
+ const description = describeOwnerTurnRequestIntent(
+ request.intent,
+ props.messages,
+ copy.regenerateRequest,
+ );
+ const title = sourceTurnId === undefined
+ ? copy.ownerTurnRequestTitle
+ : copy.ownerRegenerateRequestTitle;
return (
<div className="sessionTurnRequestApproval">
<Banner
className="sessionTurnRequestApprovalBanner"
status="warning"
role="status"
- title={copy.ownerTurnRequestTitle}
+ title={title}
description={(
<HoverCard
content={(
<div className="sessionTurnRequestApprovalDetails">
- {request.intent.content.text}
+ {description}
</div>
)}
- label={copy.ownerTurnRequestTitle}
+ label={title}
placement="above"
alignment="start"
focusTrigger="always"
hasHoverIndication={false}
>
<span className="sessionTurnRequestApprovalIntent" tabIndex={0}>
- {request.intent.content.text}
+ {description}
</span>
</HoverCard>
)}
endContent={(
<div className="sessionTurnRequestApprovalActions">
+ {sourceTurnId ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ label={copy.viewSourceTurn}
+ onClick={() => props.onOpenSource(sourceTurnId)}
+ />
+ ) : null}
<Button
variant="secondary"
size="sm"
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-composer.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-composer.tsx
new file mode 100644
index 0000000000..6ae79ac634
--- /dev/null
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-composer.tsx
@@ -0,0 +1,451 @@
+/*
+ * 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 { useEffect, useId, useRef, useState } from 'react';
+import {
+ ChatComposer,
+ ChatComposerInput,
+} from '@astryxdesign/core/Chat';
+import { Tooltip } from '@astryxdesign/core/Tooltip';
+import {
+ Button,
+ readComposerDraft,
+ rememberComposerDraft,
+ Text,
+ useToast,
+ useUiLocale,
+} from '@maka/ui';
+import { ChevronDown, ICON_SIZE, Loader2 } from '@maka/ui/icons';
+import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import { getSessionCollaborationCopy } from '../../../locales/session-collaboration-copy.js';
+import {
+ describeTurnRequestIntent,
+ turnRequestStateLabel,
+} from '../model/turn-request-inbox.js';
+import { useSessionCollaborationServices } from '../services-context.js';
+
+const guestTurnRequestDrafts = new Map<string, string>();
+const guestTurnRequestDraftTokens = new Map<string, symbol>();
+const guestTurnRequestAttempts = new Map<string, TurnRequestAttempt>();
+
+interface TurnRequestAttempt {
+ readonly turnId: string;
+ readonly text: string;
+ readonly draftToken: symbol;
+ readonly phase: 'submitting' | 'reconciling' | 'retryable';
+}
+
+export function SessionTurnRequestComposer(props: {
+ readonly sessionId: string;
+}) {
+ return <SessionTurnRequestComposerForSession key={props.sessionId} {...props} />;
+}
+
+function SessionTurnRequestComposerForSession(props: {
+ readonly sessionId: string;
+}) {
+ const copy = getSessionCollaborationCopy(useUiLocale());
+ const toast = useToast();
+ const services = useSessionCollaborationServices();
+ const [text, setText] = useState(() => readComposerDraft(guestTurnRequestDrafts, props.sessionId));
+ const [requests, setRequests] = useState<readonly SessionTurnAccessRequest[]>([]);
+ const [canRequestTurns, setCanRequestTurns] = useState(false);
+ const [authorityAvailable, setAuthorityAvailable] = useState<boolean>();
+ const [reconciling, setReconciling] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [workingRequestIds, setWorkingRequestIds] = useState<ReadonlySet<string>>(
+ () => new Set(),
+ );
+ const historyId = useId();
+ const attemptRef = useRef<TurnRequestAttempt | undefined>(
+ guestTurnRequestAttempts.get(props.sessionId),
+ );
+ const submissionInFlightRef = useRef(false);
+ const previousActiveCountRef = useRef(0);
+
+ const activeRequestCount = requests.filter((request) => !isTurnRequestTerminal(request)).length;
+ const connectionPending = authorityAvailable !== true || reconciling;
+
+ useEffect(() => {
+ if (activeRequestCount > previousActiveCountRef.current) setExpanded(true);
+ previousActiveCountRef.current = activeRequestCount;
+ }, [activeRequestCount]);
+
+ function setDraft(value: string): void {
+ rememberComposerDraft(guestTurnRequestDrafts, props.sessionId, value);
+ if (value.trim()) guestTurnRequestDraftTokens.set(props.sessionId, Symbol());
+ else guestTurnRequestDraftTokens.delete(props.sessionId);
+ for (const sessionId of guestTurnRequestDraftTokens.keys()) {
+ if (!guestTurnRequestDrafts.has(sessionId)) {
+ guestTurnRequestDraftTokens.delete(sessionId);
+ }
+ }
+ setText(value);
+ }
+
+ function ownsDraft(attempt: TurnRequestAttempt): boolean {
+ return guestTurnRequestDraftTokens.get(props.sessionId) === attempt.draftToken;
+ }
+
+ function restoreDraft(attempt: TurnRequestAttempt): void {
+ if (ownsDraft(attempt)) setText(attempt.text);
+ }
+
+ function rememberAttempt(attempt: TurnRequestAttempt): void {
+ guestTurnRequestAttempts.set(props.sessionId, attempt);
+ attemptRef.current = attempt;
+ }
+
+ function changeAttemptPhase(
+ attempt: TurnRequestAttempt,
+ phase: TurnRequestAttempt['phase'],
+ ): void {
+ const current = guestTurnRequestAttempts.get(props.sessionId);
+ if (current?.turnId !== attempt.turnId) return;
+ rememberAttempt({ ...current, phase });
+ }
+
+ function acceptRequest(request: SessionTurnAccessRequest): void {
+ const attempt = attemptRef.current;
+ const shouldClearDraft = attempt?.turnId === request.intent.turnId && ownsDraft(attempt);
+ setRequests((current) =>
+ current.some((candidate) => candidate.requestId === request.requestId)
+ ? current
+ : [...current, request],
+ );
+ if (guestTurnRequestAttempts.get(props.sessionId)?.turnId === request.intent.turnId) {
+ guestTurnRequestAttempts.delete(props.sessionId);
+ }
+ if (attempt?.turnId === request.intent.turnId) attemptRef.current = undefined;
+ setReconciling(false);
+ if (shouldClearDraft) setDraft('');
+ toast.success(
+ 'content' in request.intent ? copy.turnRequestSent : copy.regenerateRequestSent,
+ );
+ }
+
+ function applyProjection(result: {
+ readonly canRequestTurns: boolean;
+ readonly requests: readonly SessionTurnAccessRequest[];
+ }): SessionTurnAccessRequest | undefined {
+ setAuthorityAvailable(true);
+ setCanRequestTurns(result.canRequestTurns);
+ setRequests(result.requests);
+ const sharedAttempt = guestTurnRequestAttempts.get(props.sessionId);
+ if (!sharedAttempt) {
+ if (attemptRef.current) {
+ attemptRef.current = undefined;
+ setText(readComposerDraft(guestTurnRequestDrafts, props.sessionId));
+ }
+ setReconciling(false);
+ return;
+ }
+ attemptRef.current = sharedAttempt;
+ const request = result.requests.find(
+ (candidate) => candidate.intent.turnId === sharedAttempt.turnId,
+ );
+ if (request) acceptRequest(request);
+ else if (sharedAttempt.phase === 'submitting') {
+ setReconciling(true);
+ } else if (sharedAttempt.phase === 'reconciling') {
+ changeAttemptPhase(sharedAttempt, 'retryable');
+ setReconciling(false);
+ restoreDraft(sharedAttempt);
+ } else setReconciling(false);
+ return request;
+ }
+
+ useEffect(() => {
+ let disposed = false;
+ let timer: number | undefined;
+ const refresh = async () => {
+ try {
+ const result = await services.getTurnRequests(props.sessionId);
+ if (!disposed) {
+ applyProjection(result);
+ }
+ } catch {
+ if (!disposed) setAuthorityAvailable(false);
+ // The Host remains authoritative; a later refresh or submit retries the projection.
+ } finally {
+ if (!disposed) timer = window.setTimeout(() => void refresh(), 2_000);
+ }
+ };
+ void refresh();
+ return () => {
+ disposed = true;
+ if (timer !== undefined) window.clearTimeout(timer);
+ };
+ }, [props.sessionId, services]);
+
+ async function submit(value: string): Promise<void> {
+ const content = value.trim();
+ if (!content) return;
+ const draftToken = guestTurnRequestDraftTokens.get(props.sessionId) ?? Symbol();
+ guestTurnRequestDraftTokens.set(props.sessionId, draftToken);
+ const attempt = attemptRef.current?.text === content
+ ? { ...attemptRef.current, draftToken, phase: 'submitting' as const }
+ : {
+ turnId: services.createOperationId(),
+ text: content,
+ draftToken,
+ phase: 'submitting' as const,
+ };
+ rememberAttempt(attempt);
+ submissionInFlightRef.current = true;
+ setSubmitting(true);
+ try {
+ const request = await services.requestTurn(
+ props.sessionId,
+ { kind: 'start', turnId: attempt.turnId, text: attempt.text },
+ );
+ acceptRequest(request);
+ } catch (error) {
+ // Astryx clears its visible input as soon as onSubmit returns. Request
+ // creation is not yet conclusive, so restore the still-owned draft before
+ // reconciliation can wait on a recovering Host.
+ restoreDraft(attempt);
+ changeAttemptPhase(attempt, 'reconciling');
+ try {
+ const current = await services.getTurnRequests(props.sessionId);
+ const request = applyProjection(current);
+ if (
+ request ||
+ guestTurnRequestAttempts.get(props.sessionId)?.turnId !== attempt.turnId
+ ) return;
+ } catch {
+ if (guestTurnRequestAttempts.get(props.sessionId)?.turnId !== attempt.turnId) return;
+ setReconciling(true);
+ setAuthorityAvailable(false);
+ return;
+ }
+ restoreDraft(attempt);
+ toast.error(copy.submitTurnRequest, errorMessage(error));
+ } finally {
+ submissionInFlightRef.current = false;
+ setSubmitting(false);
+ }
+ }
+
+ async function dismiss(requestId: string): Promise<void> {
+ setRequestWorking(requestId, true);
+ try {
+ await services.acknowledgeTurnRequest(
+ props.sessionId,
+ requestId,
+ );
+ setRequests((current) =>
+ current.filter((request) => request.requestId !== requestId),
+ );
+ } catch (error) {
+ toast.error(copy.turnRequests, errorMessage(error));
+ } finally {
+ setRequestWorking(requestId, false);
+ }
+ }
+
+ async function withdraw(requestId: string): Promise<void> {
+ setRequestWorking(requestId, true);
+ try {
+ const result = await services.withdrawTurnRequest(
+ props.sessionId,
+ requestId,
+ );
+ if (result.withdrawn) {
+ setRequests((current) => current.filter((request) => request.requestId !== requestId));
+ toast.info(copy.turnRequestWithdrawn);
+ } else {
+ const current = await services.getTurnRequests(props.sessionId);
+ applyProjection(current);
+ }
+ } catch (error) {
+ toast.error(copy.turnRequests, errorMessage(error));
+ } finally {
+ setRequestWorking(requestId, false);
+ }
+ }
+
+ function setRequestWorking(requestId: string, working: boolean): void {
+ setWorkingRequestIds((current) => {
+ const next = new Set(current);
+ if (working) next.add(requestId);
+ else next.delete(requestId);
+ return next;
+ });
+ }
+
+ return (
+ <div className="sessionTurnRequestSurface">
+ {requests.length > 0 ? (
+ <div className="maka-composer-queue sessionTurnRequestQueue">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="sessionTurnRequestQueueToggle"
+ label={copy.turnRequestCount(requests.length)}
+ aria-expanded={expanded}
+ aria-controls={historyId}
+ endContent={(
+ <ChevronDown
+ className={expanded ? 'isExpanded' : undefined}
+ size={ICON_SIZE.chrome}
+ aria-hidden="true"
+ />
+ )}
+ onClick={() => setExpanded((current) => !current)}
+ />
+ {expanded ? (
+ <div
+ id={historyId}
+ className="sessionTurnRequestHistory"
+ aria-label={copy.turnRequests}
+ >
+ {requests.slice().reverse().map((request) => {
+ const description = describeTurnRequestIntent(
+ request.intent,
+ copy.regenerateRequest,
+ );
+ const requestWorking = workingRequestIds.has(request.requestId);
+ return (
+ <div className="sessionTurnRequestHistoryRow" key={request.requestId}>
+ <div className="sessionTurnRequestHistoryBody" title={description}>
+ <Text
+ type="body"
+ className="sessionTurnRequestHistoryText"
+ >
+ {description}
+ </Text>
+ <Text type="supporting" color="secondary">
+ {turnRequestStateLabel(request, copy)}
+ </Text>
+ </div>
+ {request.state.kind === 'pending' ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ label={copy.withdrawTurnRequest}
+ isDisabled={authorityAvailable !== true || requestWorking}
+ onClick={() => void withdraw(request.requestId)}
+ />
+ ) : isTurnRequestTerminal(request) ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ label={copy.dismissTurnRequest}
+ isDisabled={authorityAvailable !== true || requestWorking}
+ onClick={() => void dismiss(request.requestId)}
+ />
+ ) : null}
+ </div>
+ );
+ })}
+ </div>
+ ) : null}
+ </div>
+ ) : null}
+ {canRequestTurns ? (
+ <div className="composer sessionTurnRequestComposer">
+ <ChatComposer
+ className="maka-composer-astryx"
+ value={text}
+ placeholder={copy.turnRequestPlaceholder}
+ isDisabled={submitting}
+ input={(
+ <ChatComposerInput
+ label={copy.turnRequestPlaceholder}
+ maxRows={8}
+ hasHistory={false}
+ pasteAsToken={false}
+ onKeyDown={(event) => {
+ if (connectionPending && event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ }
+ }}
+ />
+ )}
+ sendButton={connectionPending ? (
+ <Tooltip
+ content={reconciling ? copy.turnRequestReconciling : copy.turnRequestReconnecting}
+ >
+ <span className="sessionTurnRequestReconnectButton">
+ <Button
+ variant="primary"
+ size="md"
+ label={
+ reconciling ? copy.turnRequestReconciling : copy.turnRequestReconnecting
+ }
+ isDisabled
+ isIconOnly
+ icon={(
+ <Loader2
+ size={ICON_SIZE.control}
+ className="maka-spin"
+ aria-hidden="true"
+ />
+ )}
+ />
+ </span>
+ </Tooltip>
+ ) : undefined}
+ onChange={(value) => {
+ // ChatComposer clears itself immediately after invoking onSubmit.
+ // Reflect that transient state without deleting the remembered
+ // draft until the Host has conclusively accepted the request.
+ if (submissionInFlightRef.current && value === '' && attemptRef.current) {
+ setText('');
+ return;
+ }
+ if (
+ !submissionInFlightRef.current &&
+ value.trim() !== attemptRef.current?.text
+ ) {
+ const attempt = attemptRef.current;
+ if (
+ attempt?.phase === 'retryable' &&
+ guestTurnRequestAttempts.get(props.sessionId)?.turnId === attempt.turnId
+ ) {
+ guestTurnRequestAttempts.delete(props.sessionId);
+ }
+ attemptRef.current = undefined;
+ }
+ setDraft(value);
+ }}
+ onSubmit={(value) => void submit(value)}
+ />
+ </div>
+ ) : authorityAvailable === false ? (
+ <div className="sessionCollaborationReadOnly">{copy.accessUnavailable}</div>
+ ) : authorityAvailable === true ? (
+ <div className="sessionCollaborationReadOnly">{copy.observeHelp}</div>
+ ) : null}
+ </div>
+ );
+}
+
+function isTurnRequestTerminal(request: SessionTurnAccessRequest): boolean {
+ return (
+ request.state.kind === 'rejected' ||
+ (request.state.kind === 'approved' && request.state.admission !== 'pending')
+ );
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts
index 060636183d..9de85db23b 100644
--- a/apps/desktop/src/renderer/locales/conversation-copy.ts
+++ b/apps/desktop/src/renderer/locales/conversation-copy.ts
@@ -77,6 +77,7 @@ export interface DesktopConversationCopy {
regenerateRunning: string;
regenerateAgain: string;
regenerate: string;
+ requestRegenerate: string;
branchRunning: string;
branchAborted: string;
branch: string;
@@ -445,7 +446,7 @@ const COPY = {
provider_retired: '当前任务绑定的连接,其登录方式已从 Maka 移除,无法用于发送。请到 设置 · 模型 改用其他连接后新建任务。',
},
},
- footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '复制', info: '详情' }, pending: '正在处理…', regenerateRunning: '当前回答仍在进行中,结束后再重新生成', regenerateAgain: '已重新生成过,再次点击将创建新的并行回答', regenerate: '让模型重新生成本轮回答', branchRunning: '当前回答仍在进行中,结束后再分支', branchAborted: '从中断前的上下文分支出新任务', branch: '基于此回答的上下文分支出新任务', copy: '复制回答到剪贴板', copyEmpty: '此回答尚无可复制的内容' },
+ footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '复制', info: '详情' }, pending: '正在处理…', regenerateRunning: '当前回答仍在进行中,结束后再重新生成', regenerateAgain: '已重新生成过,再次点击将创建新的并行回答', regenerate: '让模型重新生成本轮回答', requestRegenerate: '请求所有者批准重新生成本轮回答', branchRunning: '当前回答仍在进行中,结束后再分支', branchAborted: '从中断前的上下文分支出新任务', branch: '基于此回答的上下文分支出新任务', copy: '复制回答到剪贴板', copyEmpty: '此回答尚无可复制的内容' },
lineage: { regeneratedFrom: '重新生成自旧回答', regeneratedFromTooltip: '这是重新生成的并行回答,点击查看被保留的旧回答', regeneratedTo: '已重新生成 → 新回答', regeneratedToTooltip: '点击跳转到重新生成的新回答' },
workbar: {
ariaLabel: '任务工作栏',
@@ -673,7 +674,7 @@ const COPY = {
provider_retired: 'The sign-in this task\u2019s connection uses was removed from Maka, so it cannot send. Switch to another connection in Settings · Models, then start a new task.',
},
},
- footer: { labels: { regenerate: 'Regenerate', branch: 'Branch', copy: 'Copy', info: 'Details' }, pending: 'Working…', regenerateRunning: 'Wait for the current response to finish before regenerating', regenerateAgain: 'A regenerated response already exists; click again to create another parallel response', regenerate: 'Generate another response to this turn', branchRunning: 'Wait for the current response to finish before branching', branchAborted: 'Branch from the context before the i [...]
+ footer: { labels: { regenerate: 'Regenerate', branch: 'Branch', copy: 'Copy', info: 'Details' }, pending: 'Working…', regenerateRunning: 'Wait for the current response to finish before regenerating', regenerateAgain: 'A regenerated response already exists; click again to create another parallel response', regenerate: 'Generate another response to this turn', requestRegenerate: 'Ask the Owner to approve regenerating this response', branchRunning: 'Wait for the current response to fini [...]
lineage: { regeneratedFrom: 'Regenerated from previous response', regeneratedFromTooltip: 'This is a parallel regenerated response; click to view the retained previous response', regeneratedTo: 'Regenerated → New response', regeneratedToTooltip: 'Jump to the regenerated response' },
workbar: {
ariaLabel: 'Task workbar',
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
index 915cb39042..43f65b3d0c 100644
--- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -87,6 +87,8 @@ const ZH = {
turnRequestPlaceholder: '描述你希望发起的新轮次',
submitTurnRequest: '请求新轮次',
turnRequestSent: '请求已提交,等待所有者批准',
+ regenerateRequestSent: '重新生成请求已提交,等待所有者批准',
+ turnRequestReconnecting: '正在重连 Runtime Host;草稿会保留,连接恢复后即可发送。',
turnRequestReconciling: '正在确认请求是否已被 Host 接收…',
turnRequestPending: '等待批准',
turnRequestApproved: '已批准',
@@ -95,11 +97,17 @@ const ZH = {
turnRequestBlocked: '未能开始',
turnRequestFailed: '准入失败',
dismissTurnRequest: '关闭',
+ withdrawTurnRequest: '撤回',
+ turnRequestWithdrawn: '请求已撤回',
+ turnRequestCount: (count: number) => `${count} 个轮次请求`,
+ regenerateRequest: '重新生成回答',
sharedTask: '共享任务',
newTurnRequestTitle: (count: number) => count === 1 ? '新的轮次请求' : `${count} 个新的轮次请求`,
newTurnRequestSummary: (count: number) => `${count} 个轮次请求正在等待审批`,
reviewTurnRequest: '查看并审批',
ownerTurnRequestTitle: '访客请求开始新轮次',
+ ownerRegenerateRequestTitle: '访客请求重新生成回答',
+ viewSourceTurn: '查看原轮次',
moreTurnRequests: (count: number) => `还有 ${count} 个请求等待审批`,
pendingTurnRequestCount: (count: number) => `${count} 个待审批轮次请求`,
};
@@ -181,6 +189,8 @@ const EN = {
turnRequestPlaceholder: 'Describe the new Turn you want to start',
submitTurnRequest: 'Request new Turn',
turnRequestSent: 'Request sent for Owner approval',
+ regenerateRequestSent: 'Regeneration request sent for Owner approval',
+ turnRequestReconnecting: 'Reconnecting to the Runtime Host. Your draft is kept and can be sent when the connection returns.',
turnRequestReconciling: 'Checking whether the Host received this request…',
turnRequestPending: 'Awaiting approval',
turnRequestApproved: 'Approved',
@@ -189,11 +199,17 @@ const EN = {
turnRequestBlocked: 'Could not start',
turnRequestFailed: 'Admission failed',
dismissTurnRequest: 'Dismiss',
+ withdrawTurnRequest: 'Withdraw',
+ turnRequestWithdrawn: 'Request withdrawn',
+ turnRequestCount: (count: number) => `${count} Turn ${count === 1 ? 'request' : 'requests'}`,
+ regenerateRequest: 'Regenerate response',
sharedTask: 'Shared task',
newTurnRequestTitle: (count: number) => count === 1 ? 'New Turn request' : `${count} new Turn requests`,
newTurnRequestSummary: (count: number) => `${count} Turn requests are waiting for approval`,
reviewTurnRequest: 'Review',
ownerTurnRequestTitle: 'A Guest requested a new Turn',
+ ownerRegenerateRequestTitle: 'A Guest requested a regenerated response',
+ viewSourceTurn: 'View original Turn',
moreTurnRequests: (count: number) => `${count} more ${count === 1 ? 'request' : 'requests'} waiting`,
pendingTurnRequestCount: (count: number) => `${count} pending Turn ${count === 1 ? 'request' : 'requests'}`,
} satisfies SessionCollaborationCopy;
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 8cb25a7688..38333aadc2 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
@@ -32,6 +32,14 @@ export function createDesktopSessionCollaborationServices(
readInvitationClipboard: () => bridge.sessionCollaboration.readInvitationClipboard(),
listMounts: () => bridge.sessionCollaboration.listMounts(),
removeMount: (mountId) => bridge.sessionCollaboration.removeMount(mountId),
+ requestTurn: (sessionId, input) =>
+ bridge.sessionCollaboration.requestTurn(sessionId, input),
+ getTurnRequests: (sessionId) =>
+ bridge.sessionCollaboration.getTurnRequests(sessionId),
+ acknowledgeTurnRequest: (sessionId, requestId) =>
+ bridge.sessionCollaboration.acknowledgeTurnRequest(sessionId, requestId),
+ withdrawTurnRequest: (sessionId, requestId) =>
+ bridge.sessionCollaboration.withdrawTurnRequest(sessionId, requestId),
getPendingTurnRequests: () => bridge.sessionCollaboration.getPendingTurnRequests(),
decideTurnRequest: (sessionId, requestId, decision) =>
bridge.sessionCollaboration.decideTurnRequest(sessionId, requestId, decision),
diff --git a/apps/desktop/src/renderer/session-collaboration-dialog.tsx b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
index c8628268ca..f60dafef1f 100644
--- a/apps/desktop/src/renderer/session-collaboration-dialog.tsx
+++ b/apps/desktop/src/renderer/session-collaboration-dialog.tsx
@@ -37,7 +37,10 @@ import type {
SessionTurnAccessRequest,
} from '@maka/runtime-host/protocol';
import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js';
-import { turnRequestStateLabel } from './session-turn-request-composer.js';
+import {
+ describeTurnRequestIntent,
+ turnRequestStateLabel,
+} from './features/session-collaboration';
type Props = {
readonly target?: {
@@ -370,7 +373,7 @@ function ShareSessionDialog(props: ShareSessionDialogProps) {
<div className="sessionCollaborationTurnRequest" key={request.requestId}>
<div>
<Text type="body" className="sessionCollaborationTurnRequestText">
- {request.intent.content.text}
+ {describeTurnRequestIntent(request.intent, copy.regenerateRequest)}
</Text>
<Text type="supporting" color="secondary">
{guestIdentityLabel(request.principalId, copy.guest)}
diff --git a/apps/desktop/src/renderer/session-turn-request-composer.tsx b/apps/desktop/src/renderer/session-turn-request-composer.tsx
deleted file mode 100644
index f6a7789ffe..0000000000
--- a/apps/desktop/src/renderer/session-turn-request-composer.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import { useEffect, useRef, useState } from 'react';
-import { Button, Text, TextArea, useToast, useUiLocale } from '@maka/ui';
-import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
-import { getSessionCollaborationCopy } from './locales/session-collaboration-copy.js';
-
-export function SessionTurnRequestComposer(props: {
- readonly sessionId: string;
-}) {
- const copy = getSessionCollaborationCopy(useUiLocale());
- const toast = useToast();
- const [text, setText] = useState('');
- const [requests, setRequests] = useState<readonly SessionTurnAccessRequest[]>([]);
- const [canRequestTurns, setCanRequestTurns] = useState(false);
- const [authorityAvailable, setAuthorityAvailable] = useState<boolean>();
- const [reconciling, setReconciling] = useState(false);
- const [working, setWorking] = useState(false);
- const attemptRef = useRef<{ readonly turnId: string; readonly text: string } | undefined>(
- undefined,
- );
- const reconcilingTurnIdRef = useRef<string | undefined>(undefined);
-
- function acceptRequest(request: SessionTurnAccessRequest): void {
- setRequests((current) =>
- current.some((candidate) => candidate.requestId === request.requestId)
- ? current
- : [...current, request],
- );
- attemptRef.current = undefined;
- reconcilingTurnIdRef.current = undefined;
- setReconciling(false);
- setText('');
- toast.success(copy.turnRequestSent);
- }
-
- function applyProjection(result: {
- readonly canRequestTurns: boolean;
- readonly requests: readonly SessionTurnAccessRequest[];
- }): SessionTurnAccessRequest | undefined {
- setAuthorityAvailable(true);
- setCanRequestTurns(result.canRequestTurns);
- setRequests(result.requests);
- const turnId = reconcilingTurnIdRef.current;
- if (!turnId) return;
- const request = result.requests.find((candidate) => candidate.intent.turnId === turnId);
- if (request) acceptRequest(request);
- else {
- reconcilingTurnIdRef.current = undefined;
- setReconciling(false);
- }
- return request;
- }
-
- useEffect(() => {
- let disposed = false;
- let timer: number | undefined;
- const refresh = async () => {
- try {
- const result = await window.maka.sessionCollaboration.getTurnRequests(props.sessionId);
- if (!disposed) {
- applyProjection(result);
- }
- } catch {
- if (!disposed) setAuthorityAvailable(false);
- // The Host remains authoritative; a later refresh or submit retries the projection.
- } finally {
- if (!disposed) timer = window.setTimeout(() => void refresh(), 2_000);
- }
- };
- void refresh();
- return () => {
- disposed = true;
- if (timer !== undefined) window.clearTimeout(timer);
- };
- }, [props.sessionId]);
-
- async function submit(): Promise<void> {
- const content = text.trim();
- if (!content) return;
- const attempt = attemptRef.current?.text === content
- ? attemptRef.current
- : { turnId: crypto.randomUUID(), text: content };
- attemptRef.current = attempt;
- setWorking(true);
- try {
- const request = await window.maka.sessionCollaboration.requestTurn(
- props.sessionId,
- attempt,
- );
- acceptRequest(request);
- } catch (error) {
- try {
- const current = await window.maka.sessionCollaboration.getTurnRequests(props.sessionId);
- reconcilingTurnIdRef.current = attempt.turnId;
- const request = applyProjection(current);
- if (request) {
- return;
- }
- } catch {
- reconcilingTurnIdRef.current = attempt.turnId;
- setReconciling(true);
- setAuthorityAvailable(false);
- return;
- }
- toast.error(copy.submitTurnRequest, errorMessage(error));
- } finally {
- setWorking(false);
- }
- }
-
- async function dismiss(requestId: string): Promise<void> {
- try {
- await window.maka.sessionCollaboration.acknowledgeTurnRequest(
- props.sessionId,
- requestId,
- );
- setRequests((current) =>
- current.filter((request) => request.requestId !== requestId),
- );
- } catch (error) {
- toast.error(copy.turnRequests, errorMessage(error));
- }
- }
-
- return (
- <div className="sessionTurnRequestSurface">
- {requests.length > 0 ? (
- <div className="sessionTurnRequestHistory" aria-label={copy.turnRequests}>
- {requests.slice().reverse().map((request) => (
- <div className="sessionTurnRequestHistoryRow" key={request.requestId}>
- <Text type="body" className="sessionTurnRequestHistoryText">
- {request.intent.content.text}
- </Text>
- <Text type="supporting" color="secondary">
- {turnRequestStateLabel(request, copy)}
- </Text>
- {isTurnRequestTerminal(request) ? (
- <Button
- variant="ghost"
- size="sm"
- label={copy.dismissTurnRequest}
- isDisabled={authorityAvailable !== true}
- onClick={() => void dismiss(request.requestId)}
- />
- ) : null}
- </div>
- ))}
- </div>
- ) : null}
- {authorityAvailable === false ? (
- <div className="sessionCollaborationReadOnly">
- {reconciling ? copy.turnRequestReconciling : copy.accessUnavailable}
- </div>
- ) : authorityAvailable === true && canRequestTurns ? (
- <div className="sessionTurnRequestComposer">
- <TextArea
- label={copy.turnRequestPlaceholder}
- isLabelHidden
- value={text}
- rows={2}
- placeholder={copy.turnRequestPlaceholder}
- isDisabled={working || reconciling}
- onChange={(value) => {
- if (value.trim() !== attemptRef.current?.text) attemptRef.current = undefined;
- setText(value);
- }}
- />
- <div className="sessionTurnRequestComposerFooter">
- <Text type="supporting" color="secondary">
- {copy.requestTurnHelp}
- </Text>
- <Button
- variant="primary"
- size="sm"
- label={copy.submitTurnRequest}
- isDisabled={working || reconciling || text.trim().length === 0}
- onClick={() => void submit()}
- />
- </div>
- </div>
- ) : authorityAvailable === true ? (
- <div className="sessionCollaborationReadOnly">{copy.observeHelp}</div>
- ) : null}
- </div>
- );
-}
-
-function isTurnRequestTerminal(request: SessionTurnAccessRequest): boolean {
- return (
- request.state.kind === 'rejected' ||
- (request.state.kind === 'approved' && request.state.admission !== 'pending')
- );
-}
-
-export function turnRequestStateLabel(
- request: SessionTurnAccessRequest,
- copy: ReturnType<typeof getSessionCollaborationCopy>,
-): string {
- if (request.state.kind === 'pending') return copy.turnRequestPending;
- if (request.state.kind === 'rejected') return copy.turnRequestRejected;
- if (request.state.admission === 'pending') return copy.turnRequestApproved;
- if (request.state.admission === 'started') return copy.turnRequestStarted;
- if (request.state.admission === 'blocked') return copy.turnRequestBlocked;
- return copy.turnRequestFailed;
-}
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
-}
diff --git a/apps/desktop/src/renderer/styles/astryx-mount.css b/apps/desktop/src/renderer/styles/astryx-mount.css
index cd7ca3923d..5dca89e697 100644
--- a/apps/desktop/src/renderer/styles/astryx-mount.css
+++ b/apps/desktop/src/renderer/styles/astryx-mount.css
@@ -71,3 +71,12 @@
max-height: 80vh;
}
+/* LayoutFooter owns padding but deliberately has no action layout. Give only
+ direct button groups a shared gap; wrapped HStacks keep their own sizing and
+ alignment semantics. */
+.astryx-layout-footer > div:has(> button) {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css
index e4b2175943..f4cbd53a55 100644
--- a/apps/desktop/src/renderer/styles/shell-layout.css
+++ b/apps/desktop/src/renderer/styles/shell-layout.css
@@ -633,7 +633,11 @@ dialog:modal {
}
.sessionTurnRequestComposer {
- padding: var(--space-3);
+ padding-block-start: var(--space-2);
+}
+
+.sessionTurnRequestReconnectButton {
+ display: inline-flex;
}
.sessionCollaborationReadOnly {
@@ -642,18 +646,48 @@ dialog:modal {
text-align: center;
}
+.sessionTurnRequestQueue {
+ margin-block-start: var(--space-2);
+}
+
+.sessionTurnRequestQueueToggle {
+ width: 100%;
+ justify-content: space-between;
+}
+
+.sessionTurnRequestQueueToggle svg {
+ transition: transform var(--duration-fast) var(--ease-standard);
+}
+
+.sessionTurnRequestQueueToggle svg.isExpanded {
+ transform: rotate(180deg);
+}
+
.sessionTurnRequestHistory {
display: grid;
- gap: var(--space-2);
- padding: var(--space-3);
- border-bottom: var(--border-width-hairline) solid var(--border);
+ max-height: 12rem;
+ overflow-y: auto;
+ margin-block-start: var(--space-1);
+ border-top: var(--border-width-hairline) solid var(--border);
}
.sessionTurnRequestHistoryRow {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+ padding: var(--space-2);
+}
+
+.sessionTurnRequestHistoryRow + .sessionTurnRequestHistoryRow {
+ border-top: var(--border-width-hairline) solid var(--border);
+}
+
+.sessionTurnRequestHistoryBody {
display: grid;
- grid-template-columns: minmax(0, 1fr) auto auto;
- align-items: baseline;
- gap: var(--space-3);
+ gap: var(--space-0-5);
+ min-width: 0;
+ flex: 1;
}
.sessionTurnRequestHistoryText {
@@ -662,15 +696,3 @@ dialog:modal {
text-overflow: ellipsis;
white-space: nowrap;
}
-
-.sessionTurnRequestComposer {
- display: grid;
- gap: var(--space-2);
-}
-
-.sessionTurnRequestComposerFooter {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--space-3);
-}
diff --git a/apps/desktop/src/renderer/use-turn-action-registry.ts b/apps/desktop/src/renderer/use-turn-action-registry.ts
index a336323226..08da88f518 100644
--- a/apps/desktop/src/renderer/use-turn-action-registry.ts
+++ b/apps/desktop/src/renderer/use-turn-action-registry.ts
@@ -47,6 +47,8 @@ export interface TurnActionRegistry {
keys: Set<string>;
/** Stable mirror of the pending keys for synchronous reads. */
keysRef: RefBox<Set<string>>;
+ /** Builds the namespaced identity consumed by every registry operation. */
+ keyOf(sessionId: string, turnId: string, actionId: string): string;
/**
* Marks `key` pending and arms its auto-clear timer. Returns false (a no-op)
* if it was already pending, so callers can bail on a duplicate action.
@@ -67,6 +69,8 @@ export function useTurnActionRegistry(): TurnActionRegistry {
const controllerRef = useRef<Omit<TurnActionRegistry, 'keys'> | null>(null);
if (!controllerRef.current) {
+ const keyOf = (sessionId: string, turnId: string, actionId: string): string =>
+ `${sessionId}:${turnId}:${actionId}`;
const syncState = (): void => {
setKeys(new Set(keysRef.current));
};
@@ -99,7 +103,7 @@ export function useTurnActionRegistry(): TurnActionRegistry {
keysRef.current.clear();
syncState();
};
- controllerRef.current = { keysRef, addKey, clearKey, clearForSession, clearAll };
+ controllerRef.current = { keysRef, keyOf, addKey, clearKey, clearForSession, clearAll };
}
return { keys, ...controllerRef.current };
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index 41256327ab..d82ab9389b 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -371,7 +371,6 @@ function ComposedShell(props: {
activeId: active?.id,
pendingTurnActions: new Set<string>(),
uiLocale: 'zh',
- pendingKeyOf: (sessionId, turnId, actionId) => `${sessionId}:${turnId}:${actionId}`,
});
const projectGroups: SessionGroup[] = catalogProjects.map((item) => ({
id: `project:${item.id}`,
diff --git a/apps/desktop/stories/shared-session-guest.stories.tsx b/apps/desktop/stories/shared-session-guest.stories.tsx
new file mode 100644
index 0000000000..0cd5bad735
--- /dev/null
+++ b/apps/desktop/stories/shared-session-guest.stories.tsx
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol';
+import { ToastProvider } from '@maka/ui';
+import {
+ SessionCollaborationServicesProvider,
+ SessionTurnRequestComposer,
+ type SessionCollaborationServices,
+} from '../src/renderer/features/session-collaboration/index.js';
+
+const SESSION_ID = 'shared-session-story';
+const REQUESTS: readonly SessionTurnAccessRequest[] = [
+ {
+ requestId: 'pending-request',
+ principalId: 'guest-story',
+ grantId: 'turn-grant',
+ intent: {
+ sessionId: SESSION_ID,
+ turnId: 'pending-turn',
+ content: { text: '请检查这个连接恢复方案,并给出可以直接执行的修复建议。' },
+ },
+ createdAt: '2026-09-03T01:00:00.000Z',
+ state: { kind: 'pending' },
+ },
+ {
+ requestId: 'regenerate-request',
+ principalId: 'guest-story',
+ grantId: 'turn-grant',
+ intent: {
+ sessionId: SESSION_ID,
+ turnId: 'regenerated-turn',
+ sourceTurnId: 'source-turn',
+ },
+ createdAt: '2026-09-03T00:58:00.000Z',
+ state: {
+ kind: 'approved',
+ decidedAt: '2026-09-03T00:59:00.000Z',
+ decidedBy: 'owner-story',
+ admission: 'started',
+ },
+ },
+];
+
+function services(
+ query: () => Promise<{
+ readonly canRequestTurns: boolean;
+ readonly requests: readonly SessionTurnAccessRequest[];
+ }>,
+ withdrawTurnRequest: (
+ sessionId: string,
+ requestId: string,
+ ) => Promise<{ readonly withdrawn: boolean }> = async () => ({ withdrawn: true }),
+): SessionCollaborationServices {
+ return {
+ importInvitation: async () => {
+ throw new Error('unused');
+ },
+ cancelImport: async () => 'cancelled',
+ readInvitationClipboard: async () => '',
+ listMounts: async () => [],
+ removeMount: async () => undefined,
+ requestTurn: async () => REQUESTS[0],
+ getTurnRequests: query,
+ acknowledgeTurnRequest: async () => ({ acknowledged: true }),
+ withdrawTurnRequest,
+ getPendingTurnRequests: async () => [],
+ decideTurnRequest: async () => {
+ throw new Error('unused');
+ },
+ createOperationId: () => 'story-turn',
+ };
+}
+
+let requestQueueRequests = [...REQUESTS];
+const requestQueueServices = services(
+ async () => ({ canRequestTurns: true, requests: requestQueueRequests }),
+ async (_sessionId, requestId) => {
+ const current = requestQueueRequests.find(
+ (request) => request.requestId === requestId,
+ );
+ if (current?.state.kind !== 'pending') return { withdrawn: false };
+ requestQueueRequests = requestQueueRequests.filter(
+ (request) => request.requestId !== requestId,
+ );
+ return { withdrawn: true };
+ },
+);
+
+let reconnectQueryCount = 0;
+const reconnectServices = services(async () => {
+ reconnectQueryCount += 1;
+ if (reconnectQueryCount === 1) {
+ return { canRequestTurns: true, requests: [] };
+ }
+ throw new Error('Runtime Host is reconnecting');
+});
+
+const meta = {
+ title: 'Product/Shared Session Guest',
+ component: SessionTurnRequestComposer,
+ parameters: { layout: 'fullscreen' },
+ decorators: [
+ (Story) => (
+ <ToastProvider>
+ <div
+ className="maka-panel maka-panel-detail"
+ style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}
+ >
+ <div className="maka-detail-with-artifacts">
+ <div className="mainColumn" style={{ justifyContent: 'flex-end' }}>
+ <Story />
+ </div>
+ </div>
+ </div>
+ </ToastProvider>
+ ),
+ ],
+} satisfies Meta<typeof SessionTurnRequestComposer>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+// Real path: join a shared Session with Turn-request access. The queue is
+// expanded because one active request arrived, and terminal history remains
+// dismissible without pushing the composer away from the normal chat layout.
+export const RequestQueue: Story = {
+ args: { sessionId: SESSION_ID },
+ decorators: [
+ (Story) => (
+ <SessionCollaborationServicesProvider services={requestQueueServices}>
+ <Story />
+ </SessionCollaborationServicesProvider>
+ ),
+ ],
+};
+
+// Real path: a Guest who already has Turn-request access loses the Runtime
+// Host connection. The first projection establishes that access; subsequent
+// refreshes fail so the composer exercises its stable reconnecting state.
+export const Reconnecting: Story = {
+ args: { sessionId: SESSION_ID },
+ decorators: [
+ (Story) => (
+ <SessionCollaborationServicesProvider services={reconnectServices}>
+ <Story />
+ </SessionCollaborationServicesProvider>
+ ),
+ ],
+};
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 3a6020b081..14992542f9 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 243 files — blocker 0, reimplementation 0, polish 1, aligned 242.
+**Totals:** 244 files — blocker 0, reimplementation 0, polish 1, aligned 243.
## Exclusions (explicit)
@@ -59,8 +59,10 @@ 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-join-dialog.tsx` | dialog-overlay | Banner, Button, Dialog, DialogHeader, FormLayout, Layout, LayoutContent, LayoutFooter, List, ListItem, TextArea | aligned — uses Astryx (Banner, Button, Dialog, DialogHeader, FormLayout, Layout, LayoutContent, LayoutFooter) | 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 |
| `apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx` | shell-chrome-or-panel | Banner, Button, HoverCard | aligned — uses Astryx (Banner, Button, HoverCard) | aligned |
| `apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-badge.tsx` | shell-chrome-or-panel | Badge | aligned — uses Astryx (Badge) | aligned |
+| `apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, ChatComposerInput, Text, Tooltip | aligned — uses Astryx (Button, ChatComposer, ChatComposerInput, Text, Tooltip) | aligned |
| `apps/desktop/src/renderer/features/session-navigation/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/session-settings/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
@@ -93,7 +95,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `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/session-turn-request-composer.tsx` | shell-chrome-or-panel | Button, Text, TextArea | aligned — uses Astryx (Button, Text, TextArea) | aligned |
| `apps/desktop/src/renderer/settings/about-settings-page.tsx` | settings-page | Banner, Button, HStack, Heading, Kbd, Link, List, ListItem, Text, Token, VStack | aligned — uses Astryx (Banner, Button, HStack, Heading, Kbd, Link, List, ListItem) | 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 e5dcda131d..e256ca9acb 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -30,8 +30,10 @@ apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-profi
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-join-dialog.tsx
+apps/desktop/src/renderer/features/session-collaboration/ui/session-guest-turn-actions.tsx
apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-approval.tsx
apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-badge.tsx
+apps/desktop/src/renderer/features/session-collaboration/ui/session-turn-request-composer.tsx
apps/desktop/src/renderer/features/session-navigation/services-context.tsx
apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx
apps/desktop/src/renderer/features/session-settings/services-context.tsx
@@ -64,7 +66,6 @@ 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/session-turn-request-composer.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/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
index 464ecc2085..c46f144b0d 100644
--- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts
@@ -473,6 +473,7 @@ describe('Runtime Host operator commands', () => {
'access.principal.revoke',
'collaboration.turn-request.acknowledge',
'collaboration.turn-request.create',
+ 'collaboration.turn-request.withdraw',
'host.upgrade.prepare',
'hosted.execution.cancel',
'hosted.execution.start',
diff --git a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
index 13a3185638..6605fd9b01 100644
--- a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
+++ b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts
@@ -48,6 +48,10 @@ function sessionGuest(principalId: string) {
return { principalId, principalKind: 'session_guest' as const };
}
+async function unexpectedRegenerateTurn(): Promise<never> {
+ throw new Error('Unexpected regeneration request');
+}
+
test('Session Guest invitation, grants, and revocation form one durable authority lifecycle', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-session-collaboration-'));
const authority = await openRuntimeHostAccessAuthority(directory);
@@ -72,6 +76,7 @@ test('Session Guest invitation, grants, and revocation form one durable authorit
'collaboration.turn-request.create',
'collaboration.turn-request.acknowledge',
'collaboration.turn-request.query',
+ 'collaboration.turn-request.withdraw',
'runtime.resource.query',
'session.shared.query',
'subscription.open',
@@ -142,6 +147,22 @@ test('Session Guest invitation, grants, and revocation form one durable authorit
});
test('Turn requests reject execution input that the Owner cannot review', () => {
+ assert.deepEqual(
+ HOST_OPERATION_SPECS['collaboration.turn-request.create'].decodeInput({
+ intent: {
+ sessionId: 'session-1',
+ turnId: 'turn-regenerated',
+ sourceTurnId: 'turn-original',
+ },
+ }),
+ {
+ intent: {
+ sessionId: 'session-1',
+ turnId: 'turn-regenerated',
+ sourceTurnId: 'turn-original',
+ },
+ },
+ );
assert.throws(
() =>
HOST_OPERATION_SPECS['collaboration.turn-request.create'].decodeInput({
@@ -152,10 +173,60 @@ test('Turn requests reject execution input that the Owner cannot review', () =>
skillSelection: { mode: 'all' },
},
}),
- /Unknown Session Turn request intent field/u,
+ /Unknown Session Turn start request intent field/u,
);
});
+test('a Guest can withdraw only its own pending Turn request', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-withdraw-'));
+ const authority = await openRuntimeHostAccessAuthority(directory);
+ try {
+ const principalId = await activateTurnGuest(authority);
+ const otherPrincipalId = await activateTurnGuest(authority);
+ const request = await authority.createTurnAccessRequest(principalId, {
+ intent: {
+ sessionId: 'session-1',
+ turnId: 'turn-withdrawn',
+ content: { text: 'Do not run this' },
+ },
+ });
+
+ assert.deepEqual(
+ await authority.withdrawTurnAccessRequest(otherPrincipalId, {
+ requestId: request.requestId,
+ }),
+ { withdrawn: false },
+ );
+ assert.deepEqual(
+ await authority.withdrawTurnAccessRequest(principalId, { requestId: request.requestId }),
+ { withdrawn: true },
+ );
+ assert.equal(
+ authority.queryTurnAccessRequests(LOCAL_OWNER, { sessionId: 'session-1' }).requests.length,
+ 0,
+ );
+
+ const approved = await authority.createTurnAccessRequest(principalId, {
+ intent: {
+ sessionId: 'session-1',
+ turnId: 'turn-approved',
+ content: { text: 'Run this' },
+ },
+ });
+ await authority.decideTurnAccessRequest('local_owner', {
+ requestId: approved.requestId,
+ decision: 'approve',
+ });
+ assert.deepEqual(
+ await authority.withdrawTurnAccessRequest(principalId, { requestId: approved.requestId }),
+ { withdrawn: false },
+ );
+ } finally {
+ await authority.close();
+ await rm(directory, { recursive: true, force: true });
+ }
+});
+
test('Owner can query one durable Turn-request inbox without exposing another Guest', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-inbox-'));
const authority = await openRuntimeHostAccessAuthority(directory);
@@ -246,6 +317,7 @@ test('an approved exact Turn request survives restart and is admitted once', asy
},
};
},
+ regenerateTurn: unexpectedRegenerateTurn,
});
await coordinator.recover();
await coordinator.close();
@@ -274,6 +346,65 @@ test('an approved exact Turn request survives restart and is admitted once', asy
}
});
+test('an approved regeneration request uses the same durable admission boundary', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-session-regenerate-request-'));
+ const authority = await openRuntimeHostAccessAuthority(directory);
+ try {
+ const principalId = await activateTurnGuest(authority);
+ const intent = {
+ sessionId: 'session-1',
+ turnId: 'turn-regenerated',
+ sourceTurnId: 'turn-original',
+ };
+ const request = await authority.createTurnAccessRequest(principalId, { intent });
+ const equivalent = await authority.createTurnAccessRequest(principalId, {
+ intent: { ...intent, turnId: 'another-client-attempt' },
+ });
+ assert.equal(equivalent.requestId, request.requestId);
+ await authority.decideTurnAccessRequest('local_owner', {
+ requestId: request.requestId,
+ decision: 'approve',
+ });
+ const admitted: unknown[] = [];
+ const coordinator = new SessionTurnAccessRequestCoordinator({
+ authority,
+ hostEpoch: 'epoch-1',
+ acquireResidency: () => ({ release: () => undefined }),
+ requestDrain: () => undefined,
+ whenIdle: () => undefined,
+ startTurn: async () => {
+ throw new Error('Regeneration must not use turn.start');
+ },
+ regenerateTurn: async (input) => {
+ admitted.push(input);
+ return {
+ ok: true,
+ result: {
+ sessionId: input.sessionId,
+ turnId: input.turnId,
+ runId: 'run-regenerated',
+ status: 'running',
+ },
+ };
+ },
+ });
+ coordinator.recover();
+ await coordinator.close();
+
+ assert.deepEqual(admitted, [intent]);
+ const completed = authority.queryTurnAccessRequests(LOCAL_OWNER, {
+ sessionId: 'session-1',
+ }).requests[0];
+ assert.equal(
+ completed?.state.kind === 'approved' ? completed.state.admission : undefined,
+ 'started',
+ );
+ } finally {
+ await authority.close();
+ await rm(directory, { recursive: true, force: true });
+ }
+});
+
test('Turn access request creation is idempotent for one exact Guest intent', async () => {
const directory = await mkdtemp(join(tmpdir(), 'maka-session-turn-request-idempotent-'));
const authority = await openRuntimeHostAccessAuthority(directory);
@@ -295,7 +426,7 @@ test('Turn access request creation is idempotent for one exact Guest intent', as
authority.createTurnAccessRequest(principalId, {
intent: { ...intent, content: { text: 'Different work' } },
}),
- /different content/u,
+ /different intent/u,
);
} finally {
await authority.close();
@@ -497,6 +628,7 @@ test('recovery stays ready while an approved Turn request waits for the Session'
},
};
},
+ regenerateTurn: unexpectedRegenerateTurn,
});
coordinator.recover();
assert.equal(attempts, 1);
@@ -553,6 +685,7 @@ test('an idle-wait failure drains without losing the approved Turn request', asy
retryable: true,
},
}),
+ regenerateTurn: unexpectedRegenerateTurn,
});
coordinator.recover();
await coordinator.close();
@@ -600,6 +733,7 @@ test('drain does not terminalize an in-flight admission failure', async () => {
started();
return admission;
},
+ regenerateTurn: unexpectedRegenerateTurn,
});
const request = await authority.createTurnAccessRequest(principalId, {
intent: {
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 3b71b6d122..abac95994a 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -100,7 +100,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 100 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 101 as const;
+// 101: Session Turn requests can carry regeneration intents and Guests can
+// atomically withdraw pending requests. Older peers do not share this command
+// vocabulary or the expanded Guest operation grant.
// 100: `session.branch.create` makes `sourceTurnId` optional, so a side
// conversation can fork with an empty context (no copied messages, no
// fabricated `branchOfTurnId`) instead of requiring a settled turn. An older
diff --git a/packages/runtime-host/src/protocol/session-collaboration.ts b/packages/runtime-host/src/protocol/session-collaboration.ts
index 81eac55fdb..3d6bcb0268 100644
--- a/packages/runtime-host/src/protocol/session-collaboration.ts
+++ b/packages/runtime-host/src/protocol/session-collaboration.ts
@@ -116,12 +116,22 @@ export type SessionTurnAccessRequestState =
readonly admission: 'pending' | 'started' | 'blocked' | 'failed';
};
-export interface SessionTurnRequestIntent {
+export interface SessionTurnStartRequestIntent {
readonly sessionId: string;
readonly turnId: string;
readonly content: { readonly text: string };
}
+export interface SessionTurnRegenerateRequestIntent {
+ readonly sessionId: string;
+ readonly turnId: string;
+ readonly sourceTurnId: string;
+}
+
+export type SessionTurnRequestIntent =
+ | SessionTurnStartRequestIntent
+ | SessionTurnRegenerateRequestIntent;
+
export interface SessionTurnAccessRequest {
readonly requestId: string;
readonly principalId: string;
@@ -164,6 +174,14 @@ export interface CollaborationTurnRequestAcknowledgeInput {
export interface CollaborationTurnRequestAcknowledgeResult {
readonly acknowledged: boolean;
}
+
+export interface CollaborationTurnRequestWithdrawInput {
+ readonly requestId: string;
+}
+
+export interface CollaborationTurnRequestWithdrawResult {
+ readonly withdrawn: boolean;
+}
const COLLABORATION_ERRORS = [
'host_not_ready',
'host_draining',
@@ -252,6 +270,17 @@ export const SESSION_COLLABORATION_OPERATION_SPECS = {
decodeInput: decodeCollaborationTurnRequestAcknowledgeInput,
decodeOutput: decodeCollaborationTurnRequestAcknowledgeResult,
}),
+ 'collaboration.turn-request.withdraw': defineOperation<
+ CollaborationTurnRequestWithdrawInput,
+ CollaborationTurnRequestWithdrawResult,
+ (typeof COLLABORATION_ERRORS)[number]
+ >({
+ mode: 'command',
+ availability: 'ready',
+ errors: COLLABORATION_ERRORS,
+ decodeInput: decodeCollaborationTurnRequestWithdrawInput,
+ decodeOutput: decodeCollaborationTurnRequestWithdrawResult,
+ }),
'collaboration.turn-request.decide': defineOperation<
CollaborationTurnRequestDecideInput,
CollaborationTurnRequestDecideResult,
@@ -471,6 +500,27 @@ function decodeCollaborationTurnRequestAcknowledgeResult(
return { acknowledged: record.acknowledged };
}
+function decodeCollaborationTurnRequestWithdrawInput(
+ value: unknown,
+): CollaborationTurnRequestWithdrawInput {
+ const record = requireExactRecord(value, 'collaboration Turn request withdrawal input', [
+ 'requestId',
+ ]);
+ return { requestId: requireId(record.requestId, 'requestId') };
+}
+
+function decodeCollaborationTurnRequestWithdrawResult(
+ value: unknown,
+): CollaborationTurnRequestWithdrawResult {
+ const record = requireExactRecord(value, 'collaboration Turn request withdrawal result', [
+ 'withdrawn',
+ ]);
+ if (typeof record.withdrawn !== 'boolean') {
+ throw invalidProtocolFrame('Invalid collaboration Turn request withdrawal result');
+ }
+ return { withdrawn: record.withdrawn };
+}
+
function decodeCollaborationTurnRequestDecideInput(
value: unknown,
): CollaborationTurnRequestDecideInput {
@@ -528,7 +578,20 @@ export function decodeSessionTurnAccessRequest(value: unknown): SessionTurnAcces
}
function decodeSessionTurnRequestIntent(value: unknown): SessionTurnRequestIntent {
- const record = requireExactRecord(value, 'Session Turn request intent', [
+ const candidate = requireRecord(value, 'Session Turn request intent');
+ if (!Object.hasOwn(candidate, 'content')) {
+ const record = requireExactRecord(value, 'Session Turn regenerate request intent', [
+ 'sessionId',
+ 'turnId',
+ 'sourceTurnId',
+ ]);
+ return {
+ sessionId: requireEntityId(record.sessionId, 'sessionId'),
+ turnId: requireEntityId(record.turnId, 'turnId'),
+ sourceTurnId: requireEntityId(record.sourceTurnId, 'sourceTurnId'),
+ };
+ }
+ const record = requireExactRecord(value, 'Session Turn start request intent', [
'sessionId',
'turnId',
'content',
diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts
index f25fbc0e0a..76820bae6f 100644
--- a/packages/runtime-host/src/server/access-authority.ts
+++ b/packages/runtime-host/src/server/access-authority.ts
@@ -19,6 +19,7 @@
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { join } from 'node:path';
+import { isDeepStrictEqual } from 'node:util';
import { runtimeHostAccessCredentialHash } from '../access-credential-identity.js';
import {
type AccessCredentialIssueInput,
@@ -51,6 +52,8 @@ import {
type CollaborationTurnRequestDecideResult,
type CollaborationTurnRequestQueryInput,
type CollaborationTurnRequestQueryResult,
+ type CollaborationTurnRequestWithdrawInput,
+ type CollaborationTurnRequestWithdrawResult,
encodeCollaborationInvitationCode,
type SessionCollaborationGrant,
type SessionCollaborationGrantKind,
@@ -135,6 +138,10 @@ export interface RuntimeHostAccessAuthority {
principalId: string,
input: CollaborationTurnRequestAcknowledgeInput,
): Promise<CollaborationTurnRequestAcknowledgeResult>;
+ withdrawTurnAccessRequest(
+ principalId: string,
+ input: CollaborationTurnRequestWithdrawInput,
+ ): Promise<CollaborationTurnRequestWithdrawResult>;
decideTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestDecideInput,
@@ -389,13 +396,25 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
request.intent.turnId === input.intent.turnId,
);
if (existing) {
- if (existing.intent.content.text !== input.intent.content.text) {
+ if (!isDeepStrictEqual(existing.intent, input.intent)) {
throw new RuntimeHostAccessInputError(
- 'A Turn access request already uses this Turn identity with different content',
+ 'A Turn access request already uses this Turn identity with a different intent',
);
}
return existing;
}
+ if (!('content' in input.intent)) {
+ const { sessionId, sourceTurnId } = input.intent;
+ const equivalentActive = retainedRequests.find(
+ (request) =>
+ request.principalId === principalId &&
+ isActiveTurnAccessRequest(request) &&
+ !('content' in request.intent) &&
+ request.intent.sessionId === sessionId &&
+ request.intent.sourceTurnId === sourceTurnId,
+ );
+ if (equivalentActive) return equivalentActive;
+ }
if (
retainedRequests.filter(isActiveTurnAccessRequest).length >= TURN_ACCESS_REQUEST_ACTIVE_MAX
) {
@@ -476,6 +495,26 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority {
});
}
+ withdrawTurnAccessRequest(
+ principalId: string,
+ input: CollaborationTurnRequestWithdrawInput,
+ ): Promise<CollaborationTurnRequestWithdrawResult> {
+ return this.#mutate(async () => {
+ const current = this.#file.turnAccessRequests.find(
+ (request) => request.requestId === input.requestId && request.principalId === principalId,
+ );
+ if (!current || current.state.kind !== 'pending') return { withdrawn: false };
+ await this.#commit(
+ createAccessCredentialFile(
+ this.#file.credentials,
+ this.#file.sessionGrants,
+ this.#file.turnAccessRequests.filter((request) => request !== current),
+ ),
+ );
+ return { withdrawn: true };
+ });
+ }
+
decideTurnAccessRequest(
principalId: string,
input: CollaborationTurnRequestDecideInput,
@@ -1426,6 +1465,26 @@ export async function acknowledgeCollaborationTurnRequest(
}
}
+export async function withdrawCollaborationTurnRequest(
+ authority: RuntimeHostAccessAuthority | undefined,
+ principalId: string,
+ input: CollaborationTurnRequestWithdrawInput,
+): Promise<OperationOutcome<'collaboration.turn-request.withdraw'>> {
+ if (!authority) return collaborationUnavailable('collaboration.turn-request.withdraw');
+ try {
+ return {
+ ok: true,
+ result: await authority.withdrawTurnAccessRequest(principalId, input),
+ };
+ } catch (error) {
+ return accessPersistenceFailure(
+ error,
+ 'Turn access withdrawal outcome is unknown',
+ 'Turn access request could not be withdrawn',
+ );
+ }
+}
+
export async function decideCollaborationTurnRequest(
authority: RuntimeHostAccessAuthority | undefined,
principalId: string,
@@ -1496,6 +1555,7 @@ function collaborationUnavailable<
| 'collaboration.principal.revoke'
| 'collaboration.turn-request.create'
| 'collaboration.turn-request.acknowledge'
+ | 'collaboration.turn-request.withdraw'
| 'collaboration.turn-request.decide'
| 'collaboration.turn-request.query',
>(operation: K): OperationOutcome<K> {
diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts
index dfdc7e1a49..4532bf0bec 100644
--- a/packages/runtime-host/src/server/access-credential-store.ts
+++ b/packages/runtime-host/src/server/access-credential-store.ts
@@ -88,6 +88,7 @@ export const SESSION_GUEST_OPERATION_GRANTS = Object.freeze([
'collaboration.turn-request.create',
'collaboration.turn-request.acknowledge',
'collaboration.turn-request.query',
+ 'collaboration.turn-request.withdraw',
'runtime.resource.query',
'session.shared.query',
'subscription.open',
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index 2c390fed4f..6496697eff 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -1240,6 +1240,7 @@ export async function createExecutionRuntimeHostComposition(
? new SessionTurnAccessRequestCoordinator({
authority: context.sessionAccessAuthority,
startTurn: interactiveTurns.handlers['turn.start'],
+ regenerateTurn: interactiveTurns.handlers['turn.regenerate'],
hostEpoch: context.hostEpoch,
acquireResidency: () => context.acquireResidency('collaboration-turn-request'),
requestDrain: context.requestDrain,
diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts
index f92554d9d9..9655398dda 100644
--- a/packages/runtime-host/src/server/host-kernel.ts
+++ b/packages/runtime-host/src/server/host-kernel.ts
@@ -62,6 +62,7 @@ import {
acknowledgeCollaborationTurnRequest,
createCollaborationTurnRequest,
decideCollaborationTurnRequest,
+ withdrawCollaborationTurnRequest,
finalizeAccessCredential,
prepareCollaborationInvitation,
queryCollaborationTurnRequests,
@@ -799,6 +800,14 @@ export class RuntimeHostKernel {
input,
),
),
+ 'collaboration.turn-request.withdraw': async (input, context) =>
+ this.#settleAccessCredentialMutation(
+ withdrawCollaborationTurnRequest(
+ this.#options.accessAuthority,
+ context.principal,
+ input,
+ ),
+ ),
'collaboration.turn-request.decide': async (input, context) =>
this.#settleAccessCredentialMutation(
decideCollaborationTurnRequest(this.#options.accessAuthority, context.principal, input),
diff --git a/packages/runtime-host/src/server/session-turn-access-request-coordinator.ts b/packages/runtime-host/src/server/session-turn-access-request-coordinator.ts
index 6cd33f19b2..5d67741799 100644
--- a/packages/runtime-host/src/server/session-turn-access-request-coordinator.ts
+++ b/packages/runtime-host/src/server/session-turn-access-request-coordinator.ts
@@ -33,6 +33,7 @@ type TurnAccessRequestAuthority = Pick<
export class SessionTurnAccessRequestCoordinator {
readonly #authority: TurnAccessRequestAuthority;
readonly #startTurn: OperationHandler<'turn.start'>;
+ readonly #regenerateTurn: OperationHandler<'turn.regenerate'>;
readonly #acquireResidency: () => OperationResidency;
readonly #requestDrain: () => void;
readonly #whenIdle: (sessionId: string) => Promise<void> | undefined;
@@ -44,6 +45,7 @@ export class SessionTurnAccessRequestCoordinator {
constructor(input: {
readonly authority: TurnAccessRequestAuthority;
readonly startTurn: OperationHandler<'turn.start'>;
+ readonly regenerateTurn: OperationHandler<'turn.regenerate'>;
readonly acquireResidency: () => OperationResidency;
readonly requestDrain: () => void;
readonly whenIdle: (sessionId: string) => Promise<void> | undefined;
@@ -51,6 +53,7 @@ export class SessionTurnAccessRequestCoordinator {
}) {
this.#authority = input.authority;
this.#startTurn = input.startTurn;
+ this.#regenerateTurn = input.regenerateTurn;
this.#acquireResidency = input.acquireResidency;
this.#requestDrain = input.requestDrain;
this.#whenIdle = input.whenIdle;
@@ -103,32 +106,10 @@ export class SessionTurnAccessRequestCoordinator {
acquireResidency: this.#acquireResidency,
};
try {
- let outcome: Awaited<ReturnType<OperationHandler<'turn.start'>>>;
- for (;;) {
- try {
- outcome = await this.#startTurn(request.intent, context);
- } catch {
- this.#requestDrain();
- return;
- }
- if (outcome.ok || outcome.error.code !== 'session_busy') break;
- const whenIdle = this.#whenIdle(request.intent.sessionId);
- if (whenIdle) {
- try {
- await whenIdle;
- } catch {
- this.#requestDrain();
- return;
- }
- }
- if (this.#draining) return;
- }
- if (this.#draining && !outcome.ok) return;
+ const admission = await this.#run(request, context);
+ if (!admission) return;
try {
- await this.#authority.completeTurnAccessRequest(
- request.requestId,
- outcome.ok ? outcome.result.kind : 'failed',
- );
+ await this.#authority.completeTurnAccessRequest(request.requestId, admission);
} catch {
this.#requestDrain();
}
@@ -137,6 +118,42 @@ export class SessionTurnAccessRequestCoordinator {
}
}
+ async #run(
+ request: SessionTurnAccessRequest,
+ context: ConnectionContext,
+ ): Promise<'started' | 'blocked' | 'failed' | undefined> {
+ for (;;) {
+ try {
+ if ('content' in request.intent) {
+ const outcome = await this.#startTurn(request.intent, context);
+ if (outcome.ok) return outcome.result.kind;
+ if (outcome.error.code !== 'session_busy') {
+ return this.#draining ? undefined : 'failed';
+ }
+ } else {
+ const outcome = await this.#regenerateTurn(request.intent, context);
+ if (outcome.ok) return 'started';
+ if (outcome.error.code !== 'session_busy') {
+ return this.#draining ? undefined : 'failed';
+ }
+ }
+ } catch {
+ this.#requestDrain();
+ return undefined;
+ }
+ const whenIdle = this.#whenIdle(request.intent.sessionId);
+ if (whenIdle) {
+ try {
+ await whenIdle;
+ } catch {
+ this.#requestDrain();
+ return undefined;
+ }
+ }
+ if (this.#draining) return undefined;
+ }
+ }
+
#settled(): Promise<void> {
return Promise.all(this.#tasks.values()).then(() => undefined);
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/02: fix(desktop): stop misreporting shared Session joins (#4601)
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
commit c0fa4647c580370967c0fbdf554da5452a329d91
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Sep 3 14:31:45 2026 +0800
fix(desktop): stop misreporting shared Session joins (#4601)
Complete Guest imports at the durable credential-activation boundary instead of waiting for the replacement authenticated stream. Keep the reconnect lifecycle running in the background and present uncertain finalization as retained recovery rather than a failed join.
Tests: npm run typecheck --workspace @maka/desktop; npm run lint; npm run format:check; targeted Desktop manager, Guest mount, and join dialog tests
Generated-by: Codex
---
.../__tests__/runtime-host-desktop-manager.test.ts | 22 ++++++--
.../runtime-host-guest-session-mounts.test.ts | 14 ++++--
.../session-collaboration-join-dialog.test.ts | 58 +++++++++++++++++++++-
apps/desktop/src/main/runtime-host-boot.ts | 2 +-
.../src/main/runtime-host-desktop-manager.ts | 32 +++++++++---
.../src/main/runtime-host-guest-session-mounts.ts | 33 +++++++-----
.../ui/session-collaboration-join-dialog.tsx | 5 ++
.../renderer/locales/session-collaboration-copy.ts | 4 ++
apps/desktop/src/shared/session-collaboration.d.ts | 1 +
9 files changed, 140 insertions(+), 31 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
index f8573f5b22..7320f3e868 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
@@ -711,7 +711,7 @@ test('reconnects after a pairing candidate becomes bound to this Client', async
await manager.close();
});
-test('activates Guest access on a fresh stream without replaying route progress', async () => {
+test('completes Guest import at credential activation while reconnect continues', async () => {
const local = candidateHarness({ hostId: 'host-a' });
const remoteHostId = 'a'.repeat(64);
const pending = candidateHarness({
@@ -719,19 +719,27 @@ test('activates Guest access on a fresh stream without replaying route progress'
finalizeReconnectRequired: true,
});
const active = candidateHarness({ hostId: remoteHostId });
- const queue = [local.candidate, pending.candidate, active.candidate];
+ let releaseActive!: () => void;
+ const activeReleased = new Promise<void>((resolve) => {
+ releaseActive = resolve;
+ });
+ let starts = 0;
const phases: string[] = [];
const routeRefreshes: Array<boolean | undefined> = [];
const manager = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{
startCandidate: async (input) => {
+ starts += 1;
if (input.profileTarget) {
routeRefreshes.push(input.refreshPeerRoutes);
input.onConnectionPhase?.('discovering');
input.onConnectionPhase?.('connecting');
}
- return ready(queue.shift()!);
+ if (starts === 1) return ready(local.candidate);
+ if (starts === 2) return ready(pending.candidate);
+ await activeReleased;
+ return ready(active.candidate);
},
reconnectBackoff: { minMs: 0, maxMs: 0 },
},
@@ -743,14 +751,18 @@ test('activates Guest access on a fresh stream without replaying route progress'
);
let activations = 0;
- await manager.finalizeGuestAccess('shared-session', undefined, () => {
+ const result = await manager.finalizeGuestAccess('shared-session', undefined, () => {
activations += 1;
});
+ assert.equal(result, 'reconnecting');
+ assert.equal(manager.current('shared-session')?.readiness, 'reconnecting');
assert.deepEqual(phases, ['discovering', 'connecting']);
- assert.deepEqual(routeRefreshes, [undefined, false]);
assert.equal(activations, 1);
assert.equal(pending.closeCalls, 1);
+ releaseActive();
+ await manager.waitUntilReady('shared-session');
+ assert.deepEqual(routeRefreshes, [undefined, false]);
assert.equal(manager.current('shared-session')?.candidate, active.candidate);
await manager.close();
});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
index 39a7948302..08a02fec32 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts
@@ -64,7 +64,7 @@ test('retains a successful Guest mount and rehydrates the same authority after r
await second!.close();
});
-test('presents Guest import as one connection followed by access activation', async () => {
+test('reports activated Guest access as recovering while reauthentication continues', async () => {
const progress: string[] = [];
const mounts = service(memoryStore(), {
mount: async (_target, _signal, onConnectionPhase) => {
@@ -76,6 +76,7 @@ test('presents Guest import as one connection followed by access activation', as
},
finalizeAccess: async (_mountId, _signal, onAccessActivated) => {
onAccessActivated?.();
+ return 'reconnecting';
},
});
@@ -86,7 +87,7 @@ test('presents Guest import as one connection followed by access activation', as
(phase) => progress.push(phase),
);
- assert.equal(result.kind, 'connected');
+ assert.equal(result.kind, 'recovering');
const visibleProgress = progress.filter((phase, index) => phase !== progress[index - 1]);
assert.deepEqual(visibleProgress, [
'validating_invitation',
@@ -202,6 +203,7 @@ test('settles admitted finalization before committing unmount desire', async ()
finalizeAccess: async () => {
started();
await finalized;
+ return 'ready';
},
unmount: async () => {
assert.deepEqual(await store.read(), []);
@@ -257,6 +259,7 @@ test('removal fences a connecting startup mount before credential finalization',
},
finalizeAccess: async () => {
finalizations += 1;
+ return 'ready';
},
});
@@ -288,6 +291,7 @@ test('removal settles one admitted startup finalization without waiting through
finalizeAccess: async () => {
markFinalizing();
await finalization;
+ return 'ready';
},
});
@@ -315,6 +319,7 @@ test('settles admitted finalization before closing and retains the mount', async
finalizeAccess: async () => {
started();
await finalized;
+ return 'ready';
},
});
@@ -347,11 +352,12 @@ test('retains and reconciles a mount when finalization outcome is unknown', asyn
attempts += 1;
if (attempts === 1) throw new RuntimeHostPairingFinalizationInterruptedError();
resolveReconciled();
+ return 'ready';
},
});
const result = await mounts.importInvitation(invitation('guest-unknown'), false, 'import-unknown');
- assert.equal(result.kind, 'error');
+ assert.equal(result.kind, 'recovering');
assert.equal((await store.read()).length, 1);
await reconciled;
assert.equal(attempts, 2);
@@ -446,7 +452,7 @@ function service(
return createDesktopGuestSessionMountService({
store,
mount: overrides.mount ?? (async () => undefined),
- finalizeAccess: overrides.finalizeAccess ?? (async () => undefined),
+ finalizeAccess: overrides.finalizeAccess ?? (async () => 'ready'),
unmount: overrides.unmount ?? (async () => undefined),
...(overrides.wait ? { wait: overrides.wait } : {}),
onError: overrides.onError ?? (() => undefined),
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 90f28e17fa..6027f80f36 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
@@ -41,6 +41,7 @@ const originalGlobals = {
Event: globalThis.Event,
Node: globalThis.Node,
CSS: globalThis.CSS,
+ getComputedStyle: globalThis.getComputedStyle,
matchMedia: globalThis.matchMedia,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
@@ -109,6 +110,57 @@ test('keeps loading progress visible while an irreversible import settles', asyn
assert.doesNotMatch(document.body.textContent, /finalizingAccess/u);
});
+test('closes as a retained background recovery instead of reporting a failed join', async () => {
+ let imported = 0;
+ let closed = 0;
+ const services: SessionCollaborationServices = {
+ importInvitation: async () => ({ kind: 'recovering', mountId: 'shared-1' }),
+ cancelImport: async () => 'settling',
+ readInvitationClipboard: async () => '',
+ listMounts: async () => [],
+ removeMount: async () => undefined,
+ getPendingTurnRequests: async () => [],
+ decideTurnRequest: async () => {
+ throw new Error('unused');
+ },
+ createOperationId: () => 'operation-1',
+ };
+ const { document } = installDom();
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ mountedRoot = createRoot(container);
+ await act(async () => {
+ mountedRoot?.render(
+ createElement(LocaleProvider, {
+ locale: 'en',
+ children: createElement(AstryxLocaleProvider, {
+ children: createElement(ToastProvider, {
+ children: createElement(SessionCollaborationServicesProvider, {
+ services,
+ children: createElement(SessionCollaborationJoinDialog, {
+ copy: testCopy(),
+ onImported: () => {
+ imported += 1;
+ },
+ onClose: () => {
+ closed += 1;
+ },
+ }),
+ }),
+ }),
+ }),
+ }),
+ );
+ await Promise.resolve();
+ });
+ await setTextArea(document, 'invitation');
+ await clickButton(document, 'join');
+
+ assert.equal(imported, 1);
+ assert.equal(closed, 1);
+ assert.doesNotMatch(document.body.textContent, /connectionFailed/u);
+});
+
function installDom(): { document: Document } {
const parsed = parseHTML('<html><body><div id="root"></div></body></html>');
const { document, window } = parsed;
@@ -122,7 +174,10 @@ function installDom(): { document: Document } {
removeEventListener() {},
dispatchEvent: () => false,
});
- Object.assign(window, { matchMedia, scrollTo() {} });
+ const getComputedStyle = () => ({
+ getPropertyValue: () => '',
+ }) as unknown as CSSStyleDeclaration;
+ Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} });
Object.assign(window.HTMLElement.prototype, {
showModal(this: HTMLElement) {
this.setAttribute('open', '');
@@ -140,6 +195,7 @@ function installDom(): { document: Document } {
Event: window.Event,
Node: window.Node,
CSS: { escape: (value: string) => value },
+ getComputedStyle,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0),
cancelAnimationFrame: (handle: number) => clearTimeout(handle),
IS_REACT_ACT_ENVIRONMENT: true,
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index a326187f48..74e68af3ee 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -619,7 +619,7 @@ const guestSessionMountService = createDesktopGuestSessionMountService({
},
finalizeAccess: async (mountId, signal, onAccessActivated) => {
if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable');
- await runtimeHostManager.finalizeGuestAccess(mountId, signal, onAccessActivated);
+ return runtimeHostManager.finalizeGuestAccess(mountId, signal, onAccessActivated);
},
unmount: async (mountId) => {
if (!runtimeHostManager) return;
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index b531d0b3a7..150e760454 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -83,7 +83,7 @@ export interface RuntimeHostDesktopManager {
mountId: string,
signal?: AbortSignal,
onAccessActivated?: () => void,
- ): Promise<void>;
+ ): Promise<RuntimeHostGuestAccessFinalization>;
unmountGuest(mountId: string): Promise<void>;
wakePeerRecovery(): void;
disable(profileId: string): Promise<void>;
@@ -175,11 +175,13 @@ export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconn
export class RuntimeHostPairingFinalizationInterruptedError extends Error {
constructor(options?: ErrorOptions) {
- super('Runtime Host pairing finalization was deferred until the next startup', options);
+ super('Runtime Host pairing finalization is continuing in the background', options);
this.name = 'RuntimeHostPairingFinalizationInterruptedError';
}
}
+export type RuntimeHostGuestAccessFinalization = 'ready' | 'reconnecting';
+
const DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS = 30_000;
export type RuntimeHostRestartableConflict = Extract<
@@ -359,24 +361,27 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
}
finalizePairing(profileId: string): Promise<void> {
- return this.#mutateTarget(profileId, () => this.#finalizeAccessCredential(profileId));
+ return this.#mutateTarget(profileId, async () => {
+ await this.#finalizeAccessCredential(profileId, 'ready');
+ });
}
finalizeGuestAccess(
mountId: string,
signal?: AbortSignal,
onAccessActivated?: () => void,
- ): Promise<void> {
+ ): Promise<RuntimeHostGuestAccessFinalization> {
return this.#mutateTarget(mountId, () =>
- this.#finalizeAccessCredential(mountId, signal, onAccessActivated),
+ this.#finalizeAccessCredential(mountId, 'activation', signal, onAccessActivated),
);
}
async #finalizeAccessCredential(
profileId: string,
+ completion: 'activation' | 'ready',
externalSignal?: AbortSignal,
onAccessActivated?: () => void,
- ): Promise<void> {
+ ): Promise<RuntimeHostGuestAccessFinalization> {
const target = this.#requireTarget(profileId);
if (target.target.profile.kind !== 'remote') {
throw new Error('Only remote Runtime Host profiles can finalize pairing');
@@ -415,11 +420,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
) {
target.skipPeerRouteRefreshOnce = true;
}
+ if (completion === 'activation') {
+ try {
+ await candidate.close();
+ } catch (error) {
+ // The Host already committed the credential. Candidate cleanup
+ // cannot turn that durable success into a failed Guest import;
+ // its closed signal still drives the reconnect lifecycle.
+ this.#baseInput.onError?.(error);
+ }
+ return 'reconnecting';
+ }
await candidate.close();
await this.#waitForReadyCandidate(lifecycle, candidate, signal);
}
- signal.throwIfAborted();
- return;
+ if (completion === 'ready') signal.throwIfAborted();
+ return 'ready';
} catch (error) {
if (pairingFinalizeTimedOut(error)) {
throw new RuntimeHostPairingFinalizationInterruptedError({ cause: error });
diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
index 44d0a94e5d..39b231d080 100644
--- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
+++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts
@@ -41,7 +41,10 @@ import {
decodeDesktopCollaborationInvitation,
DESKTOP_COLLABORATION_INVITATION_CODE_MAX_BYTES,
} from './runtime-host-collaboration-invitation.js';
-import { RuntimeHostPairingFinalizationInterruptedError } from './runtime-host-desktop-manager.js';
+import {
+ RuntimeHostPairingFinalizationInterruptedError,
+ type RuntimeHostGuestAccessFinalization,
+} from './runtime-host-desktop-manager.js';
const STORE_SCHEMA_VERSION = 1;
const STORE_SLOT = 'desktop-guest-session-mounts';
@@ -64,7 +67,7 @@ interface GuestSessionMountDocument {
interface LiveGuestActivationBase {
readonly controller: AbortController;
stage: 'connecting' | 'finalizing';
- finalization?: Promise<void>;
+ finalization?: Promise<RuntimeHostGuestAccessFinalization>;
task: Promise<unknown>;
}
@@ -140,7 +143,7 @@ export function createDesktopGuestSessionMountService(input: {
mountId: string,
signal: AbortSignal,
onAccessActivated?: () => void,
- ) => Promise<void>;
+ ) => Promise<RuntimeHostGuestAccessFinalization>;
readonly unmount: (mountId: string) => Promise<void>;
readonly wait?: (delayMs: number, signal: AbortSignal) => Promise<void>;
readonly onError?: (error: Error, mount: GuestSessionMount) => void;
@@ -203,7 +206,7 @@ export function createDesktopGuestSessionMountService(input: {
const activate = async (
activation: LiveGuestActivation,
mount: GuestSessionMount,
- ): Promise<void> => {
+ ): Promise<RuntimeHostGuestAccessFinalization> => {
activation.stage = 'connecting';
await input.mount(resolveMountTarget(mount), activation.controller.signal, (phase) => {
if (activation.kind === 'import') {
@@ -233,8 +236,9 @@ export function createDesktopGuestSessionMountService(input: {
);
activation.finalization = finalization;
try {
- await finalization;
+ const result = await finalization;
activation.controller.signal.throwIfAborted();
+ return result;
} finally {
if (activation.finalization === finalization) activation.finalization = undefined;
}
@@ -361,12 +365,15 @@ export function createDesktopGuestSessionMountService(input: {
let reconcile = false;
try {
reportImportProgress(activation.onProgress, 'discovering_host');
- await activate(activation, mount);
+ const finalization = await activate(activation, mount);
activation.controller.signal.throwIfAborted();
if (!(await load()).has(mount.mountId)) {
throw new Error('Shared Session mount was removed while connecting');
}
- return { kind: 'connected', mountId: mount.mountId };
+ return {
+ kind: finalization === 'ready' ? 'connected' : 'recovering',
+ mountId: mount.mountId,
+ };
} catch (error) {
if (
activation.stage === 'finalizing' &&
@@ -382,11 +389,13 @@ export function createDesktopGuestSessionMountService(input: {
activation.controller.abort(new Error('Shared Session mount activation failed'));
await input.unmount(mount.mountId).catch(() => undefined);
}
- return {
- kind: 'error',
- reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed',
- message: asError(error).message,
- };
+ return reconcile
+ ? { kind: 'recovering', mountId: mount.mountId }
+ : {
+ kind: 'error',
+ reason: isPeerPathUnavailable(error) ? 'peer_path_unavailable' : 'connection_failed',
+ message: asError(error).message,
+ };
} finally {
activations.delete(activation);
if (reconcile) beginStartupReconciliation(mount);
diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx
index d4f1ab7450..d4c0e27a05 100644
--- a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx
+++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx
@@ -56,6 +56,8 @@ export interface SessionCollaborationJoinCopy {
readonly authenticatingGuest: string;
readonly finalizingAccess: string;
readonly loadingSession: string;
+ readonly recoveryStarted: string;
+ readonly recoveryStartedBody: string;
readonly retainedTasks: string;
readonly disconnect: string;
readonly disconnectFailed: string;
@@ -144,6 +146,9 @@ export function SessionCollaborationJoinDialog(props: {
return;
}
props.onImported();
+ if (result.kind === 'recovering') {
+ toast.info(props.copy.recoveryStarted, props.copy.recoveryStartedBody);
+ }
finishClose();
} catch (error) {
if (!open.current || activeOperationId.current !== operationId) return;
diff --git a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
index 21b53b6441..915cb39042 100644
--- a/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
+++ b/apps/desktop/src/renderer/locales/session-collaboration-copy.ts
@@ -68,6 +68,8 @@ const ZH = {
authenticatingGuest: '正在验证访客凭据…',
finalizingAccess: '正在确认访客访问权限…',
loadingSession: '正在加载共享任务…',
+ recoveryStarted: '共享任务正在后台连接',
+ recoveryStartedBody: '任务已保存在此设备;连接恢复后会自动出现在任务列表中。',
invalidCode: '邀请码无效',
connectionFailed: '无法加入共享任务',
directPathUnavailable: '未能连接到任务所在的 Runtime Host。请确认当前 Desktop Client 和该 Host 在同一个 Peer Mesh 中,并且存在可用的直连或成员转发路径;可在 Peer Mesh 设置中同步路径并检查成员转发。',
@@ -160,6 +162,8 @@ const EN = {
authenticatingGuest: 'Authenticating the Guest credential…',
finalizingAccess: 'Confirming Guest access…',
loadingSession: 'Loading the shared task…',
+ recoveryStarted: 'Shared task is connecting in the background',
+ recoveryStartedBody: 'The task is saved on this device and will appear in the task list when the connection recovers.',
invalidCode: 'The invitation code is invalid',
connectionFailed: 'Could not join the shared task',
directPathUnavailable: 'Could not reach the Runtime Host for this task. Make sure this Desktop Client and the Host are in the same Peer Mesh and have a usable direct or member-transit path. Sync routes and check member transit in Peer Mesh settings.',
diff --git a/apps/desktop/src/shared/session-collaboration.d.ts b/apps/desktop/src/shared/session-collaboration.d.ts
index bc942de184..c617854ec0 100644
--- a/apps/desktop/src/shared/session-collaboration.d.ts
+++ b/apps/desktop/src/shared/session-collaboration.d.ts
@@ -19,6 +19,7 @@
export type SessionCollaborationImportResult =
| { readonly kind: 'connected'; readonly mountId: string }
+ | { readonly kind: 'recovering'; readonly mountId: string }
| {
readonly kind: 'error';
readonly reason:
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org