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('[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('[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('[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((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('[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('[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('
'); + 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 { + await act(async () => { + textbox.textContent = value; + textbox.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); +} + +async function pressEnter(textbox: HTMLElement): Promise { + 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 { + 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; 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; getTurnRequests(sessionId: string): Promise; /** Pending Owner decisions across every connected Owner Runtime Host. */ @@ -776,6 +783,10 @@ export interface MakaBridge { sessionId: string, requestId: string, ): Promise; + withdrawTurnRequest( + sessionId: string, + requestId: string, + ): Promise; 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; + }, 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; - 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; refreshSessions: () => Promise; 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 { + 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; 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(); 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({
sharedSessionDialog.openSession(activeDesktopSession), } } @@ -2899,6 +2888,8 @@ function AppShellContent({ {!sharedSessionActive && activeId ? ( ) : null} {!sharedSessionActive && navSelection.section === 'sessions' && @@ -2923,7 +2914,9 @@ function AppShellContent({ /> ) : null} {sharedSessionActive && activeId ? ( - + ) : ( {navSelection.section === 'sessions' ? ( + + {(turnActions) => ( 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} /> + )} + ) : null} )} 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(); + 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 => + message.type === 'user' && message.turnId === intent.sourceTurnId, + ); + const sourceText = sourceUserMessage + ? userFacingText(sourceUserMessage) + : messages.find( + (message): message is Extract => + 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; listMounts(): Promise; removeMount(mountId: string): Promise; + requestTurn( + sessionId: string, + input: + | { readonly kind: 'start'; readonly turnId: string; readonly text: string } + | { + readonly kind: 'regenerate'; + readonly turnId: string; + readonly sourceTurnId: string; + }, + ): Promise; + getTurnRequests(sessionId: string): Promise; + acknowledgeTurnRequest( + sessionId: string, + requestId: string, + ): Promise; + withdrawTurnRequest( + sessionId: string, + requestId: string, + ): Promise; getPendingTurnRequests(): Promise; 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 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( - () => ({ ...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; + +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()); + 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 ( props.onOpenSession(props.sessionId, turnId)} /> ); } export function SessionTurnRequestApproval(props: { readonly requests: readonly SessionTurnAccessRequest[]; + readonly messages: readonly StoredMessage[]; readonly workingRequestIds: ReadonlySet; readonly copy: Pick< SessionTurnRequestInboxCopy, - 'ownerTurnRequestTitle' | 'reject' | 'approve' | 'moreTurnRequests' + | 'ownerTurnRequestTitle' + | 'ownerRegenerateRequestTitle' + | 'regenerateRequest' + | 'viewSourceTurn' + | 'reject' + | 'approve' + | 'moreTurnRequests' >; readonly onDecide: ( request: SessionTurnAccessRequest, decision: 'approve' | 'reject', ) => void | Promise; + 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 (
- {request.intent.content.text} + {description}
)} - label={copy.ownerTurnRequestTitle} + label={title} placement="above" alignment="start" focusTrigger="always" hasHoverIndication={false} > - {request.intent.content.text} + {description} )} endContent={(
+ {sourceTurnId ? ( +
+ ); + })} +
+ ) : null} + + ) : null} + {canRequestTurns ? ( +
+ { + if (connectionPending && event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + } + }} + /> + )} + sendButton={connectionPending ? ( + + +
+ ) : authorityAvailable === false ? ( +
{copy.accessUnavailable}
+ ) : authorityAvailable === true ? ( +
{copy.observeHelp}
+ ) : null} + + ); +} + +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 interruption', branch: 'Branch a new task from this response', copy: 'Copy response to clipboard', copyEmpty: 'This response has no content to copy' }, + 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 finish before branching', branchAborted: 'Branch from the context before the interruption', branch: 'Branch a new task from this response', copy: 'Copy response to clipboard', copyEmpty: 'This response has no content to copy' }, 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) {
- {request.intent.content.text} + {describeTurnRequestIntent(request.intent, copy.regenerateRequest)} {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([]); - const [canRequestTurns, setCanRequestTurns] = useState(false); - const [authorityAvailable, setAuthorityAvailable] = useState(); - const [reconciling, setReconciling] = useState(false); - const [working, setWorking] = useState(false); - const attemptRef = useRef<{ readonly turnId: string; readonly text: string } | undefined>( - undefined, - ); - const reconcilingTurnIdRef = useRef(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 { - 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 { - 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 ( -
- {requests.length > 0 ? ( -
- {requests.slice().reverse().map((request) => ( -
- - {request.intent.content.text} - - - {turnRequestStateLabel(request, copy)} - - {isTurnRequestTerminal(request) ? ( -
- ))} -
- ) : null} - {authorityAvailable === false ? ( -
- {reconciling ? copy.turnRequestReconciling : copy.accessUnavailable} -
- ) : authorityAvailable === true && canRequestTurns ? ( -
-