From 2ed26ce9f8d8e0a829996eb8525ca015c41f9955 Mon Sep 17 00:00:00 2001 From: YayoiNanoka Date: Wed, 14 Oct 2026 02:01:34 +0800 Subject: [PATCH 01/11] feat(desktop): open sessions in dedicated windows Coordinate exclusive session-group ownership across independent windows, preserve workspace drafts and resources through transfers, and cover lifecycle and recovery regressions. Generated-by: OpenAI Codex --- apps/desktop/e2e-budget.json | 4 + apps/desktop/e2e/session-windows.spec.ts | 115 ++++ apps/desktop/renderer-architecture.json | 28 +- .../main/__tests__/attachment-preview.test.ts | 40 ++ .../main/__tests__/browser-ipc-main.test.ts | 32 +- .../__tests__/command-palette-retired.test.ts | 16 + .../__tests__/composer-staging-owner.test.ts | 260 ++++++++- .../__tests__/message-queue-ui-state.test.ts | 1 + .../__tests__/quote-companion-retry.test.ts | 94 ++++ ...time-host-session-domains-ipc-main.test.ts | 44 +- ...me-host-session-execution-ipc-main.test.ts | 37 ++ .../session-local-attachment-recovery.test.ts | 43 ++ .../session-window-coordinator.test.ts | 351 ++++++++++++ .../__tests__/session-window-group.test.ts | 54 ++ .../main/__tests__/session-window-pip.test.ts | 73 +++ .../__tests__/session-window-renderer.test.ts | 282 ++++++++++ .../session-workspace-state-store.test.ts | 276 ++++++++++ .../workbar-services-adapter.test.ts | 3 + .../workspace-window-creation.test.ts | 148 +++++ .../workspace-window-registry.test.ts | 48 ++ apps/desktop/src/main/app-ipc-main.ts | 6 +- apps/desktop/src/main/app-quit-coordinator.ts | 2 + apps/desktop/src/main/attachment-preview.ts | 34 +- apps/desktop/src/main/browser-ipc-main.ts | 58 +- .../src/main/computer-use/pip-electron.ts | 2 + .../src/main/computer-use/pip-window.ts | 67 ++- .../desktop-native-capability-assembly.ts | 12 +- .../src/main/desktop-shell-presentation.ts | 2 +- apps/desktop/src/main/early-window.ts | 9 +- apps/desktop/src/main/main-window.ts | 273 ++++++---- apps/desktop/src/main/notifications-main.ts | 6 +- apps/desktop/src/main/pet-pack-import.ts | 4 +- .../main/runtime-host-artifacts-ipc-main.ts | 2 +- apps/desktop/src/main/runtime-host-boot.ts | 196 ++++++- .../src/main/runtime-host-config-ipc-main.ts | 4 +- .../main/runtime-host-desktop-candidate.ts | 6 + .../runtime-host-session-bundle-ipc-main.ts | 12 +- .../runtime-host-session-domains-ipc-main.ts | 3 +- ...runtime-host-session-execution-ipc-main.ts | 23 +- .../main/runtime-host-shell-runs-ipc-main.ts | 41 +- .../src/main/runtime-host-skills-ipc-main.ts | 4 +- .../main/session-local-attachment-recovery.ts | 58 +- .../src/main/session-window-coordinator.ts | 513 ++++++++++++++++++ apps/desktop/src/main/session-window-group.ts | 72 +++ .../src/main/session-workspace-state-store.ts | 331 +++++++++++ .../src/main/workspace-window-registry.ts | 82 +++ apps/desktop/src/preload/bridge-contract.d.ts | 4 +- apps/desktop/src/preload/preload.ts | 14 + .../src/renderer/app-shell-command-actions.ts | 12 +- .../desktop/src/renderer/app-shell-effects.ts | 9 +- apps/desktop/src/renderer/app-shell.tsx | 256 +++------ .../session-side-conversation-handoff.ts | 92 ++++ .../contracts/session-workspace-draft.ts | 52 ++ .../contracts/session-workspace-handoff.ts | 92 ++++ .../src/renderer/command-palette-commands.ts | 7 +- .../composition/desktop-feature-services.tsx | 5 + .../controller/composer-staging-commands.ts | 4 + .../composer-submission-commands.ts | 4 + .../controller/composer-surface-owner.ts | 62 +++ .../controller/transcript-reading-position.ts | 9 + .../controller/use-composer-attachments.ts | 13 +- .../controller/use-composer-submission.ts | 33 +- .../controller/use-conversation-controller.ts | 4 +- .../controller/use-conversation-target.ts | 1 + .../renderer/features/conversation/index.ts | 4 + .../model/composer-staging-contract.ts | 12 + .../model/composer-submission-contract.ts | 4 + .../model/conversation-workspace.ts | 4 +- .../renderer/features/conversation/testing.ts | 1 + .../ui/composer-staging-provider.tsx | 4 + .../conversation/ui/conversation-provider.tsx | 4 +- .../ui/conversation-window-state.ts | 47 ++ .../use-session-navigation-controller.ts | 46 +- .../use-session-navigation-row-actions.ts | 65 +++ .../features/session-navigation/index.ts | 2 + .../features/session-navigation/ports.ts | 1 + .../ui/session-navigation-commands.tsx | 38 ++ .../ui/session-navigation-provider.tsx | 3 +- .../controller/session-mode-commands.ts | 76 +++ .../features/session-settings/index.ts | 2 + .../controller/session-window-command.ts | 74 +++ .../controller/session-window-navigation.ts | 74 +++ .../renderer/features/session-windows/copy.ts | 26 + .../features/session-windows/index.ts | 24 + .../features/session-windows/ports.ts | 27 + .../session-windows/services-context.tsx | 28 + .../features/session-windows/testing.ts | 21 + .../ui/session-window-binding.tsx | 178 ++++++ .../ui/session-window-empty-state.tsx | 24 + .../ui/session-window-root.tsx | 96 ++++ .../controller/use-workbar-controller.ts | 11 +- .../controller/use-workbar-layout-state.ts | 42 +- .../controller/workbar-shell-bridge.ts | 2 + .../features/workbar/model/workbar-layout.ts | 20 + .../features/workbar/model/workbar-tabs.ts | 37 +- .../src/renderer/features/workbar/ports.ts | 2 + .../src/renderer/features/workbar/testing.ts | 3 + .../tools/side-chat/quote-companion-core.ts | 2 + .../tools/side-chat/quote-companion-panel.tsx | 48 +- .../tools/side-chat/use-quote-companion.ts | 68 ++- .../use-side-conversation-workspace.ts | 20 +- .../features/workbar/ui/workbar-surface.tsx | 19 +- .../features/workbar/ui/workbar-toggle.tsx | 2 +- .../src/renderer/locales/shell-copy.ts | 4 + apps/desktop/src/renderer/nav-selection.ts | 3 +- .../desktop/create-session-window-services.ts | 35 ++ .../desktop/create-workbar-services.ts | 9 +- .../src/renderer/shell/window-titlebar.tsx | 19 +- .../src/renderer/styles/shell-layout.css | 18 + .../src/shared/session-window-contract.d.ts | 80 +++ .../src/shared/session-workbar-state.d.ts | 62 +++ .../src/shared/session-workspace-state.d.ts | 75 +++ apps/desktop/stories/app-shell.stories.tsx | 30 +- .../composer-draft-references.test.tsx | 63 ++- .../session-history-multi-select.test.tsx | 32 +- packages/ui/src/composer-helpers.ts | 38 +- packages/ui/src/composer.tsx | 23 +- packages/ui/src/conversation-copy.ts | 7 +- packages/ui/src/session-history-list.tsx | 9 +- packages/ui/src/use-composer-attachments.ts | 102 +++- packages/ui/src/use-composer-draft.ts | 34 +- scripts/pip-interaction-smoke.mjs | 2 +- 122 files changed, 5624 insertions(+), 626 deletions(-) create mode 100644 apps/desktop/e2e/session-windows.spec.ts create mode 100644 apps/desktop/src/main/__tests__/session-window-coordinator.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-window-group.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-window-pip.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-window-renderer.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-workspace-state-store.test.ts create mode 100644 apps/desktop/src/main/__tests__/workspace-window-creation.test.ts create mode 100644 apps/desktop/src/main/__tests__/workspace-window-registry.test.ts create mode 100644 apps/desktop/src/main/session-window-coordinator.ts create mode 100644 apps/desktop/src/main/session-window-group.ts create mode 100644 apps/desktop/src/main/session-workspace-state-store.ts create mode 100644 apps/desktop/src/main/workspace-window-registry.ts create mode 100644 apps/desktop/src/renderer/application/contracts/session-side-conversation-handoff.ts create mode 100644 apps/desktop/src/renderer/application/contracts/session-workspace-draft.ts create mode 100644 apps/desktop/src/renderer/application/contracts/session-workspace-handoff.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/composer-surface-owner.ts create mode 100644 apps/desktop/src/renderer/features/conversation/ui/conversation-window-state.ts create mode 100644 apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-row-actions.ts create mode 100644 apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-commands.tsx create mode 100644 apps/desktop/src/renderer/features/session-settings/controller/session-mode-commands.ts create mode 100644 apps/desktop/src/renderer/features/session-windows/controller/session-window-command.ts create mode 100644 apps/desktop/src/renderer/features/session-windows/controller/session-window-navigation.ts create mode 100644 apps/desktop/src/renderer/features/session-windows/copy.ts create mode 100644 apps/desktop/src/renderer/features/session-windows/index.ts create mode 100644 apps/desktop/src/renderer/features/session-windows/ports.ts create mode 100644 apps/desktop/src/renderer/features/session-windows/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/session-windows/testing.ts create mode 100644 apps/desktop/src/renderer/features/session-windows/ui/session-window-binding.tsx create mode 100644 apps/desktop/src/renderer/features/session-windows/ui/session-window-empty-state.tsx create mode 100644 apps/desktop/src/renderer/features/session-windows/ui/session-window-root.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-session-window-services.ts create mode 100644 apps/desktop/src/shared/session-window-contract.d.ts create mode 100644 apps/desktop/src/shared/session-workbar-state.d.ts create mode 100644 apps/desktop/src/shared/session-workspace-state.d.ts diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 3f0fa9a9f22..8981629fc3d 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -84,6 +84,10 @@ "zh-tw-locale.spec.ts": { "tests": 1, "electron": "the locale is a persisted Host setting and only takes effect through a renderer reload" + }, + "session-windows.spec.ts": { + "tests": 2, + "electron": "native BrowserWindow focus/minimize/close and main recreation, real WebContentsView identity across renderer/main transfer, and restart with persisted drafts but no restored session windows" } } } diff --git a/apps/desktop/e2e/session-windows.spec.ts b/apps/desktop/e2e/session-windows.spec.ts new file mode 100644 index 00000000000..70bc5eefc50 --- /dev/null +++ b/apps/desktop/e2e/session-windows.spec.ts @@ -0,0 +1,115 @@ +/* + * 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 { createServer } from 'node:http'; +import type { ElectronApplication, Page } from '@playwright/test'; +import { COMPOSER_INPUT, awaitSendReady, ensureSidebarExpanded, expect, test } from './fixtures'; + +async function createTask(page: Page, text: string): Promise { + await page.locator(COMPOSER_INPUT).fill(text); + await awaitSendReady(page); + await page.locator(COMPOSER_INPUT).press('Enter'); + await expect(page.getByText(`Fake backend received: ${text}`)).toBeVisible(); + await ensureSidebarExpanded(page); + const id = await page.locator('[data-session-id]:has([aria-current="page"])').getAttribute('data-session-id'); + if (!id) throw new Error('No active session'); + return id; +} +async function detach(app: ElectronApplication, page: Page, id: string): Promise { + const next = app.waitForEvent('window'); + await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id); + const child = await next; + await expect(child.locator(COMPOSER_INPUT)).toBeVisible(); + return child; +} + +// Electron boundary: real WebContentsView identity and renderer/main handoff; +// a mock window cannot expose Chromium destruction during native reparenting. +test('detaching and returning keeps the same native browser and restores a minimized owner', async ({ sessionLocalWindow }) => { + const { page, app } = sessionLocalWindow; + const id = await createTask(page, 'multi-window browser continuity'); + await page.locator(COMPOSER_INPUT).fill('draft travelling with the session'); + const server = createServer((_request, response) => response.end('Window handoff')); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No server address'); + const url = `http://127.0.0.1:${address.port}/`; + await page.evaluate(async ({ id, url }) => { window.maka.browser.setActiveSession(id); await window.maka.browser.navigate(id, url); }, { id, url }); + const nativePageId = await app.evaluate(async ({ webContents }, url) => { + const native = webContents.getAllWebContents().find((contents) => contents.getURL() === url); + if (!native) throw new Error('Native browser page not found'); + await native.executeJavaScript('document.querySelector("input").value = "preserved native state"'); + return native.id; + }, url); + const child = await detach(app, page, id); + await expect(child.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session'); + await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); + const childNative = await app.browserWindow(child); + await childNative.evaluate((window) => window.minimize()); + await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id); + await expect.poll(() => childNative.evaluate((window) => window.isMinimized())).toBe(false); + // Isolated E2E fixtures intentionally reveal without taking OS focus. + await expect.poll(() => childNative.evaluate((window) => window.isVisible())).toBe(true); + await child.getByRole('button', { name: '在主窗口打开', exact: true }).click(); + await expect.poll(() => child.isClosed()).toBe(true); + await expect(page.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session'); + expect(await app.evaluate(async ({ webContents }, nativeId) => { + const native = webContents.fromId(nativeId); + return native?.executeJavaScript('document.querySelector("input").value'); + }, nativePageId)).toBe('preserved native state'); + } finally { await new Promise((resolve) => server.close(() => resolve())); } +}); + +// Electron boundary: closing/recreating real BrowserWindows and app restart, +// plus persisted draft recovery across separate renderer/main lifetimes. +test('closing main keeps the session window alive and restart opens only main', async ({ sessionLocalWindow }) => { + const { page, app, restart } = sessionLocalWindow; + const id = await createTask(page, 'multi-window independent lifetime'); + const child = await detach(app, page, id); + await child.locator(COMPOSER_INPUT).fill('saved after closing a session window'); + const mainNative = await app.browserWindow(page); + await mainNative.evaluate((window) => window.close()); + await expect.poll(() => page.isClosed()).toBe(true); + expect(child.isClosed()).toBe(false); + const reopened = app.waitForEvent('window'); + await child.getByRole('button', { name: '在主窗口打开', exact: true }).click(); + const main = await reopened; + await expect(main.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window'); + const secondChild = await detach(app, main, id); + await expect(secondChild.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window'); + const childNative = await app.browserWindow(secondChild); + await childNative.evaluate((window) => window.close()); + await expect.poll(() => secondChild.isClosed()).toBe(true); + await ensureSidebarExpanded(main); + await main.locator(`[data-session-id=${JSON.stringify(id)}]`).click(); + await expect(main.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window'); + await detach(app, main, id); + let restartedApp: ElectronApplication | undefined; + const restored = await restart(async (next) => { restartedApp = next; }); + expect((await restored.evaluate(() => window.maka.appWindow.context())).kind).toBe('main'); + await expect.poll(async () => { + const pages = restartedApp!.windows().filter((window) => !window.isClosed()); + const contexts = await Promise.all(pages.map((window) => window.evaluate(() => window.maka?.appWindow?.context()).catch(() => undefined))); + return contexts.filter((context) => context?.kind === 'session').length; + }).toBe(0); + await ensureSidebarExpanded(restored); + await restored.locator(`[data-session-id=${JSON.stringify(id)}]`).click(); + await expect(restored.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window'); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 9b57266c893..da96acda804 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -348,12 +348,15 @@ "ConversationMessageConsumer", "ConversationProvider", "ConversationTranscriptRegion", + "ConversationWindowState", "NEW_TASK_PENDING_KEY", "PlanExecutionSurface", "PlanProvider", "TaskReadinessProvider", "createComposerStagingCommands", "createComposerSubmissionCommands", + "createComposerSurfaceOwner", + "createComposerUriDispatcher", "createExecutorSessionActivator", "resolveTaskReadinessModelTarget", "useActiveExecutionBoundary", @@ -444,6 +447,7 @@ }, "src/renderer/features/session-navigation/index.ts": { "appShell": [ + "SessionNavigationCommands", "SessionNavigationProvider", "createSessionOpenCommand", "sessionRailLayoutStore", @@ -456,12 +460,24 @@ "src/renderer/features/session-settings/index.ts": { "appShell": [ "SessionSettingsProvider", + "createSessionModeCommands", + "deriveSessionSettingsPresentation", "useSessionSettingIntent" ], "composition": [ "SessionSettingsServicesProvider" ] }, + "src/renderer/features/session-windows/index.ts": { + "appShell": [ + "SessionWindowBinding", + "SessionWindowEmptyState", + "SessionWindowRoot" + ], + "composition": [ + "SessionWindowServicesProvider" + ] + }, "src/renderer/features/storage-usage/index.ts": { "appShell": [ "ArchiveRetentionNotices" @@ -547,7 +563,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2158 + "nonTriviaTokens": 2132 }, "src/renderer/app-shell-copy.ts": { "importDeclarations": 1, @@ -609,7 +625,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 843 + "nonTriviaTokens": 841 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 4, @@ -642,7 +658,7 @@ "nonTriviaTokens": 858 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 34, + "importDeclarations": 33, "bridgePaths": {}, "environmentCapabilities": { "window.requestAnimationFrame": 3 @@ -699,6 +715,7 @@ "./features/session-collaboration": 1, "./features/session-navigation": 1, "./features/session-settings": 1, + "./features/session-windows/index.js": 1, "./features/storage-usage/index.js": 1, "./features/task-entry": 1, "./features/workbar": 1, @@ -725,13 +742,12 @@ "@astryxdesign/core/AppShell": 1, "@maka/core/onboarding-milestone": 1, "@maka/core/session": 1, - "@maka/core/session-revisions": 1, "@maka/core/ui-locale": 1, "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 43, - "nonTriviaTokens": 7290 + "importSpecifiers": 42, + "nonTriviaTokens": 7279 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/attachment-preview.test.ts b/apps/desktop/src/main/__tests__/attachment-preview.test.ts index 221f5a9a557..b16ae2839af 100644 --- a/apps/desktop/src/main/__tests__/attachment-preview.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-preview.test.ts @@ -20,8 +20,10 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; import { loadApprovalPreview, + loadRecoveryApprovalPreview, registerAttachmentPreviewIpc, type AttachmentPreviewResult, } from '../attachment-preview.js'; @@ -200,6 +202,8 @@ describe('staged attachment preview (approval source)', () => { (event: { sender: { id: number } }, approvalId: unknown) => Promise >(); registerAttachmentPreviewIpc({ + recovery: new SessionLocalAttachmentRecovery(), + authorizeRecovery: () => true, ipcMain: { handle: (channel, listener) => handlers.set(channel, listener) }, approvals, readFile: async () => PNG, @@ -259,3 +263,39 @@ describe('staged attachment preview (approval source)', () => { ); }); }); + + +describe('workspace recovery image preview', () => { + const owner = { senderId: 1, partition: 'authorized', scope: { hostId: 'host', targetEpoch: 'epoch' }, sessionId: 'session' }; + it('rebuilds a thumbnail for the new window without exposing original bytes or consuming its send approval', async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]); + const thumbnail = Uint8Array.from([...PNG, 1, 2, 3]); + const result = await loadRecoveryApprovalPreview({ senderId: owner.senderId, approvalId: approval.approvalId, recovery, + authorizeRecovery: (candidate) => candidate.partition === owner.partition && candidate.scope.targetEpoch === owner.scope.targetEpoch, + renderPreview: async () => ({ mimeType: 'image/png', bytes: thumbnail }), + }); + assert.deepEqual(result, { ok: true, mimeType: 'image/png', base64: Buffer.from(thumbnail).toString('base64') }); + assert.notEqual((result as { base64: string }).base64, Buffer.from(PNG).toString('base64')); + assert.equal(recovery.prepare(owner, [approval]).commit(() => true), true); + }); + it('rejects another window or revoked Host and never falls back to original recovery bytes', async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]); + const input = { senderId: owner.senderId, approvalId: approval.approvalId, recovery, authorizeRecovery: () => true, renderPreview: async () => null }; + assert.deepEqual(await loadRecoveryApprovalPreview({ ...input, senderId: 2 }), { ok: false, reason: 'not_found' }); + assert.deepEqual(await loadRecoveryApprovalPreview({ ...input, authorizeRecovery: () => false }), { ok: false, reason: 'not_found' }); + assert.deepEqual(await loadRecoveryApprovalPreview(input), { ok: false, reason: 'unreadable' }); + }); + it('discards a thumbnail if source authority expires during decoding', async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]); + const result = await loadRecoveryApprovalPreview({ senderId: owner.senderId, approvalId: approval.approvalId, recovery, + authorizeRecovery: () => true, renderPreview: async () => { + recovery.clear(owner.partition); + return { mimeType: 'image/png', bytes: PNG }; + }, + }); + assert.deepEqual(result, { ok: false, reason: 'not_found' }); + }); +}); diff --git a/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts b/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts index f949323ae03..71dbf5b40f5 100644 --- a/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts @@ -157,14 +157,20 @@ test('browser IPC isolates owned renderer documents and their native parents', a await controller?.dispose(); }, }; + const workspaceRenderers = new Set([main]); + let sessionOwner: unknown = main; const mainWindowController = { + isWorkspaceRenderer: (contents: unknown) => workspaceRenderers.has(contents), getBrowserViews: (resolve: typeof parentResolver) => { parentResolver = resolve; return manager; }, isMainRenderer: (contents: Electron.WebContents) => contents === main as unknown as Electron.WebContents, }; + const parkingParent = { getVisible: () => false } as Electron.View; const browserIpc = registerBrowserIpc({ + parkingParent, + canUseSession: (contents, id) => !id.includes('main-session') || contents === sessionOwner, mainWindowController: mainWindowController as never, auxiliaryWindowRegistry: { rendererParent: (contents: Electron.WebContents) => @@ -360,10 +366,32 @@ test('browser IPC isolates owned renderer documents and their native parents', a // Electron rejects fromWebContents on that destroyed native object. emit('browser:active-session', main, scope, 'main-session', 'main-document', 5); emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 5); + const child = new FakeRenderer('child-frame'); + const childWindow = new FakeWindow(); + const childParent = { getVisible: () => true } as Electron.View; + childWindow.contentView = childParent; + windows.set(child, childWindow); + workspaceRenderers.add(child); + emit('browser:document-ready', child, 'child-document'); + sessionOwner = child; + browserIpc.transferSessions([JSON.stringify([scope.hostId, 'main-session'])], main as never, child as never); + emit('browser:active-session', child, scope, 'main-session', 'child-document', 1); + emit('browser:setViewport', child, scope, { sessionId: 'main-session', rect: mainRect }, 'child-document', 1); + emit('browser:active-session', main, scope, 'main-session', 'main-document', 6); + emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 6); + await invoke('browser:navigate', main, scope, 'main-session', 'https://late-source.example/'); + assert.equal(mainController.parent, childParent, 'late source selection cannot steal the destination page'); + assert.deepEqual(mainController.navigations, ['https://main.example/']); assert.doesNotThrow(() => main.destroy()); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(mainController.disposed, true, 'closing Main still releases its owned browser page'); - assert.equal(await browserViewHost().canDrive(mainKey, 'observe'), false); + assert.equal(mainController.parent, childParent, 'source destruction leaves the native page with its new owner'); + assert.equal(mainController.disposed, false); + assert.doesNotThrow(() => child.destroy()); + assert.equal(mainController.disposed, false, 'closing a workspace preserves the task browser page'); + assert.equal(mainController.parent, parkingParent, 'pages leave the destroyed window'); + assert.equal(await browserViewHost().canDrive(mainKey, 'observe'), true); + await browserIpc.retireTarget(scope); + assert.equal(mainController.disposed, true, 'retiring the Host still releases its browser resources'); assert.equal(mainWindow.listenerCount('close'), 0, 'renderer teardown removes window listeners'); } finally { hooks.deregister(); diff --git a/apps/desktop/src/main/__tests__/command-palette-retired.test.ts b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts index e77c9cd51cc..0a4c5c0ca25 100644 --- a/apps/desktop/src/main/__tests__/command-palette-retired.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts @@ -133,3 +133,19 @@ for (const locale of ['en', 'zh-CN'] as const) { (locale === 'zh-CN' ? '脱敏日志 · 仅写入剪贴板' : 'Redacted logs · clipboard only')); }); } + +test('a session window offers local task tools and main-window entry points without other workspaces', () => { + const commands = buildCommandList({ + locale: 'en', activeSessionId: 'A', themePref: 'auto', connections: [], defaultSlug: null, + allowGlobalNavigation: false, + onNewChat() {}, onOpenSideChat() {}, onOpenSettings() {}, onOpenSettingsSection() {}, + onOpenShortcuts() {}, onSetTheme() {}, onSelectModule() {}, onStartScheduledTask() {}, onOpenWorkspace() {}, + }); + const ids = commands.map((command) => command.id); + assert.ok(ids.includes('action:side-chat')); + assert.ok(ids.includes('action:new-chat')); + assert.ok(ids.includes('action:open-settings')); + assert.ok(!ids.some((id) => id.startsWith('nav:'))); + assert.ok(!ids.includes('action:new-scheduled-task')); + assert.ok(!ids.includes('diag:open-workspace')); +}); diff --git a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts index 2d954deb3f0..0d0c8333c82 100644 --- a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts @@ -41,11 +41,16 @@ import { createChatActions } from '../../renderer/features/conversation/testing. import { createRevisionActions, type TurnRevisionDraft } from '../../renderer/features/conversation/testing.js'; import { createDesktopComposerStagingServices } from '../../renderer/platform/desktop/create-composer-staging-services.js'; import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION, windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; +import { captureSessionWorkspace, clearSessionWorkspace, restoreSessionWorkspace } from '../../renderer/application/contracts/session-workspace-handoff.js'; import { ComposerStagingFixture } from './composer-staging-fixture.js'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import type { DesktopLocalMessageDraft } from '../../shared/session-local-contract.js'; +import { createFakeWorkbarServices, createSessionWorkbarPanelsState, openSessionWorkbarPanelTab, useSideConversationWorkspace, WorkbarServicesProvider, WorkbarSurface, type WorkbarServices } from '../../renderer/features/workbar/testing.js'; +import { captureSideConversationWorkspace, clearSideConversationWorkspace, restoreSideConversationWorkspace } from '../../renderer/application/contracts/session-side-conversation-handoff.js'; +import type { SideConversationWorkspaceState } from '../../shared/session-workspace-state.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; const saved = Object.fromEntries([ 'window', 'document', 'Element', 'HTMLBRElement', 'sessionStorage', 'HTMLElement', 'HTMLIFrameElement', 'Event', 'Node', 'CSS', @@ -213,6 +218,13 @@ test('staged follow-up holds recovered attachments through removal and preserves assert.deepEqual(released, [['local-recovery:follow-up']], 'the removed attachment is released after the refused send'); }); +test('idle workspace capture does not start Session reference resolution', async () => { + let referenceRequests = 0; + const view = await mount({ waitForSessionReference: async () => { referenceRequests++; return false; } }); + await view.composer.current!.waitForDraftReady(); + assert.equal(referenceRequests, 0); +}); + test('StagedComposer keeps the submitted recovery attachment while waiting for Session references', async () => { const released: string[][] = []; let finishReference!: (ready: boolean) => void; @@ -259,7 +271,10 @@ test('StagedComposer keeps the submitted recovery attachment while waiting for S assert.ok(form); form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); }); + let ready = false; + const flushed = view.composer.current!.waitForDraftReady().then(() => { ready = true; }); await act(() => view.removeAttachment()); + assert.equal(ready, false, 'workspace capture waits for a send still resolving Session references'); assert.deepEqual(released, [], 'early Composer ownership protects the approval before onSend starts'); assert.deepEqual(submissions, []); await act(async () => { finishReference(true); await Promise.resolve(); }); @@ -267,7 +282,9 @@ test('StagedComposer keeps the submitted recovery attachment while waiting for S sources: [{ type: 'approval', approvalId: 'local-recovery:waiting-reference', name: 'recovered.txt' }], replaces: 'paused-original', }], 'the later factory must submit the content captured by the original send'); - await act(async () => { finishAdmission(false); await sent; }); + assert.equal(ready, false, 'workspace capture also waits for admission and Composer cleanup'); + await act(async () => { finishAdmission(false); await sent; await flushed; }); + assert.equal(ready, true); assert.deepEqual(released, [['local-recovery:waiting-reference']]); }); @@ -868,3 +885,244 @@ test('Desktop backs the attachment port with the bridge reader; AppShell no long const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); assert.deepEqual(shell.split('\n').filter((line) => /\battachments\s*\.\s*readBytes\b|\bonReadAttachmentBytes\b/.test(line)), []); }); + + +test('window handoff carries inactive member drafts and staging while preserving an unrelated session', async () => { + const view = await mount(); + await act(() => { + view.composer.current!.appendDraft('draft-a', '@file.ts first', [{ kind: 'workspace_file', value: '@file.ts', label: 'file.ts', start: 0 }], 'replacement'); + view.commands.addQuote({ text: 'quoted A', turnId: 'turn-a' }); + view.stage({ attachments: [attachment], directoryReferences: [{ hostId: 'host-a', path: '/project-a' }] }); + }); + await view.render('draft-child'); + await act(() => { + view.composer.current!.setText('child draft'); + view.commands.addQuote({ text: 'quoted child' }); + view.restoreRecovery('local-recovery:child'); + }); + await view.render('unrelated'); + await act(() => { + view.composer.current!.setText('B stays here'); + view.commands.addQuote({ text: 'B quote' }); + }); + const input = { composer: view.composer.current, staging: view.commands, sessionIds: ['draft-a', 'draft-child'] }; + const saved = await captureSessionWorkspace(input); + assert.deepEqual(saved.drafts.map((draft) => draft.text), ['@file.ts first', 'child draft']); + assert.equal(saved.drafts[0]!.replacesMessageId, 'replacement'); + assert.deepEqual(saved.drafts[0]!.directoryReferences, [{ hostId: 'host-a', path: '/project-a' }]); + assert.equal(saved.drafts[0]!.attachments[0]!.type, 'retained'); + assert.equal(saved.drafts[1]!.attachments[0]!.type, 'approval'); + assert.ok(!JSON.stringify(saved).includes('previewUrl')); + await act(() => clearSessionWorkspace(input)); + assert.equal(view.composer.current!.getText(), 'B stays here'); + assert.deepEqual(view.commands.captureSubmission().quotesForSend(), [{ text: 'B quote' }]); + assert.equal(view.composer.current!.getDraft('draft-a'), ''); + assert.equal(view.commands.captureDrafts(['draft-a'])[0]!.attachments.length, 0); + // Main reissues these approvals; restore accepts only the resulting handles. + const restored = { ...saved, drafts: saved.drafts.map((draft) => ({ ...draft, attachments: draft.attachments.map((item) => item.type === 'approval' ? { ...item, approvalId: 'local-recovery:destination' } : item) })) }; + await act(() => restoreSessionWorkspace({ ...input, snapshot: restored })); + await view.render('draft-a'); + assert.equal(view.composer.current!.getText(), '@file.ts first'); + assert.deepEqual(view.composer.current!.captureDrafts(['draft-a'])[0]!.references, [{ kind: 'workspace_file', value: '@file.ts', label: 'file.ts', start: 0 }]); + assert.deepEqual(view.commands.captureSubmission().quotesForSend(), [{ text: 'quoted A', sourceTurnId: 'turn-a' }]); + assert.equal(view.commands.captureSubmission().submittableAttachments?.[0]?.source.type, 'retained'); + await view.render('draft-child'); + assert.equal(view.composer.current!.getText(), 'child draft'); + assert.deepEqual(view.commands.captureSubmission().submittableAttachments?.[0]?.source, { type: 'approval', approvalId: 'local-recovery:destination', name: 'recovered.txt' }); +}); + +test('restoring a retained workspace keeps shared recovery approvals sendable and releases only replaced context', async () => { + const released: string[] = []; + const sent: string[] = []; + const view = await mount({ + releaseRecoveryAttachments: async (ids) => { released.push(...ids); }, + onSend: async (_text, _metadata, staging) => { + for (const item of staging?.submittableAttachments ?? []) { + assert.equal(item.source.type, 'approval'); + if (item.source.type !== 'approval') continue; + assert.ok(!released.includes(item.source.approvalId), 'restored sends must not use an expired approval'); + sent.push(item.source.approvalId); + } + return false; + }, + }); + await act(() => { + view.restoreRecovery('local-recovery:retained-main-B'); + view.composer.current!.setText('B draft'); + view.commands.addQuote({ text: 'B quote' }); + view.stage({ directoryReferences: [{ hostId: 'host-a', path: '/B' }] }); + }); + const input = { composer: view.composer.current, staging: view.commands, sessionIds: ['draft-a'] }; + const snapshot = await captureSessionWorkspace(input); + await act(() => restoreSessionWorkspace({ ...input, snapshot })); + assert.equal(released.length, 0, 'rollback to the current snapshot retains the current approval'); + await act(() => { + view.restoreRecovery('local-recovery:replaced'); + view.commands.addQuote({ text: 'discarded quote' }); + view.stage({ directoryReferences: [{ hostId: 'host-a', path: '/discarded' }] }); + }); + await act(() => restoreSessionWorkspace({ ...input, snapshot })); + assert.deepEqual(released, ['local-recovery:replaced']); + const restored = view.commands.captureSubmission(); + assert.deepEqual(restored.quotesForSend(), [{ text: 'B quote' }]); + assert.deepEqual(restored.directoryOptions, { directoryReferences: [{ hostId: 'host-a', path: '/B' }] }); + await act(async () => { + view.container.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + await view.composer.current!.waitForDraftReady(); + }); + assert.deepEqual(sent, ['local-recovery:retained-main-B']); +}); + +test('workspace replacement moves recovery approvals between member drafts without releasing unrelated owners', async () => { + const released: string[] = []; + const view = await mount({ releaseRecoveryAttachments: async (ids) => { released.push(...ids); } }); + await act(() => view.restoreRecovery('local-recovery:moving')); + await view.render('draft-child'); + await act(() => view.restoreRecovery('local-recovery:obsolete-child')); + await view.render('unrelated'); + await act(() => view.restoreRecovery('local-recovery:unrelated')); + const input = { composer: view.composer.current, staging: view.commands, sessionIds: ['draft-a', 'draft-child'] }; + const captured = await captureSessionWorkspace(input); + const moving = captured.drafts[0]!.attachments; + const snapshot = { ...captured, drafts: captured.drafts.map((draft) => ({ ...draft, + attachments: draft.sessionId === 'draft-child' ? moving : [], + })) }; + await act(() => restoreSessionWorkspace({ ...input, snapshot })); + assert.deepEqual(released, ['local-recovery:obsolete-child']); + const drafts = view.commands.captureDrafts(['draft-a', 'draft-child', 'unrelated']); + assert.deepEqual(drafts.map((draft) => draft.attachments.map((item) => item.source.type === 'approval' ? item.source.approvalId : 'unexpected')), + [[], ['local-recovery:moving'], ['local-recovery:unrelated']]); + await act(() => clearSessionWorkspace(input)); + assert.deepEqual(released, ['local-recovery:obsolete-child', 'local-recovery:moving']); + assert.equal(view.commands.captureSubmission().submittableAttachments?.length, 1, 'unrelated draft retains its approval'); +}); + +async function mountSideWorkspace(options: { + send?: WorkbarServices['sideChat']['send']; + initialSessionId?: string; + companion?: boolean; +} = {}) { + const view = await mount(); + const source = { ...recoverySession('B'), llmConnectionId: 'connection' }; + const fork = { ...source, id: 'B-side' }; + const registry = new SessionLocalAttachmentRecovery(); + const owner = { senderId: 11, partition: 'local', scope: { hostId: 'host-a', targetEpoch: 'epoch-1' }, sessionId: options.companion ? fork.id : source.id }; + const approved = registry.issue(owner, [{ name: 'side-draft.txt', mimeType: 'text/plain', content: new Uint8Array([1, 2, 3]) }])[0]!; + const released: string[] = []; + const dismissed: string[] = []; + const base = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + attachments: { ...base.attachments, releaseRecoveryAttachments: async (ids) => { released.push(...ids); registry.release(owner.senderId, ids); } }, + sideChat: { ...base.sideChat, + listSessions: async () => [source, fork], listTurns: async () => [{ turnId: 'source-turn', status: 'completed' }], + branchFromTurn: async () => ({ ok: true, session: fork }), + cleanupSessionCopy: async (id) => { dismissed.push(id); }, + abandonSessionCopy: async (id) => { dismissed.push(id); }, + ...(options.send ? { send: options.send } : {}), + }, + }); + const emptyPanels = createSessionWorkbarPanelsState(); + const panels = openSessionWorkbarPanelTab(emptyPanels, 'right', { id: 'side-chat:panel-B', kind: 'side-chat' }); + const record: SideConversationWorkspaceState = { + panel: { id: 'panel-B', sourceSessionId: source.id, quotes: [] }, hasContent: false, active: false, + ...(options.companion ? { companion: { sessionId: fork.id, ownTurnIds: [], queueOwnedMessageIds: [] } } : {}), + draft: { sessionId: owner.sessionId, text: 'B side draft', references: [], quotes: [], directoryReferences: [], + attachments: [{ type: 'approval', ...approved }] }, + }; + function Frame({ sessionId }: { sessionId?: string }) { + const workspace = useSideConversationWorkspace(); + return createElement(WorkbarSurface, { + sessionId, sourceSession: sessionId ? { ...source, id: sessionId } : undefined, + modelChoices: [{ connectionId: 'connection', connectionSlug: 'test', providerType: 'openai', providerLabel: 'Test', model: 'test', label: 'Test', isDefault: true, thinkingLevels: [] }], + panelsState: sessionId === source.id ? panels : emptyPanels, + quotes: workspace.panels, hidden: false, rightCollapsed: false, bottomOpen: false, + onDismissPanel() {}, onToggleRightPanel() {}, onActivateTab() {}, onCloseTab() {}, onOpenLauncher() {}, onRequestOpenTab() {}, + onContentStateChange: workspace.setContent, onActivityStateChange: workspace.setActive, confirmBypass: async () => true, + }); + } + const render = async (sessionId?: string) => { + await act(async () => root!.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(ToastProvider, { children: createElement(WorkbarServicesProvider, { services, children: createElement(Frame, { sessionId }) }) }), + }))); + }; + await render(options.initialSessionId ?? source.id); + await act(() => restoreSideConversationWorkspace([record])); + const capture = async () => { + let captured!: SideConversationWorkspaceState[]; + await act(async () => { captured = await captureSideConversationWorkspace(['B']); }); + return captured; + }; + return { ...view, render, capture, registry, owner, approvalId: approved.approvalId, released, dismissed, record }; +} + +test('Workbar layout changes and rollback keep another task side draft mounted; group clear releases it', async () => { + const view = await mountSideWorkspace({ companion: true }); + const form = view.container.querySelector('form'); + assert.ok(form); + for (const next of ['A', undefined, 'B']) { + await view.render(next); + // Failed transfer rollback restores the retained snapshot while B is still owned here. + await act(() => restoreSideConversationWorkspace([view.record])); + assert.equal(view.container.querySelector('form'), form, 'the existing side composer stays mounted'); + const captured = await view.capture(); + assert.equal(captured[0]!.draft.text, 'B side draft'); + assert.equal(captured[0]!.draft.sessionId, 'B-side'); + assert.ok(view.registry.ownerForPreview(view.owner.senderId, view.approvalId)); + assert.equal(view.released.length, 0); + assert.equal(view.dismissed.length, 0); + } + await act(() => clearSideConversationWorkspace(['B'])); + assert.equal(view.container.querySelector('form'), null, 'a transferred group no longer leaves a source panel behind'); + assert.deepEqual(view.released, [view.approvalId]); + assert.equal(view.registry.ownerForPreview(view.owner.senderId, view.approvalId), undefined); + assert.equal(view.dismissed.length, 0, 'handoff preserves the backend fork'); +}); + +for (const companion of [false, true]) { + test(`restored inactive member side draft captures its owner before the member is visited (fork=${companion})`, async () => { + const view = await mountSideWorkspace({ initialSessionId: 'A', companion }); + const captured = await view.capture(); + assert.equal(captured[0]!.draft.sessionId, companion ? 'B-side' : 'B'); + assert.equal(captured[0]!.draft.text, 'B side draft'); + assert.deepEqual(captured[0]!.draft.attachments, view.record.draft.attachments); + await act(() => clearSideConversationWorkspace(['B'])); + assert.deepEqual(view.released, [view.approvalId]); + }); +} + +test('side composer holds recovery attachments during pending admission and releases them after unmount settles', async () => { + let complete!: (value: Awaited>) => void; + const pending = new Promise>>((resolve) => { complete = resolve; }); + let sending = false; + const view = await mountSideWorkspace({ send: async () => { sending = true; return pending; } }); + await act(() => view.container.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }))); + for (let attempt = 0; !sending && attempt < 20; attempt++) await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + assert.equal(sending, true); + await act(() => clearSideConversationWorkspace(['B'])); + assert.equal(view.released.length, 0, 'unmount cannot revoke an in-flight attachment before admission returns'); + assert.ok(view.registry.ownerForPreview(view.owner.senderId, view.approvalId)); + await act(async () => { complete({ ok: false, reason: 'rejected' }); await pending; }); + assert.deepEqual(view.released, [view.approvalId]); + assert.equal(view.registry.ownerForPreview(view.owner.senderId, view.approvalId), undefined); +}); + +for (const action of ['remove', 'send'] as const) { + test(`side composer releases its recovered attachment after explicit ${action}`, async () => { + const view = await mountSideWorkspace({ send: async () => ({ ok: true, turnId: 'accepted-turn' }) }); + await act(async () => { + if (action === 'remove') { + const remove = view.container.querySelector('.maka-composer-attachment-token button'); + assert.ok(remove); + remove.dispatchEvent(new window.Event('click', { bubbles: true })); + } else { + view.container.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + } + }); + for (let attempt = 0; !view.released.length && attempt < 20; attempt++) await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + assert.deepEqual(view.released, [view.approvalId]); + assert.equal(view.registry.ownerForPreview(view.owner.senderId, view.approvalId), undefined); + const captured = await view.capture(); + assert.equal(captured[0]!.draft.attachments.length, 0); + await act(() => clearSideConversationWorkspace(['B'])); + }); +} diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index 3a2e8402726..ccc413240cc 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -997,6 +997,7 @@ test('editing a queued message retracts it and restores its content under the ow }, }), children: createElement(Probe) }) })}))); surface.composer.current = { + waitForDraftReady: async () => {}, captureDrafts: () => [], restoreDrafts() {}, setText() {}, appendText() {}, getText: () => '', clearDraft() {}, setDraft: (key, text) => { restoredDrafts.push([key, text]); }, getDraft: () => '', diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index be14c0b6ef1..4c6371c48b5 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -49,6 +49,9 @@ import { type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; +import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; +import { resolveAttachmentRefs, resolveIngestItems } from '../attachment-ingest.js'; const originalGlobals = { document: globalThis.document, @@ -145,6 +148,7 @@ function installDom() { async function renderProbe( sideChat: Partial, options: { + restoredWorkspace?: Parameters[0]['restoredWorkspace']; ownership?: boolean; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; @@ -183,6 +187,7 @@ async function renderProbe( ? createElement(QuoteCompanionOwnershipProbe, { onSend: options.onSend ?? (() => undefined), onProjection: options.onProjection, + restoredWorkspace: options.restoredWorkspace, onQueue: options.onQueue, onSteer: options.onSteer, onStop: options.onStop, @@ -3570,6 +3575,7 @@ function QuoteCompanionProbe(props: { } function QuoteCompanionOwnershipProbe(props: { + restoredWorkspace?: Parameters[0]['restoredWorkspace']; onSend: (send: (text: string) => Promise) => void; onProjection?: (companion: ReturnType) => void; onQueue?: (queue: (text: string) => Promise) => void; @@ -3587,6 +3593,8 @@ function QuoteCompanionOwnershipProbe(props: { const sourceSession = props.sourceSession ?? SOURCE_SESSION; const companion = useQuoteCompanion({ panelId: 'ownership-panel', + restoredWorkspace: props.restoredWorkspace, + restoredHasContent: Boolean(props.restoredWorkspace), active: props.active ?? true, pendingQuotes: props.pendingQuotes ?? [], sourceSession, @@ -3864,3 +3872,89 @@ test('an unknown steer outcome that later retracts keeps the staged attachments' }); assert.deepEqual(consumed, []); }); + + +test('a transferred side conversation observes the existing fork and detaches without deleting it', async () => { + const subscriptions: string[] = []; + const cleanups: string[] = []; + let projection!: ReturnType; + let forks = 0; + const restoredWorkspace = { sessionId: 'existing-fork', ownTurnIds: ['side-turn'], queueOwnedMessageIds: ['queued'] }; + const view = await renderProbe({ + listSessions: async () => [session('existing-fork')], + branchFromTurn: async () => { forks += 1; return { ok: true as const, session: session('unexpected') }; }, + subscribeEvents: (id, _handler, onSeeded) => { subscriptions.push(id); onSeeded?.(); return () => {}; }, + cleanupSessionCopy: async (id) => { cleanups.push(id); }, + }, { ownership: true, restoredWorkspace, onProjection: (value) => { projection = value; }, + ready: (container) => container.firstElementChild?.getAttribute('data-companion-id') === 'existing-fork' }); + assert.equal(forks, 0); + assert.deepEqual(subscriptions, ['existing-fork'], 'one observer is attached to the existing fork'); + assert.deepEqual(await projection.captureWorkspace(), restoredWorkspace); + projection.detachWorkspace(); + await act(async () => { view.root.unmount(); await Promise.resolve(); }); + mountedRoot = undefined; + assert.deepEqual(cleanups, [], 'closing the source window cannot delete the transferred fork'); +}); + +test('a cancelled window transfer restores normal side-panel dismissal', async () => { + const cleanups: string[] = []; + let projection!: ReturnType; + const view = await renderProbe({ + listSessions: async () => [session('existing-fork')], + subscribeEvents: (_id, _handler, onSeeded) => { onSeeded?.(); return () => {}; }, + cleanupSessionCopy: async (id) => { cleanups.push(id); }, + }, { ownership: true, restoredWorkspace: { sessionId: 'existing-fork', ownTurnIds: [], queueOwnedMessageIds: [] }, + onProjection: (value) => { projection = value; }, + ready: (container) => container.firstElementChild?.getAttribute('data-companion-id') === 'existing-fork' }); + projection.detachWorkspace(); + projection.resumeWorkspace(); + await act(async () => { view.root.unmount(); await Promise.resolve(); }); + mountedRoot = undefined; + assert.deepEqual(cleanups, ['existing-fork']); +}); + +for (const existing of [false, true]) { + test(`a recovered side attachment sends through ${existing ? 'its existing' : 'a newly created'} fork with exact Session authority`, async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const owner = { senderId: 11, partition: 'authority', scope: { hostId: 'host', targetEpoch: 'epoch' }, + sessionId: existing ? 'side-conversation' : SOURCE_SESSION.id }; + const [approval] = recovery.issue(owner, [{ name: 'question.txt', mimeType: 'text/plain', content: Buffer.from('saved side bytes') }]); + const recovered = approvalAttachment(approval.approvalId, approval.name); + let send!: SendFn; + let branches = 0; + const ingested: string[] = []; + await renderProbe({ + listSessions: async () => [session('side-conversation')], + branchFromTurn: async (sourceId, input) => { + branches++; + assert.equal(sourceId, SOURCE_SESSION.id); + assert.deepEqual(input.recoveryAttachmentIds, [approval.approvalId]); + recovery.transfer(owner, 'side-conversation', input.recoveryAttachmentIds); + return { ok: true, session: session('side-conversation') }; + }, + subscribeEvents: (_id, _handler, seeded) => { seeded?.(); return () => {}; }, + send: async (sessionId, command) => { + assert.equal(sessionId, 'side-conversation'); + assert.throws(() => recovery.prepare({ ...owner, sessionId: 'another-session' }, command.attachmentItems)); + const prepared = recovery.prepare({ ...owner, sessionId }, command.attachmentItems); + try { + prepared.validate(); + const files = await resolveIngestItems({ senderId: owner.senderId, items: prepared.items, + approvals: createAttachmentApprovalRegistry(), stat: async () => { throw new Error('Recovery cannot open a client path'); } }); + await resolveAttachmentRefs({ files, snapshot: async ({ name, mimeType, content }) => { + ingested.push(Buffer.from(content).toString()); + return { name, mimeType, content }; + } }); + prepared.validate(); + return { ok: true, turnId: 'side-turn' }; + } finally { prepared.dispose(); } + }, + }, { ownership: true, onSend: (next) => { send = next; }, + ...(existing ? { restoredWorkspace: { sessionId: 'side-conversation', ownTurnIds: [], queueOwnedMessageIds: [] }, + ready: (container: Element) => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation' } : {}), + }); + await act(async () => { assert.equal(await send('follow up', [recovered]), true); }); + assert.equal(branches, existing ? 0 : 1); + assert.deepEqual(ingested, ['saved side bytes']); + }); +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index 79de0255b90..44c7041f5e5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -1220,16 +1220,18 @@ function shellRunUpdate(overrides: Partial = {}): ShellRunUpdate type IpcHandler = Parameters['handle']>[1]; function ipcHarness() { + let sender = { id: 1 }; const handlers = new Map(); return { handle(channel: string, handler: IpcHandler) { assert.equal(handlers.has(channel), false, `duplicate handler: ${channel}`); handlers.set(channel, handler); }, + setSender(id: number) { sender = { id }; }, async invoke(channel: string, ...args: unknown[]): Promise { const handler = handlers.get(channel); assert.ok(handler, `missing handler: ${channel}`); - return handler({} as never, ...args); + return handler({ sender } as never, ...args); }, }; } @@ -1406,3 +1408,43 @@ test('plan control channels return the Host error code across the IPC boundary', assert.deepEqual(changed, [], `${scenario.channel} must not report a mode change`); } }); + + +test('moving a terminal transfers its observer and rejects late source input and detach without stopping the PTY', async () => { + const ipc = ipcHarness(); + const events: string[] = []; + let owner = 1; + const pty = { sessionId: 'session-1', ref: 'pty-1', sequence: 5, buffer: 'still running', size: { cols: 80, rows: 24 } }; + const handle = registerDomainsIpc({ + client: domainClient({ + acquireRuntimeResourceController: async (input) => { events.push('acquire:' + input.controllerId); return { controllerId: input.controllerId, nextSequence: 8, pty }; }, + controlRuntimeResource: async (input) => { events.push('input:' + input.sequence); return { controllerId: input.controllerId, sequence: input.sequence }; }, + releaseRuntimeResourceController: async (input) => { events.push('release'); return { controllerId: input.controllerId, released: true }; }, + stopRuntimeResource: async () => { events.push('stop'); return {}; }, + }), + canUseSession: (target) => target.id === owner, + sessionObserver: { + observe: async (_id, _observer, target) => { events.push('observe:' + target.id); }, + unobserve: async () => { events.push('unobserve'); }, + }, + emitModeChanged() {}, newId: () => 'transfer', + }, ipc); + const identity = { sessionId: 'session-1', ref: pty.ref }; + await ipc.invoke('shell-runs:attach', identity); + owner = 2; + ipc.setSender(2); + assert.deepEqual(await ipc.invoke('shell-runs:attach', identity), pty); + ipc.setSender(1); + await ipc.invoke('shell-runs:detach', identity); + await assert.rejects(ipc.invoke('shell-runs:write', { ...identity, input: 'late' }), /another window/); + await assert.rejects(ipc.invoke('shell-runs:attach', identity), /another window/); + ipc.setSender(2); + // A real renderer retains its WebContents identity across invokes. + await ipc.invoke('shell-runs:attach', identity); + await ipc.invoke('shell-runs:write', { ...identity, size: { cols: 100, rows: 30 } }); + assert.equal(events.includes('release'), false); + assert.equal(events.includes('stop'), false); + assert.equal(events.at(-1), 'input:8'); + assert.ok(events.indexOf('unobserve') < events.indexOf('observe:2')); + await handle.close(); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index b520f5ce05b..5df89392003 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -37,6 +37,7 @@ import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, } from '@maka/runtime-host/client'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; import { createAttachmentApprovalRegistry } from "../attachment-approval.js"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; import { @@ -2558,3 +2559,39 @@ test('prompt suggestion IPC forwards only a validated Session and does not recon assert.deepEqual(calls, ['session-1']); assert.equal(ipc.reconnectableChannels.has('sessions:generatePromptSuggestion'), false); }); + + +for (const channel of ['sessions:send', 'sessions:submitMessage'] as const) { + test(`${channel} admits recovered side-chat attachments only for their assigned session`, async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const owner = { senderId: 9, partition: 'partition', scope: { hostId: 'host', targetEpoch: 'epoch' }, sessionId: 'source' }; + const [approval] = recovery.issue(owner, [{ name: 'recovered.txt', mimeType: 'text/plain', content: Buffer.from('hello') }]); + const uploads: string[] = []; + const ipc = ipcHarness(); + registerExecutionIpc({ + recoverAttachmentItems: (senderId, sessionId, items) => recovery.prepare({ ...owner, senderId, sessionId }, items), + transferRecoveryAttachments: (senderId, sourceSessionId, targetSessionId, ids) => recovery.transfer({ ...owner, senderId, sessionId: sourceSessionId }, targetSessionId, ids), + client: executionClient({ + getSession: async () => ({ ...session('/workspace', 'fork'), labels: [SIDE_CONVERSATION_SESSION_LABEL] }), + copySession: async () => session('/workspace', 'fork'), + ingestAttachment: async ({ sessionId, name, mimeType, content }) => { + uploads.push(Buffer.from(content).toString()); + return { kind: 'other', name, mimeType, bytes: content.byteLength, ref: { kind: 'session_file', sessionId, relativePath: 'attachment-1' } }; + }, + submitMessage: async () => ({ disposition: 'turn_started', turnId: 'turn-1', skillInvocation: { loaded: [], failed: [], receipts: [] } }), + }), + }, ipc); + const input = { type: 'send', text: 'read recovered attachment', messageId: 'message-1', attachmentItems: [{ type: 'approval', approvalId: approval!.approvalId, name: approval!.name }] }; + const send = () => channel === 'sessions:send' + ? ipc.invoke(channel, 'fork', input) + : ipc.invoke(channel, 'fork', 'next_turn', input); + assert.deepEqual(await send(), { ok: false, reason: 'attachment_blocked', code: 'source_expired' }); + await ipc.invoke('sessions:branchFromTurn', 'source', { copyId: 'fork', sideConversation: true, recoveryAttachmentIds: [approval!.approvalId] }); + assert.equal((await send() as { ok: boolean }).ok, true); + assert.deepEqual(uploads, ['hello']); + // A failed remote admission can retain the original draft; renderer cleanup + // owns successful-send disposal instead of prematurely consuming its token. + assert.equal(recovery.snapshot({ ...owner, sessionId: 'fork' }, approval!.approvalId).content.byteLength, 5); + await assert.rejects(ipc.invoke('sessions:branchFromTurn', 'source', { copyId: 'ordinary', recoveryAttachmentIds: [approval!.approvalId] }), /Invalid side conversation recovery attachments/); + }); +} diff --git a/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts b/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts index e83c7b5ee38..b638a7b5e6b 100644 --- a/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts +++ b/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts @@ -130,6 +130,49 @@ test('two prepared submissions cannot consume the same recovery approval twice', concurrent.dispose(); }); +test('closing a sender releases its unleased drafts but preserves another window and acquired send leases', () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [draft, sending, cancelled] = recovery.issue(owner, [attachment(), attachment(), attachment()]); + const other = { ...owner, senderId: owner.senderId + 1 }; + const [otherDraft] = recovery.issue(other, [attachment()]); + const send = recovery.prepare(owner, [sending]); + const cancel = recovery.prepare(owner, [cancelled]); + recovery.releaseSender(owner.senderId); + assert.throws(() => recovery.prepare(owner, [draft]), blocked('source_expired')); + assert.throws(() => recovery.prepare(owner, [sending]), blocked('source_expired')); + assert.equal(send.commit(() => 'admitted'), 'admitted'); + send.dispose(); + cancel.dispose(); + assert.throws(() => recovery.prepare(owner, [cancelled]), blocked('source_expired')); + assert.equal(recovery.prepare(other, [otherDraft]).commit(() => 'other window'), 'other window'); +}); + +test('a side draft transfers only its exact source authority to the newly created fork, without duplicating bytes or extending expiry', () => { + let now = 0; + const recovery = new SessionLocalAttachmentRecovery(() => now); + const [approval] = recovery.issue(owner, [attachment()]); + const fork = { ...owner, sessionId: 'side-fork' }; + for (const foreign of [ + { ...owner, senderId: 999 }, { ...owner, sessionId: 'unrelated' }, + { ...owner, partition: 'other' }, { ...owner, scope: { ...owner.scope, targetEpoch: 'other' } }, + ]) assert.throws(() => recovery.transfer(foreign, fork.sessionId, [approval.approvalId]), blocked('source_expired')); + const pendingSend = recovery.prepare(owner, [approval]); + assert.throws(() => recovery.transfer(owner, fork.sessionId, [approval.approvalId]), blocked('source_expired')); + pendingSend.dispose(); + assert.throws(() => recovery.transfer(owner, fork.sessionId, [approval.approvalId, 'local-recovery:forged']), blocked('source_expired')); + assert.equal(recovery.snapshot(owner, approval.approvalId).content.byteLength, 4, 'a partially invalid batch never moves the first attachment'); + now = ATTACHMENT_APPROVAL_TTL_MS - 1; + recovery.transfer(owner, fork.sessionId, [approval.approvalId]); + recovery.transfer(owner, fork.sessionId, [approval.approvalId]); + assert.throws(() => recovery.prepare(owner, [approval]), blocked('source_expired')); + assert.deepEqual(recovery.snapshot(fork, approval.approvalId).content, attachment().content); + const send = recovery.prepare(fork, [approval]); + send.validate(); + now += 2; + assert.throws(() => send.validate(), blocked('source_expired')); + send.dispose(); +}); + test('a synchronous admission failure preserves every recovery approval for a retry', () => { const recovery = new SessionLocalAttachmentRecovery(); const approvals = recovery.issue(owner, [attachment(), attachment()]); diff --git a/apps/desktop/src/main/__tests__/session-window-coordinator.test.ts b/apps/desktop/src/main/__tests__/session-window-coordinator.test.ts new file mode 100644 index 00000000000..a4091740354 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-coordinator.test.ts @@ -0,0 +1,351 @@ +/* + * 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 { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { createSessionWindowCoordinator, registerSessionWindowIpc } from '../session-window-coordinator.js'; +import { createWorkspaceWindowRegistry } from '../workspace-window-registry.js'; +import type { SessionWindowCommand, SessionWindowContext, SessionWindowGroup } from '../../shared/session-window-contract.js'; +import type { SessionWorkspaceSnapshot } from '../../shared/session-workspace-state.js'; + +const snapshot = (): SessionWorkspaceSnapshot => ({ capturedAt: 1, drafts: [], sideConversations: [] }); +const group: SessionWindowGroup = { hostId: 'local', groupId: 'A', sessionIds: ['A', 'A-child', 'A-version'] }; + +function harness() { + const registry = createWorkspaceWindowRegistry(); + const commands: Array<{ windowId: string; command: SessionWindowCommand }> = []; + const errors: unknown[] = []; + const migrations: string[] = []; + const saves: string[] = []; + const loads: string[] = []; + const released: string[] = []; + const drafts = new Map(); + let restoreGate: { started(): void; wait: Promise } | undefined; + const toSnapshot = (entries: readonly [string, string][]): SessionWorkspaceSnapshot => ({ + ...snapshot(), drafts: entries.map(([sessionId, text]) => ({ sessionId, text, references: [], attachments: [], directoryReferences: [], quotes: [] })), + }); + let failRestore: 'main' | 'session' | undefined; + let failSave = false; + let failRelease = false; + let nextId = 0; + let coordinator: ReturnType; + class FakeWindow extends EventEmitter { + readonly id = String(++nextId); + private readonly contents = Object.assign(new EventEmitter(), { + id: Number(this.id), + isDestroyed: () => this.destroyed, + send: (channel: string, command: SessionWindowCommand) => { + if (channel !== 'session-window:command') return; + commands.push({ windowId: this.id, command }); + queueMicrotask(async () => { + if (restoreGate && command.kind === 'restore' && this.context.kind === 'main') { + const gate = restoreGate; restoreGate = undefined; gate.started(); await gate.wait; + } + const shouldFail = command.kind === 'restore' && this.context.kind === failRestore; + if (shouldFail) failRestore = undefined; + const captured = command.kind === 'prepare-transfer' + ? toSnapshot([...this.drafts].filter(([id]) => command.group.sessionIds.includes(id))) + : toSnapshot([...this.drafts]); + const result = { snapshot: captured, activeSessionId: this.active }; + if (!shouldFail) { + if (command.kind === 'clear') { + for (const id of command.group.sessionIds) this.drafts.delete(id); + if (command.group.sessionIds.includes(this.active ?? '')) this.active = undefined; + } else if (command.kind === 'restore') { + for (const draft of command.snapshot.drafts) this.drafts.set(draft.sessionId, draft.text); + this.active = command.navigation.sessionId; + } else if (command.kind === 'navigate') this.active = command.navigation.sessionId; + } + coordinator.reply(this.contents as never, shouldFail + ? { requestId: command.requestId, ok: false, error: 'restore failed' } + : { requestId: command.requestId, ok: true, result }); + }); + }, + }); + get webContents() { + if (this.destroyed) throw new Error('Object has been destroyed'); + return this.contents; + } + destroyed = false; + focused = false; + revealed = false; + active: string | undefined = 'A'; + readonly drafts = new Map(); + context: SessionWindowContext; + constructor(context: Omit, 'windowId'> | { kind: 'main' }) { + super(); + this.context = { ...context, windowId: this.id }; + } + isDestroyed() { return this.destroyed; } + isFocused() { return this.focused; } + destroy() { this.destroyed = true; this.emit('closed'); } + close() { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + this.emit('close', event); + if (!event.defaultPrevented) this.destroy(); + } + } + const windows: FakeWindow[] = []; + function add(context: Omit, 'windowId'> | { kind: 'main' }) { + const window = new FakeWindow(context); + const controller = { + browserWindow: () => window, + reveal: () => { window.revealed = true; }, + focus: () => { window.focused = true; }, + }; + registry.register({ context: window.context, window: window as never, controller: controller as never }); + windows.push(window); + if (coordinator) coordinator.sessionWindowReady(window.webContents as never); + return { window, controller }; + } + const main = add({ kind: 'main' }).window; + coordinator = createSessionWindowCoordinator({ + windows: { registry, createSessionWindow: async (context: any) => add(context).controller } as never, + signal: new AbortController().signal, + ensureMainWindow: async () => { if (!registry.main()) add({ kind: 'main' }); }, + resolveGroup: async (id) => group.sessionIds.includes(id) ? group : { hostId: 'local', groupId: id, sessionIds: [id] }, + saveSnapshot: async (sender, state) => { if (failSave) throw new Error('disk full'); saves.push(String(sender.id)); for (const draft of state.drafts) drafts.set(draft.sessionId, draft.text); }, + loadSnapshot: async (sender, ids) => { loads.push(String(sender.id)); return toSnapshot([...drafts].filter(([id]) => ids.includes(id))); }, + releaseSnapshot: (sender) => { released.push(String(sender.id)); }, + transferResources: async ({ source, target }) => { migrations.push(`${source?.id}->${target.id}`); }, + releaseResources: () => { if (failRelease) throw new Error('resource release failed'); }, + isQuitting: () => false, + onError: (error) => { errors.push(error); }, + commandTimeoutMs: 30, + }); + coordinator.sessionWindowReady(main.webContents as never); + return { + coordinator, registry, main, windows, commands, errors, migrations, saves, loads, released, + pauseNextMainRestore() { + let started!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { started = resolve; }); + const wait = new Promise((resolve) => { release = resolve; }); + restoreGate = { started, wait }; + return { entered, release }; + }, + drafts, + failRestore: () => { failRestore = 'session'; }, + failMainRestore: () => { failRestore = 'main'; }, + failSave: () => { failSave = true; }, + failRelease: () => { failRelease = true; }, + }; +} + +const settle = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, 10)); }; + +test('duplicate opens and group members share one independent window', async () => { + const h = harness(); + await Promise.all([ + h.coordinator.openSessionWindow(h.main.webContents as never, 'A'), + h.coordinator.openSessionWindow(h.main.webContents as never, 'A-child'), + ]); + assert.equal(h.windows.length, 2); + const child = h.windows[1]!; + assert.equal(child.revealed, true); + assert.equal(h.coordinator.ownerForSession('A-version'), child.webContents); + assert.equal(h.coordinator.canUseSession(h.main.webContents as never, 'A'), false); + assert.equal(await h.coordinator.routeSession(h.main.webContents as never, { sessionId: 'A-version' }), 'routed'); + assert.equal(await h.coordinator.routeSession(child.webContents as never, { sessionId: 'A-child' }), 'local'); + assert.equal(h.commands.filter(({ command }) => command.kind === 'clear').length, 1); + assert.deepEqual(h.loads, ['2'], 'only the receiving renderer gets attachment recovery handles'); +}); + +test('unrelated child navigation targets the main window without replacing the child', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + assert.equal(await h.coordinator.routeSession(child.webContents as never, { sessionId: 'B' }), 'routed'); + assert.equal(h.commands.at(-1)?.windowId, h.main.id); + assert.equal(h.coordinator.ownerForSession('A'), child.webContents); + assert.equal(child.destroyed, false); +}); + +test('returning transfers current child member and closes only after restore', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + child.active = 'A-child'; + await h.coordinator.openInMainWindow(child.webContents as never); + assert.equal(child.destroyed, true); + assert.equal(h.errors.length, 0); + assert.equal(h.coordinator.ownerForSession('A'), undefined); + const restore = h.commands.at(-1)?.command; + assert.equal(restore?.kind, 'restore'); + if (restore?.kind === 'restore') assert.equal(restore.navigation.sessionId, 'A-child'); + assert.deepEqual(h.migrations, ['1->2', '2->1']); +}); + +test('failed destination restoration revokes it before restoring original owner', async () => { + const h = harness(); + h.failRestore(); + await assert.rejects(h.coordinator.openSessionWindow(h.main.webContents as never, 'A'), /restore failed/); + assert.equal(h.windows[1]?.destroyed, true); + assert.equal(h.coordinator.ownerForSession('A'), undefined); + assert.equal(h.main.destroyed, false); + assert.deepEqual(h.migrations, ['1->2', '2->1']); + assert.deepEqual(h.loads, ['2', '1'], 'issue once to destination and once to rollback source'); + assert.deepEqual(h.released, ['2'], 'discard failed destination handles before rollback'); + const tail = h.commands.slice(-2); + assert.deepEqual(tail.map(({ windowId, command }) => [windowId, command.kind]), [['2', 'clear'], ['1', 'restore']]); +}); + +test('normal close saves workspace before releasing ownership and does not close main', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.windows[1]!.close(); + assert.equal(h.windows[1]!.destroyed, false); + await settle(); + assert.equal(h.windows[1]!.destroyed, true); + assert.equal(h.errors.length, 0); + assert.equal(h.main.destroyed, false); + assert.equal(h.coordinator.ownerForSession('A'), undefined); + assert.equal(await h.coordinator.routeSession(h.main.webContents as never, { sessionId: 'A' }), 'local'); +}); + +test('failed final save keeps the child and its ownership with a visible error callback', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.failSave(); + h.windows[1]!.close(); + await settle(); + assert.equal(h.windows[1]!.destroyed, false); + assert.equal(h.coordinator.ownerForSession('A'), h.windows[1]!.webContents); + assert.equal(h.errors.length, 1); +}); + +test('closing main leaves the session window and return recreates main', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.main.close(); + await settle(); + assert.equal(h.main.destroyed, true); + assert.equal(h.windows[1]!.destroyed, false); + await h.coordinator.openInMainWindow(h.windows[1]!.webContents as never); + assert.equal(h.windows.length, 3); + assert.equal(h.registry.main()?.context.windowId, '3'); +}); + + +test('failed return restores child ownership and the main window previous task', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.main.active = 'B'; + h.failMainRestore(); + await assert.rejects(h.coordinator.openInMainWindow(h.windows[1]!.webContents as never), /restore failed/); + assert.equal(h.coordinator.ownerForSession('A'), h.windows[1]!.webContents); + assert.equal(h.windows[1]!.destroyed, false); + const mainRestores = h.commands.filter(({ windowId, command }) => windowId === '1' && command.kind === 'restore'); + const last = mainRestores.at(-1)?.command; + assert.equal(last?.kind, 'restore'); + if (last?.kind === 'restore') assert.equal(last.navigation.sessionId, 'B'); +}); + +test('reloaded child restores the last viewed group member without opening another window', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + await h.coordinator.routeSession(child.webContents as never, { sessionId: 'A-child' }); + child.webContents.emit('render-process-gone'); + h.coordinator.sessionWindowReady(child.webContents as never); + await settle(); + assert.equal(h.windows.length, 2); + const last = h.commands.at(-1)?.command; + assert.equal(last?.kind, 'restore'); + if (last?.kind === 'restore') assert.equal(last.navigation.sessionId, 'A-child'); +}); + +test('failed application flush resumes all windows instead of leaving the UI frozen', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.failSave(); + await assert.rejects(h.coordinator.flushAll(), /disk full/); + assert.deepEqual(h.commands.slice(-2).map(({ windowId, command }) => [windowId, command.kind]), [['1', 'resume'], ['2', 'resume']]); +}); + + +test('cancelled quit resumes every saved window and keeps their owners available', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + await h.coordinator.flushAll(); + await h.coordinator.resumeAll(); + assert.deepEqual(h.commands.slice(-2).map(({ windowId, command }) => [windowId, command.kind]), [['1', 'resume'], ['2', 'resume']]); + assert.equal(h.main.destroyed, false); + assert.equal(h.windows[1]!.destroyed, false); + assert.equal(h.coordinator.ownerForSession('A'), h.windows[1]!.webContents); +}); + + +test('synchronous cleanup failure after native destruction is reported without throwing from closed', async () => { + const h = harness(); + h.failRelease(); + assert.doesNotThrow(() => h.main.destroy()); + assert.throws(() => h.main.webContents, /Object has been destroyed/); + await settle(); + assert.equal(h.errors.length, 1); + assert.match(String(h.errors[0]), /resource release failed/); +}); + + +test('navigation IPC accepts omitted optional fields from a normal session row', async () => { + const h = harness(); + const handlers = new Map any>(); + registerSessionWindowIpc(h.coordinator, { handle: (channel: string, listener: (event: any, input: unknown) => any) => { handlers.set(channel, listener); } } as never); + const route = handlers.get('session-window:route')!; + const event = { sender: h.main.webContents, senderFrame: undefined }; + assert.equal(await route(event, { sessionId: 'A', turnId: undefined, sequence: undefined }), 'local'); + assert.equal(await route(event, { sessionId: 'A', turnId: 'turn-1', sequence: 0 }), 'local'); + assert.throws(() => route(event, { sessionId: 'A', turnId: 1 }), /Invalid turn identity/); + assert.throws(() => route(event, { sessionId: 'A', sequence: -1 }), /Invalid turn sequence/); +}); + + +test('concurrent groups returning to a recreated main preserve both drafts and serialize restoration', async () => { + const h = harness(); + h.main.drafts.set('A', 'draft A'); + h.main.drafts.set('B', 'draft B'); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.main.active = 'B'; + await h.coordinator.openSessionWindow(h.main.webContents as never, 'B'); + const a = h.windows[1]!; + const b = h.windows[2]!; + b.active = 'B'; + h.main.close(); + await settle(); + const gate = h.pauseNextMainRestore(); + const returning = Promise.all([ + h.coordinator.openInMainWindow(a.webContents as never), + h.coordinator.openInMainWindow(b.webContents as never), + ]); + await gate.entered; + assert.equal(h.windows.length, 4, 'one replacement main window is shared by both returns'); + const main = h.windows[3]!; + assert.equal(h.commands.filter(({ windowId, command }) => windowId === main.id && command.kind === 'flush').length, 1, + 'B must wait until A has restored before it captures the shared main workspace'); + gate.release(); + await returning; + assert.equal(a.destroyed, true); + assert.equal(b.destroyed, true); + assert.equal(main.drafts.get('A'), 'draft A'); + assert.equal(main.drafts.get('B'), 'draft B'); + assert.equal(h.drafts.get('A'), 'draft A'); + assert.equal(h.drafts.get('B'), 'draft B'); + assert.equal(h.errors.length, 0); +}); diff --git a/apps/desktop/src/main/__tests__/session-window-group.test.ts b/apps/desktop/src/main/__tests__/session-window-group.test.ts new file mode 100644 index 00000000000..72ae72ba303 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-group.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { SessionWindowGroups, type WindowGroupSession } from '../session-window-group.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; +const key = (id: string, hostId = 'host') => desktopSessionKey({ hostId, sessionId: id }); +const parent = (parentSessionId: string): WindowGroupSession['subagentParent'] => ({ kind: 'subagent', parentSessionId, lifecycle: 'foreground', spawnedBy: { parentRunId: 'run', parentTurnId: 'turn', toolCallId: 'call' } }); +const row = (id: string, extra: Partial = {}): WindowGroupSession => ({ id, labels: [], ...extra }); + +test('session windows group nested subagents, revisions and side conversations while keeping ordinary branches separate', () => { + const groups = new SessionWindowGroups(); + const sessions = [row('root'), row('root-v2', { revisionRootSessionId: 'root' }), + row('child', { subagentParent: parent('root-v2') }), + row('grandchild', { subagentParent: parent('child') }), + row('side', { parentSessionId: 'root', labels: ['mode:side_conversation'] }), + row('branch', { parentSessionId: 'root' })]; + const group = groups.resolve(key('grandchild'), sessions); + assert.equal(group.groupId, key('root')); + assert.deepEqual(group.sessionIds, ['child', 'grandchild', 'root', 'root-v2', 'side'].map((id) => key(id))); + assert.deepEqual(groups.resolve(key('branch'), sessions).sessionIds, [key('branch')]); +}); + +test('deleting a root preserves its open family and equal IDs on different Hosts remain independent', () => { + const groups = new SessionWindowGroups(); + const child = row('child', { subagentParent: parent('root') }); + const original = groups.resolve(key('child'), [row('root'), child]); + assert.deepEqual(groups.resolve(key('child'), [child]), original); + assert.equal(groups.resolve(key('child', 'other'), [row('child')]).groupId, key('child', 'other')); +}); + +test('missing parents retain a stable family and corrupt cyclic ancestry terminates deterministically', () => { + const groups = new SessionWindowGroups(); + const rows = [row('a', { subagentParent: parent('b') }), row('b', { subagentParent: parent('a') })]; + assert.deepEqual(groups.resolve(key('a'), rows), groups.resolve(key('b'), rows)); + assert.throws(() => groups.resolve(key('unknown'), []), /unavailable/); +}); diff --git a/apps/desktop/src/main/__tests__/session-window-pip.test.ts b/apps/desktop/src/main/__tests__/session-window-pip.test.ts new file mode 100644 index 00000000000..2d90a8febb6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-pip.test.ts @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createComputerUsePipController } from '../computer-use/pip-window.js'; +import type { ParentWindowLike, PipWindowLike } from '../computer-use/pip-electron.js'; + +test('PiP resolves the session owner before construction and reparents on transfer', () => { + const main = { isDestroyed: () => false }; + const child = { isDestroyed: () => false }; + let owner: ParentWindowLike = main; + let createdParent: unknown; + let movedParent: unknown; + let subscriptionCount = 0; + let unsubscribed = 0; + const resolvedIds: string[] = []; + const bounds = { x: 20, y: 20, width: 320, height: 200 }; + const window: PipWindowLike = { + send: () => undefined, + onReady: () => undefined, + onGone: () => undefined, + onLoadFailure: () => undefined, + onMessage: () => undefined, + setParentWindow: (parent) => { movedParent = parent; }, + setBounds: () => undefined, + getBounds: () => bounds, + showInactive: () => undefined, + setIgnoreMouseEvents: () => undefined, + isDestroyed: () => false, + destroy: () => undefined, + }; + const pip = createComputerUsePipController({ + resolveBounds: () => bounds, + resolveParentWindow: (id) => { resolvedIds.push(id); return owner; }, + resolveAnchorRect: () => ({ x: 0, y: 0, width: 1000, height: 800 }), + subscribeAnchorChanges: (id) => { + assert.equal(id, 'session-A'); + subscriptionCount += 1; + return () => { unsubscribed += 1; }; + }, + createWindow: (options) => { createdParent = options.parent; return window; }, + cursorPoint: () => ({ x: 0, y: 0 }), + workAreaFor: () => ({ x: 0, y: 0, width: 1000, height: 800 }), + scheduleFrame: () => () => undefined, + scheduleHoverCheck: () => () => undefined, + }); + pip.present({ sessionId: 'session-A', base64: 'image', mimeType: 'image/png', widthPx: 320, heightPx: 200 }); + assert.equal(createdParent, main); + owner = child; + pip.refreshOwner(); + assert.equal(movedParent, child); + assert.deepEqual(resolvedIds, ['session-A', 'session-A']); + assert.equal(subscriptionCount, 2); + assert.equal(unsubscribed, 1); + pip.destroyAll(); +}); diff --git a/apps/desktop/src/main/__tests__/session-window-renderer.test.ts b/apps/desktop/src/main/__tests__/session-window-renderer.test.ts new file mode 100644 index 00000000000..bebba223de2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-renderer.test.ts @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAppShellSessionUiStateController, createConversationWorkspace, stubConversationServices, replaceWorkspaceReadingAnchors } from '../../renderer/features/conversation/testing.js'; +import { bootstrapSessionWindow, createSessionWindowCommandHandler, createSessionWindowNavigator, restoreSessionWindowWorkspace } from '../../renderer/features/session-windows/testing.js'; +import { createDesktopSessionWindowServices } from '../../renderer/platform/desktop/create-session-window-services.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { clearNewTaskReloadIntent, markNewTaskReloadIntent } from '../../renderer/application/contracts/new-task-reload-intent.js'; +import type { SessionWindowNavigation } from '../../shared/session-window-contract.js'; +import type { SessionWorkspaceSnapshot } from '../../shared/session-workspace-state.js'; + +const group = { hostId: 'host', groupId: 'A', sessionIds: ['A', 'child'] }; +const snapshot: SessionWorkspaceSnapshot = { capturedAt: 1, drafts: [], sideConversations: [] }; +function harness(selected = 'A') { + let active: string | undefined = selected; + const events: string[] = []; + const handler = createSessionWindowCommandHandler({ + freeze: (value) => { events.push(value ? 'freeze' : 'resume'); }, + activeSessionId: () => active, + sessionIds: () => ['A', 'B'], + capture: async (ids) => { events.push(`capture:${ids.join(',')}`); return snapshot; }, + clear: (ids, selection) => { events.push(`clear:${ids.join(',')}`); if (selection) active = undefined; }, + restore: () => { events.push('restore'); }, + navigate: async ({ sessionId }) => { events.push(`navigate:${sessionId}`); active = sessionId; }, + save: async () => { events.push('save'); }, + detach: () => { events.push('detach'); }, + resume: () => { events.push('resume-resources'); }, + }); + return { handler, events, active: () => active }; +} + +test('clearing A releases its displayed child but preserves an unrelated B selection', async () => { + for (const selected of ['A', 'child', 'B']) { + const state = harness(selected); + await state.handler({ requestId: 'clear', kind: 'clear', group }); + assert.equal(state.active(), selected === 'B' ? 'B' : undefined); + } +}); + +test('handoff freezes before capturing and target restore preserves B before installing A', async () => { + const state = harness('B'); + const prepared = await state.handler({ requestId: 'prepare', kind: 'prepare-transfer', group }); + assert.deepEqual(state.events, ['freeze', 'capture:A,child']); + assert.equal(prepared?.activeSessionId, 'B'); + state.events.length = 0; + await state.handler({ requestId: 'restore', kind: 'restore', group, navigation: { sessionId: 'child' }, snapshot }); + assert.deepEqual(state.events, ['capture:A,B', 'save', 'restore', 'navigate:child', 'resume']); + assert.equal(state.active(), 'child'); +}); + +test('close flush confirms saving before detaching resources', async () => { + const state = harness(); + await state.handler({ requestId: 'flush', kind: 'flush' }); + assert.deepEqual(state.events, ['freeze', 'capture:A,B', 'save', 'detach']); +}); + +test('a failed close save resumes the source without detaching or discarding it', async () => { + const events: string[] = []; + const handler = createSessionWindowCommandHandler({ + freeze: (value) => { events.push(value ? 'freeze' : 'thaw'); }, activeSessionId: () => 'A', sessionIds: () => ['A'], + capture: async () => snapshot, clear: () => { throw new Error('unexpected clear'); }, restore: () => undefined, + navigate: async () => undefined, save: async () => { throw new Error('disk full'); }, + detach: () => { events.push('detach'); }, resume: () => { events.push('resume'); }, + }); + await assert.rejects(handler({ requestId: 'flush', kind: 'flush' }), /disk full/); + assert.deepEqual(events, ['freeze', 'resume', 'thaw']); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test('an owned task routes to its other window without restoring or mounting locally', async () => { + const calls: string[] = []; + const open = createSessionWindowNavigator({ route: async () => 'routed', restore: async () => { calls.push('restore'); }, blocked: () => false }); + await open.open({ sessionId: 'A' }, () => { calls.push('activate'); }); + assert.deepEqual(calls, []); +}); + +test('local navigation restores the saved workspace before selecting the task', async () => { + const calls: string[] = []; + const open = createSessionWindowNavigator({ route: async () => 'local', restore: async () => { calls.push('restore'); }, blocked: () => false }); + await open.open({ sessionId: 'A' }, () => { calls.push('activate'); }); + assert.deepEqual(calls, ['restore', 'activate']); +}); + +test('a superseded route and a route arriving during handoff cannot replace the current task', async () => { + const first = deferred<'local'>(); + let blocked = false; + const selected: string[] = []; + const open = createSessionWindowNavigator({ route: (nav: SessionWindowNavigation) => nav.sessionId === 'A' ? first.promise : Promise.resolve('local'), restore: async () => undefined, blocked: () => blocked }); + const old = open.open({ sessionId: 'A' }, () => { selected.push('A'); }); + await open.open({ sessionId: 'B' }, () => { selected.push('B'); }); + first.resolve('local'); + await old; + assert.deepEqual(selected, ['B']); + const restoring = deferred(); + const during = createSessionWindowNavigator({ route: async () => 'local', restore: () => restoring.promise, blocked: () => blocked }); + const pending = during.open({ sessionId: 'A' }, () => { selected.push('A'); }); + await Promise.resolve(); + blocked = true; + restoring.resolve(); + await pending; + assert.deepEqual(selected, ['B']); +}); + +test('returning a task that follows latest clears its old main-window anchor and preserves B', () => { + const ui = createAppShellSessionUiStateController(); + ui.setTranscriptReadingAnchor('A', { turnId: 'old-main-anchor' }); + ui.setTranscriptReadingAnchor('B', { turnId: 'B-anchor' }); + // The child scrolled to the tail; absent A is intentional, not a partial patch. + replaceWorkspaceReadingAnchors(['A', 'child'], { child: { turnId: 'child-anchor' } }, ui.setTranscriptReadingAnchor); + assert.equal(ui.transcriptReadingAnchorBySessionRef.current.A, undefined); + assert.deepEqual(ui.transcriptReadingAnchorBySessionRef.current.child, { turnId: 'child-anchor' }); + assert.deepEqual(ui.transcriptReadingAnchorBySessionRef.current.B, { turnId: 'B-anchor' }); + // Source clear and confirmed deletion use the same scoped replacement. + replaceWorkspaceReadingAnchors(['A', 'child'], {}, ui.setTranscriptReadingAnchor); + assert.equal(ui.transcriptReadingAnchorBySessionRef.current.child, undefined); + assert.deepEqual(ui.transcriptReadingAnchorBySessionRef.current.B, { turnId: 'B-anchor' }); +}); + +function readableSessions() { + return ['A', 'B', 'child'].map((id) => ({ + id, name: id, isFlagged: false, isArchived: false, labels: [], lastMessageAt: 1, + hasUnread: false, status: 'active' as const, backend: 'ai-sdk' as const, + revision: 1, runtimeHostId: 'local', profileId: 'local', profileName: 'Local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', + permissionMode: 'ask' as const, profileKind: 'local' as const, + })); +} + +test('releasing a pending task revokes late transcript publication while preserving the other displayed task', async () => { + const catalog = createSessionCatalogController(); + catalog.commitSessions(readableSessions()); + const workspace = createConversationWorkspace(catalog, stubConversationServices().observation, false); + workspace.commands.setActiveId('B'); + const b = [{ id: 'b-message', type: 'user' as const, text: 'B', ts: 1, turnId: 'b-turn' }]; + workspace.commitTranscript('B', b); + workspace.commands.setActiveId('A'); + const ownsPendingA = workspace.commands.captureSelection(); + assert.equal(workspace.activeIdRef.current, 'B'); + const handler = createSessionWindowCommandHandler({ + freeze() {}, activeSessionId: () => workspace.activeIdRef.current, sessionIds: () => ['B'], + capture: async () => snapshot, restore() {}, navigate: async () => {}, save: async () => {}, detach() {}, resume() {}, + clear: (ids) => { for (const id of ids) workspace.commands.clearOwnedSessionState(id); }, + }); + await handler({ requestId: 'release-a', kind: 'clear', group }); + assert.equal(ownsPendingA(), false); + assert.equal(catalog.getState().activeSessionId, 'B'); + assert.equal(workspace.commitTranscript('A', [{ ...b[0]!, id: 'late-a', text: 'A' }]), false); + assert.equal(workspace.activeIdRef.current, 'B'); + assert.deepEqual(workspace.messages.getSnapshot(), b); + + // Releasing the displayed member must also leave another requested task alive. + workspace.commands.setActiveId('A'); + workspace.commitTranscript('A', []); + workspace.commands.setActiveId('B'); + await handler({ requestId: 'release-displayed-a', kind: 'clear', group }); + assert.equal(catalog.getState().activeSessionId, 'B'); + assert.equal(workspace.commitTranscript('B', b), true); +}); + +test('a completed freeze/resume cycle invalidates both pending routes and pending workspace restores', async () => { + for (const phase of ['route', 'restore'] as const) { + const wait = deferred(); + const effects: string[] = []; + let blocked = false; + const navigator = createSessionWindowNavigator({ + blocked: () => blocked, + route: async () => { if (phase === 'route') await wait.promise; return 'local'; }, + restore: async (_id, isCurrent) => { + if (phase === 'restore') await wait.promise; + if (isCurrent()) effects.push('restored-A'); + }, + }); + const pending = navigator.open({ sessionId: 'A' }, () => { effects.push('selected-A'); }); + await Promise.resolve(); + blocked = true; + navigator.invalidate(); + blocked = false; + wait.resolve(); + await pending; + assert.equal(effects.length, 0, phase); + await navigator.open({ sessionId: 'B' }, () => { effects.push('selected-B'); }); + assert.equal(effects.at(-1), 'selected-B', 'new navigation still works after resume'); + } +}); + +test('a discarded workspace load releases main and side-chat recovery handles without populating draft caches', async () => { + const draft = { + sessionId: 'A', text: 'unsent', references: [], directoryReferences: [], quotes: [], + attachments: [{ type: 'approval' as const, approvalId: 'local-recovery:main', name: 'a.png', size: 1 }], + }; + const loaded: SessionWorkspaceSnapshot = { + capturedAt: 1, drafts: [draft], + sideConversations: [{ panel: { id: 'side', sourceSessionId: 'A', quotes: [] }, hasContent: true, active: true, + draft: { ...draft, sessionId: 'side', attachments: [ + ...draft.attachments, + { type: 'approval', approvalId: 'local-recovery:side', name: 'b.png', size: 1 }, + { type: 'upload', name: 'not-a-handle', mimeType: 'text/plain', base64: 'aGVsbG8=' }, + ] } }], + }; + const released: (readonly string[])[] = []; + const services = createDesktopSessionWindowServices({ + appWindow: {}, sessionLocal: { releaseRecoveryAttachments: async (ids: readonly string[]) => { released.push(ids); } }, + } as unknown as Parameters[0]); + const ready = deferred(); + const restored = new Map(); + const selected: string[] = []; + const navigator = createSessionWindowNavigator({ + blocked: () => false, route: async () => 'local', + restore: (_id, isCurrent) => restoreSessionWindowWorkspace({ + load: () => ready.promise, release: services.releaseSnapshot, isCurrent, + restore: (state) => { for (const member of state.drafts) restored.set(member.sessionId, member.text); }, + }), + }); + const pending = navigator.open({ sessionId: 'A' }, () => { selected.push('A'); }); + await Promise.resolve(); + navigator.invalidate(); + ready.resolve(loaded); + await pending; + assert.equal(restored.size, 0); + assert.equal(selected.length, 0); + assert.deepEqual(released, [['local-recovery:main', 'local-recovery:side']]); + + // A subsequent accepted load adopts the snapshot, retaining its handles. + await navigator.open({ sessionId: 'A' }, () => { selected.push('A'); }); + assert.equal(restored.get('A'), 'unsent'); + assert.deepEqual(selected, ['A']); + assert.equal(released.length, 1); + await services.releaseSnapshot(snapshot); + assert.equal(released.length, 1, 'empty snapshots do not send disposal IPC'); +}); + +test('window bootstrap preserves an explicit new-task reload intent before and during catalog loading', async () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + const entries = new Map(); + const storage = { getItem: (key: string) => entries.get(key) ?? null, setItem: (key: string, value: string) => { entries.set(key, value); }, removeItem: (key: string) => { entries.delete(key); } }; + Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: storage }); + try { + const selected: string[] = []; + const input = { kind: 'main' as const, refresh: async () => readableSessions(), revision: () => 0, open: (id: string) => { selected.push(id); } }; + markNewTaskReloadIntent(); + await bootstrapSessionWindow(input); + assert.deepEqual(selected, []); + clearNewTaskReloadIntent(); + const refresh = deferred>(); + const boot = bootstrapSessionWindow({ ...input, refresh: () => refresh.promise }); + markNewTaskReloadIntent(); + refresh.resolve(readableSessions()); + await boot; + assert.deepEqual(selected, []); + clearNewTaskReloadIntent(); + await bootstrapSessionWindow(input); + assert.deepEqual(selected, ['A'], 'cold main windows still select history'); + await bootstrapSessionWindow({ ...input, kind: 'session' }); + assert.deepEqual(selected, ['A'], 'session windows never choose recent history'); + } finally { + if (previous) Object.defineProperty(globalThis, 'sessionStorage', previous); + else Reflect.deleteProperty(globalThis, 'sessionStorage'); + } +}); diff --git a/apps/desktop/src/main/__tests__/session-workspace-state-store.test.ts b/apps/desktop/src/main/__tests__/session-workspace-state-store.test.ts new file mode 100644 index 00000000000..8773f9ff569 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-workspace-state-store.test.ts @@ -0,0 +1,276 @@ +/* + * 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 { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; +import { SessionWorkspaceStateStore } from '../session-workspace-state-store.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; +import type { SessionWorkspaceDraft, SessionWorkspaceSnapshot } from '../../shared/session-workspace-state.js'; + +const owner = { senderId: 11, partition: 'authority-a', scope: { hostId: 'host-a', targetEpoch: 'epoch-a' } }; +const key = (id: string) => desktopSessionKey({ hostId: owner.scope.hostId, sessionId: id }); +const emptyDraft = (id = 'a'): SessionWorkspaceDraft => ({ sessionId: key(id), text: '', references: [], quotes: [], attachments: [], directoryReferences: [] }); +const snapshot = (...drafts: SessionWorkspaceDraft[]): SessionWorkspaceSnapshot => ({ capturedAt: Date.now(), drafts, sideConversations: [] }); +function fixture(t: { after(fn: () => void): void }, now: () => number = Date.now) { + const dir = mkdtempSync(join(tmpdir(), 'maka-workspace-state-')); + const approvals = createAttachmentApprovalRegistry({ now }); + const recovery = new SessionLocalAttachmentRecovery(now); + const path = join(dir, 'workspace.sqlite'); + const store = new SessionWorkspaceStateStore(path, { approvals, recovery }); + t.after(() => { store.close(); rmSync(dir, { recursive: true, force: true }); }); + return { dir, path, store, approvals, recovery }; +} + +test('stores the complete group without replacing another session and survives store reopening', async (t) => { + const { path, store, approvals, recovery } = fixture(t); + const a: SessionWorkspaceDraft = { + ...emptyDraft(), text: 'draft @file.ts', + references: [{ kind: 'workspace_file', value: '@file.ts', label: 'file.ts', start: 6 }], + quotes: [{ text: 'an excerpt', sourceTurnId: 'turn-1' }], + directoryReferences: [{ hostId: 'host-a', path: '/project' }], + replacesMessageId: 'local-message', + revision: { sourceSessionId: key('a'), draftSessionId: key('a'), sourceTurnId: 'turn-2', copyId: 'copy', copyPhase: 'reserved', previousComposerText: 'previous', originalQuotes: [], originalAttachments: [] }, + }; + await store.save(owner, snapshot({ ...emptyDraft('unrelated'), text: 'keep me' })); + await store.save(owner, { ...snapshot(a, { ...emptyDraft('child'), text: 'child draft' }), readingAnchors: { [key('a')]: { turnId: 'turn-1' } } }); + const another = new SessionWorkspaceStateStore(path, { approvals, recovery }); + try { + const loaded = another.load({ ...owner, senderId: 22 }, [key('a'), key('child')]); + assert.deepEqual(loaded.drafts, [a, { ...emptyDraft('child'), text: 'child draft' }]); + assert.deepEqual(loaded.readingAnchors, { [key('a')]: { turnId: 'turn-1' } }); + assert.equal(another.load(owner, [key('unrelated')]).drafts[0]!.text, 'keep me'); + assert.equal(another.load({ ...owner, partition: 'different-authority' }, [key('a')]).drafts[0]!.text, ''); + assert.throws(() => another.load({ ...owner, scope: { ...owner.scope, hostId: 'other-host' } }, [key('a')]), /different Host/); + } finally { another.close(); } +}); + +test('picked and recovery attachments are saved by main, reissued for the destination, and retain source send leases', async (t) => { + const { dir, store, approvals, recovery } = fixture(t); + const file = join(dir, 'picked.txt'); + writeFileSync(file, 'picked bytes'); + const picked = approvals.issueApprovals(owner.senderId, [{ path: file, name: 'picked.txt', size: 12 }])[0]!; + const recoveryOwner = { ...owner, sessionId: 'a' }; + const recovered = recovery.issue(recoveryOwner, [{ name: 'recovered.txt', mimeType: 'text/plain', content: Buffer.from('recovery bytes') }])[0]!; + const sending = recovery.prepare(recoveryOwner, [recovered]); + const draft: SessionWorkspaceDraft = { ...emptyDraft(), attachments: [ + { type: 'approval', ...picked }, { type: 'approval', ...recovered }, + { type: 'upload', name: 'paste.txt', mimeType: 'text/plain', base64: Buffer.from('paste bytes').toString('base64') }, + ] }; + await store.save(owner, snapshot(draft)); + assert.ok(approvals.peekApproval(owner.senderId, picked.approvalId), 'periodic save does not consume a picker approval'); + const target = { ...owner, senderId: 22, scope: { ...owner.scope, targetEpoch: 'new-epoch' } }; + const loaded = store.load(target, [key('a')]); + const issued = loaded.drafts[0]!.attachments; + assert.equal(issued.length, 3); + assert.ok(issued.every((item) => item.type === 'approval' && item.approvalId.startsWith('local-recovery:'))); + assert.ok(!JSON.stringify(loaded).includes('base64')); + assert.ok(!JSON.stringify(loaded).includes(file)); + assert.ok(!JSON.stringify(loaded).includes(picked.approvalId)); + recovery.release(owner.senderId, [recovered.approvalId]); + assert.equal(sending.commit(() => 'source send admitted'), 'source send admitted'); + sending.dispose(); + approvals.clearSender(owner.senderId); + const restoredOwner = { ...target, sessionId: 'a' }; + const prepared = recovery.prepare(restoredOwner, issued); + assert.deepEqual(prepared.items.map((item) => Buffer.from((item as { base64: string }).base64, 'base64').toString()), ['picked bytes', 'recovery bytes', 'paste bytes']); + assert.throws(() => recovery.prepare({ ...restoredOwner, senderId: 11 }, issued), { code: 'source_expired' }); + prepared.dispose(); +}); + +test('forged attachments fail without replacing a previously saved draft', async (t) => { + const { store } = fixture(t); + await store.save(owner, snapshot({ ...emptyDraft(), text: 'saved' })); + await assert.rejects(store.save(owner, snapshot({ ...emptyDraft(), text: 'forged', attachments: [{ type: 'approval', approvalId: 'forged', name: 'secret', size: 3 }] }))); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, 'saved'); +}); + +test('releasing abandoned large restorations keeps repeated window transfers below the recovery byte budget', async (t) => { + const { store, recovery } = fixture(t); + const original = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'large.bin', mimeType: 'application/octet-stream', content: new Uint8Array(45 * 1024 * 1024) }])[0]!; + await store.save(owner, snapshot({ ...emptyDraft(), attachments: [{ type: 'approval', ...original }] })); + recovery.releaseSender(owner.senderId); + const target = { ...owner, senderId: 22 }; + for (let attempt = 0; attempt < 4; attempt++) { + const source = store.load(owner, [key('a')]); + const destination = store.load(target, [key('a')]); + assert.equal(destination.drafts[0]!.attachments[0]!.type, 'approval'); + store.releaseSnapshot(target.senderId, destination); + store.releaseSnapshot(owner.senderId, source); + const rollback = store.load(owner, [key('a')]); + const restored = rollback.drafts[0]!.attachments[0]!; + assert.equal(restored.type, 'approval'); + if (restored.type === 'approval') assert.equal(recovery.snapshot({ ...owner, sessionId: 'a' }, restored.approvalId).content.byteLength, 45 * 1024 * 1024); + store.releaseSnapshot(owner.senderId, rollback); + } +}); + +test('snapshot release includes side-conversation approvals and remains sender-scoped', async (t) => { + const { store, recovery } = fixture(t); + const ordinary = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'a', mimeType: 'text/plain', content: Uint8Array.of(1) }])[0]!; + const side = recovery.issue({ ...owner, sessionId: 'side' }, [{ name: 'side', mimeType: 'text/plain', content: Uint8Array.of(2) }])[0]!; + const state = { ...snapshot({ ...emptyDraft(), attachments: [{ type: 'approval' as const, ...ordinary }] }), sideConversations: [{ + panel: { id: 'panel', sourceSessionId: key('a'), quotes: [] }, active: true, hasContent: true, + draft: { ...emptyDraft('side'), attachments: [{ type: 'approval' as const, ...side }] }, + }] }; + store.releaseSnapshot(22, state); + assert.equal(recovery.snapshot({ ...owner, sessionId: 'side' }, side.approvalId).content[0], 2); + store.releaseSnapshot(owner.senderId, state); + for (const [sessionId, approval] of [['a', ordinary], ['side', side]] as const) + assert.throws(() => recovery.snapshot({ ...owner, sessionId }, approval.approvalId), { code: 'source_expired' }); +}); + +test('more than 256 visited sessions preserve both empty and nonempty drafts', async (t) => { + const { store } = fixture(t); + const drafts = Array.from({ length: 257 }, (_, index) => ({ ...emptyDraft(String(index)), text: index % 2 ? `draft ${index}` : '' })); + await store.save(owner, snapshot(...drafts)); + assert.deepEqual(store.load(owner, drafts.map((draft) => draft.sessionId)).drafts, drafts); +}); + +test('a workspace can save three independently valid 45 MiB drafts together', async (t) => { + const { dir, store, approvals } = fixture(t); + const path = join(dir, 'large-draft.bin'); + writeFileSync(path, new Uint8Array(45 * 1024 * 1024)); + const drafts = ['a', 'b', 'c'].map((id) => ({ ...emptyDraft(id), attachments: [{ type: 'approval' as const, + ...approvals.issueApprovals(owner.senderId, [{ path, name: `${id}.bin`, size: 45 * 1024 * 1024 }])[0]!, + }] })); + await store.save(owner, snapshot(...drafts)); + for (const draft of drafts) { + const restored = store.load(owner, [draft.sessionId]); + const attachment = restored.drafts[0]!.attachments[0]!; + assert.equal(attachment.type, 'approval'); + if (attachment.type === 'approval') assert.equal(attachment.size, 45 * 1024 * 1024); + store.releaseSnapshot(owner.senderId, restored); + } +}); + +test('an exact draft owner can keep already-custodied bytes after source token expiry without reading its path again', async (t) => { + let now = 0; + const { dir, store, approvals, recovery } = fixture(t, () => now); + const path = join(dir, 'long-lived.txt'); + writeFileSync(path, 'original'); + const picked = approvals.issueApprovals(owner.senderId, [{ path, name: 'long-lived.txt', size: 8 }])[0]!; + const recovered = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'recovered.txt', mimeType: 'text/plain', content: Buffer.from('recovered') }])[0]!; + const draft = { ...emptyDraft(), attachments: [{ type: 'approval' as const, ...picked }, { type: 'approval' as const, ...recovered }] }; + await store.save(owner, snapshot(draft)); + rmSync(path); + now = 31 * 60 * 1000; + assert.equal(approvals.peekApproval(owner.senderId, picked.approvalId), null); + assert.throws(() => recovery.snapshot({ ...owner, sessionId: 'a' }, recovered.approvalId), { code: 'source_expired' }); + for (const other of [ + { ...owner, senderId: 22 }, { ...owner, partition: 'other' }, + { ...owner, scope: { ...owner.scope, targetEpoch: 'other' } }, + ]) await assert.rejects(store.save(other, snapshot(draft)), { code: 'source_expired' }); + await assert.rejects(store.save(owner, snapshot({ ...draft, sessionId: key('b') })), { code: 'source_expired' }); + await store.save(owner, snapshot({ ...draft, text: 'edited after expiry' })); + const target = { ...owner, senderId: 22 }; + const loaded = store.load(target, [key('a')]); + assert.equal(loaded.drafts[0]!.text, 'edited after expiry'); + const prepared = recovery.prepare({ ...target, sessionId: 'a' }, loaded.drafts[0]!.attachments); + assert.deepEqual(prepared.items.map((item) => Buffer.from((item as { base64: string }).base64, 'base64').toString()), ['original', 'recovered']); + prepared.dispose(); + await store.save(owner, snapshot(emptyDraft())); + await assert.rejects(store.save(owner, snapshot(draft)), { code: 'source_expired' }, 'removed attachments cannot be resurrected by their former custody'); +}); + +test('only an authorized recovery owner can place bytes in a workspace', async (t) => { + const { store, recovery } = fixture(t); + const issued = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'private', mimeType: 'text/plain', content: Uint8Array.of(1) }])[0]!; + const draft = { ...emptyDraft(), attachments: [{ type: 'approval' as const, ...issued }] }; + for (const other of [ + { ...owner, senderId: 22 }, + { ...owner, partition: 'other' }, + { ...owner, scope: { ...owner.scope, targetEpoch: 'other' } }, + ]) await assert.rejects(store.save(other, snapshot(draft))); + await assert.rejects(store.save(owner, snapshot({ ...draft, sessionId: key('b') }))); + await store.save(owner, snapshot(draft)); + assert.equal(store.load({ ...owner, senderId: 22 }, [key('a')]).drafts[0]!.attachments.length, 1); +}); + +test('side conversations keep their panel, fork, own-turn boundary and draft', async (t) => { + const { store } = fixture(t); + const state: SessionWorkspaceSnapshot = { + ...snapshot(emptyDraft()), sideConversations: [{ + panel: { id: 'panel', sourceSessionId: key('a'), quotes: [{ id: 'quote', value: { text: 'question' } }] }, + active: true, hasContent: true, + companion: { sessionId: key('side'), ownTurnIds: ['own-turn'], queueOwnedMessageIds: ['queued-message'] }, + draft: { ...emptyDraft('side'), text: 'follow-up question' }, + }], + }; + await store.save(owner, state); + assert.deepEqual(store.load({ ...owner, senderId: 22 }, [key('a')]).sideConversations, state.sideConversations); + await store.save(owner, { ...snapshot(emptyDraft()), capturedAt: state.capturedAt + 1 }); + assert.deepEqual(store.load(owner, [key('a')]).sideConversations, []); +}); + +test('a stale checkpoint cannot overwrite a newer final snapshot', async (t) => { + const { store } = fixture(t); + await store.save(owner, { ...snapshot({ ...emptyDraft(), text: 'final' }), capturedAt: 20 }); + await store.save(owner, { ...snapshot({ ...emptyDraft(), text: 'older checkpoint' }), capturedAt: 10 }); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, 'final'); +}); + +test('revocation blocks already queued saves and clears only the revoked partition', async (t) => { + const { store } = fixture(t); + await store.save({ ...owner, partition: 'other' }, snapshot({ ...emptyDraft(), text: 'other authority' })); + const pending = store.save(owner, snapshot({ ...emptyDraft(), text: 'queued before revoke' })); + store.clear(owner.partition); + await assert.rejects(pending, /authorization was cleared/); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, ''); + assert.equal(store.load({ ...owner, partition: 'other' }, [key('a')]).drafts[0]!.text, 'other authority'); +}); + + +test('confirmed deletion ignores pending and late checkpoints without blocking unrelated draft saves', async (t) => { + const { store } = fixture(t); + await store.save(owner, snapshot({ ...emptyDraft(), text: 'before deletion' })); + const saving = store.save(owner, snapshot({ ...emptyDraft(), text: 'pending' }, { ...emptyDraft('b'), text: 'B stays' })); + store.delete(owner.partition, [key('a')]); + await saving; + await store.save(owner, snapshot({ ...emptyDraft(), text: 'late source checkpoint' })); + const loaded = store.load(owner, [key('a'), key('b')]); + assert.equal(loaded.drafts[0]!.text, ''); + assert.equal(loaded.drafts[1]!.text, 'B stays'); +}); + +test('deletion during a picker read ignores its revoked approval and still saves other sessions', async (t) => { + const { dir, store, approvals } = fixture(t); + const path = join(dir, 'pending.txt'); + writeFileSync(path, 'pending'); + const picked = approvals.issueApprovals(owner.senderId, [{ path, name: 'pending.txt', size: 7 }])[0]!; + const peek = approvals.peekApproval; + let deleted = false; + approvals.peekApproval = (senderId, approvalId) => { + const approved = peek(senderId, approvalId); + if (!deleted) { + deleted = true; + store.delete(owner.partition, [key('a')]); + approvals.clearSender(owner.senderId); + } + return approved; + }; + await store.save(owner, snapshot({ ...emptyDraft(), attachments: [{ type: 'approval', ...picked }] }, { ...emptyDraft('b'), text: 'unrelated draft' })); + const loaded = store.load(owner, [key('a'), key('b')]); + assert.ok(deleted); + assert.equal(loaded.drafts[0]!.attachments.length, 0); + assert.equal(loaded.drafts[1]!.text, 'unrelated draft'); +}); diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index c95887a5f76..20eaa26b4a3 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -71,6 +71,7 @@ function createBridgeRecorder(answerOverrides: Record = {}): { bridge: { gitReview: domain('gitReview'), sessions: domain('sessions'), + sessionLocal: domain('sessionLocal'), shellRuns: domain('shellRuns'), browser: domain('browser'), artifacts: domain('artifacts'), @@ -241,6 +242,7 @@ describe('createDesktopWorkbarServices', () => { await services.attachments.pickFiles(); await services.attachments.previewApproval('approval'); + await services.attachments.releaseRecoveryAttachments(['local-recovery:side']); await services.sideChat.listSessions(); await services.sideChat.listTurns('s'); @@ -321,6 +323,7 @@ describe('createDesktopWorkbarServices', () => { 'inspector.subscribeUsageChanges', 'attachments.pickFiles', 'attachments.previewApproval', + 'sessionLocal.releaseRecoveryAttachments', 'sessions.list', 'sessions.listTurns', 'sessions.branchFromTurn', diff --git a/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts b/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts new file mode 100644 index 00000000000..b361a0b381d --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { EventEmitter } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { test } from 'node:test'; +import { transform } from 'esbuild'; +import { createWorkspaceWindowRegistry } from '../workspace-window-registry.js'; +import { createWindowRevealGate } from '../window-reveal.js'; +import type { MainWindowController } from '../main-window.js'; + +async function harness() { + const registry = createWorkspaceWindowRegistry(); + const windows: FakeWindow[] = []; + let settingsReads = 0; + let releaseSettings!: () => void; + const settings = new Promise((resolve) => { releaseSettings = resolve; }); + const closedCounts: number[] = []; + class Contents extends EventEmitter { + session = {}; + destroyed = false; + url = ''; + isDestroyed() { return this.destroyed; } + setWindowOpenHandler() {} + getURL() { return this.url; } + executeJavaScript() { return Promise.resolve(); } + send() {} + } + class FakeWindow extends EventEmitter { + readonly id = windows.length + 1; + private readonly contents = new Contents(); + destroyed = false; + visible = false; + readonly contentView = {}; + get webContents() { + if (this.destroyed) throw new Error('Object has been destroyed'); + return this.contents; + } + constructor(readonly options: Electron.BrowserWindowConstructorOptions) { + super(); windows.push(this); + } + isDestroyed() { return this.destroyed; } + isVisible() { return this.visible; } + isFocused() { return false; } + isMinimized() { return false; } + isMaximized() { return false; } + getBounds() { return { x: 0, y: 0, width: 1240, height: 820 }; } + getNormalBounds() { return this.getBounds(); } + async loadURL(url: string) { this.contents.url = url; this.contents.emit('did-finish-load'); this.emit('ready-to-show'); } + destroy() { + this.destroyed = true; this.contents.destroyed = true; + this.contents.emit('destroyed'); this.emit('closed'); + } + } + const modules: Record = { + electron: { app: {}, BrowserWindow: FakeWindow, nativeTheme: { shouldUseDarkColors: false }, + screen: { getAllDisplays: () => [{ workArea: { x: 0, y: 0, width: 1500, height: 1000 } }] } }, + 'node:fs/promises': { mkdir: async () => undefined }, + 'node:path': path, + '@maka/core/settings': { appIconForTheme: () => 'default' }, + './main-window-drop-guard.js': { MAIN_WINDOW_DROP_GUARD_SCRIPT: '' }, + './app-icon-surface.js': { applyInitialWindowAppIcon() {}, readableAppIconPath: () => '' }, + './external-link-guard.js': { isExternalUrl: () => false }, + './window-state.js': { readSavedBounds: async (_root: string, defaults: unknown) => defaults, writeSavedBounds: async () => undefined, SAFE_MIN_WIDTH: 600, SAFE_MIN_HEIGHT: 400 }, + './browser/controller.js': {}, './browser/view-manager.js': {}, + './main-window-permission-policy.js': { installMainWindowPermissionPolicy() {} }, + './main-renderer-loader.js': { resolveMainRendererEntry: () => ({ url: 'http://localhost/index.html' }), loadMainRenderer: (window: FakeWindow) => window.loadURL('http://localhost/index.html') }, + './main-renderer-dev-cache.js': { clearDevRendererHttpCache: async () => undefined }, + './main-renderer-process-gone.js': { observeMainRendererProcessGone() {} }, + './theme-source.js': { isDarkAppearance: () => false, toNativeThemeSource: () => 'system' }, + './window-reveal.js': { createWindowRevealGate }, + './windows-maximize-renderer-sync.js': { createWindowsMaximizeRendererSync: () => () => undefined }, + './auxiliary-window-registry.js': { auxiliaryWindowRegistry: { renderers: () => [] } }, + './workspace-window-registry.js': { workspaceWindowRegistry: registry }, + '../shared/runtime-host-identity.js': {}, + }; + const source = await readFile(fileURLToPath(new URL('../../../src/main/main-window.ts', import.meta.url)), 'utf8'); + const compiled = await transform(source, { loader: 'ts', format: 'cjs', target: 'es2022', define: { 'import.meta.dirname': JSON.stringify('/virtual/main') } }); + const module = { exports: {} as { createMainWindowController: (deps: unknown) => MainWindowController } }; + runInNewContext(compiled.code, { + module, exports: module.exports, + require: (id: string) => { if (!(id in modules)) throw new Error(`Unexpected import ${id}`); return modules[id]; }, + process: { platform: 'linux', env: {} }, setTimeout, clearTimeout, console, + }); + const controller = module.exports.createMainWindowController({ + workspaceRoot: '/virtual', e2eFixture: null, revealMode: 'hidden', + settingsStore: { get: async () => { settingsReads += 1; await settings; return { appearance: { theme: 'auto' } }; } }, + onRendererProcessGone() {}, + onClosed: () => { closedCounts.push(registry.entries().length); }, + }); + return { controller, registry, windows, releaseSettings, closedCounts, settingsReads: () => settingsReads }; +} + +test('concurrent main creation shares the asynchronous construction and one registered native window', async () => { + const h = await harness(); + const signal = new AbortController().signal; + const first = h.controller.createWindow(signal); + const second = h.controller.createWindow(signal); + assert.equal(first, second); + assert.equal(h.settingsReads(), 1); + assert.equal(h.windows.length, 0); + h.releaseSettings(); + await Promise.all([first, second]); + assert.equal(h.windows.length, 1); + assert.equal(h.registry.main()?.window, h.windows[0]); + assert.equal(h.controller.browserWindow(), h.windows[0]); + assert.equal(h.registry.main()?.controller.browserWindow(), h.windows[0]); + await h.controller.createWindow(signal); + assert.equal(h.windows.length, 1); + h.windows[0]!.destroy(); + await Promise.all([h.controller.createWindow(signal), h.controller.createWindow(signal)]); + assert.equal(h.windows.length, 2); + assert.equal(h.registry.main()?.window, h.windows[1]); +}); + +test('closing the last session window invokes the shared workspace lifecycle callback', async () => { + const h = await harness(); + h.releaseSettings(); + const signal = new AbortController().signal; + await h.controller.createWindow(signal); + await h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'A', group: { hostId: 'local', groupId: 'A', sessionIds: ['A'] } }, signal); + h.windows[0]!.destroy(); + assert.deepEqual(h.closedCounts, [1]); + assert.equal(h.controller.hasOpenWindows(), true); + h.windows[1]!.destroy(); + assert.deepEqual(h.closedCounts, [1, 0]); + assert.equal(h.controller.hasOpenWindows(), false); +}); diff --git a/apps/desktop/src/main/__tests__/workspace-window-registry.test.ts b/apps/desktop/src/main/__tests__/workspace-window-registry.test.ts new file mode 100644 index 00000000000..1eb888c1ffe --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-window-registry.test.ts @@ -0,0 +1,48 @@ +/* + * 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 { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { createWorkspaceWindowRegistry } from '../workspace-window-registry.js'; + +test('commands target only the active workspace, and main close preserves session window identity', () => { + const registry = createWorkspaceWindowRegistry(); + const delivered: string[] = []; + const make = (id: string, kind: 'main' | 'session') => { + const window = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + isFocused: () => false, + webContents: { isDestroyed: () => false, send: () => delivered.push(id) }, + }); + registry.register({ context: { kind, windowId: id } as never, window: window as never, controller: {} as never }); + return window; + }; + const main = make('1', 'main'); + const child = make('2', 'session'); + child.emit('focus'); + registry.sendCommand('window:command', { id: 'search' }); + assert.deepEqual(delivered, ['2']); + assert.equal(registry.forSender(child.webContents as never)?.context.windowId, '2'); + assert.equal(registry.forSender({ isDestroyed: () => false } as never), undefined); + main.emit('closed'); + assert.equal(registry.main(), undefined); + assert.equal(registry.entries().length, 1); + assert.throws(() => { make('3', 'main'); make('4', 'main'); }, /already exists/); +}); diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index 7f06ae91cf7..6d049d814a2 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -74,10 +74,10 @@ export function registerAppClientIpc( ): void { const { mainWindowController, e2eFixture, updateService } = deps; targetIpc.handle('window:popupMenu', (event, input: unknown) => { - if (!mainWindowController.isMainRenderer(event.sender) || event.senderFrame !== event.sender.mainFrame) { - throw new Error('Native menus require the main renderer'); + if (!mainWindowController.isWorkspaceRenderer(event.sender) || event.senderFrame !== event.sender.mainFrame) { + throw new Error('Native menus require a workspace renderer'); } - const window = mainWindowController.browserWindow(); + const window = mainWindowController.browserWindowFor(event.sender); return window ? popupNativeMenu(window, input) : null; }); targetIpc.handle('window:setTitlebarControlsVisible', (event, visible: unknown): void => { diff --git a/apps/desktop/src/main/app-quit-coordinator.ts b/apps/desktop/src/main/app-quit-coordinator.ts index 565f4502623..35a47454f12 100644 --- a/apps/desktop/src/main/app-quit-coordinator.ts +++ b/apps/desktop/src/main/app-quit-coordinator.ts @@ -23,6 +23,7 @@ export interface AppQuitEvent { export interface AppQuitCoordinator { focusOrCreateWindow(): Promise; + isQuitting(): boolean; handleBeforeQuit(event: AppQuitEvent): void; } @@ -55,6 +56,7 @@ export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitC }; return { + isQuitting: () => phase !== 'running', focusOrCreateWindow, handleBeforeQuit(event): void { if (phase === 'ready-to-exit') return; diff --git a/apps/desktop/src/main/attachment-preview.ts b/apps/desktop/src/main/attachment-preview.ts index 5128a6516ec..54ddbe377f4 100644 --- a/apps/desktop/src/main/attachment-preview.ts +++ b/apps/desktop/src/main/attachment-preview.ts @@ -20,6 +20,7 @@ import { Buffer } from 'node:buffer'; import { MAX_ATTACHMENT_BYTES, sniffAttachmentMimeType } from '@maka/core/attachments'; import type { AttachmentApprovalRegistry } from './attachment-approval.js'; +import type { SessionLocalAttachmentRecovery, RecoveryOwner } from './session-local-attachment-recovery.js'; export type AttachmentPreviewResult = | { ok: true; base64: string; mimeType: string } @@ -91,6 +92,33 @@ export async function loadApprovalPreview(input: { } } +/** Recovery data is only exposed as a freshly encoded thumbnail, never original bytes. */ +export async function loadRecoveryApprovalPreview(input: { + senderId: number; + approvalId: string; + recovery: SessionLocalAttachmentRecovery; + authorizeRecovery(owner: RecoveryOwner): boolean; + renderPreview: AttachmentPreviewDeps['renderPreview']; +}): Promise { + const owner = input.recovery.ownerForPreview(input.senderId, input.approvalId); + if (!owner || !input.authorizeRecovery(owner)) return { ok: false, reason: 'not_found' }; + try { + const { content } = input.recovery.snapshot(owner, input.approvalId); + if (content.byteLength > MAX_ATTACHMENT_BYTES) return { ok: false, reason: 'unreadable' }; + if (!sniffAttachmentMimeType(content)?.startsWith('image/')) return { ok: false, reason: 'not_image' }; + const preview = await input.renderPreview(content); + // A window can close or its Host authorization can be revoked while the + // decoder runs. Never return an already revoked snapshot to that renderer. + if (!input.recovery.ownerForPreview(input.senderId, input.approvalId) || !input.authorizeRecovery(owner)) + return { ok: false, reason: 'not_found' }; + if (!preview || preview.mimeType !== 'image/png' || preview.bytes.byteLength > INLINE_PREVIEW_FALLBACK_MAX_BYTES) + return { ok: false, reason: 'unreadable' }; + return { ok: true, base64: Buffer.from(preview.bytes).toString('base64'), mimeType: preview.mimeType }; + } catch { + return { ok: false, reason: 'unreadable' }; + } +} + /** * Register the client-owned attachment preview boundary used by Runtime Host * Desktop. Preview admission and byte validation remain local because the @@ -104,9 +132,13 @@ export function registerAttachmentPreviewIpc(input: { ): void; }; approvals: AttachmentApprovalRegistry; + recovery: SessionLocalAttachmentRecovery; + authorizeRecovery(owner: RecoveryOwner): boolean; } & AttachmentPreviewDeps): void { input.ipcMain.handle('attachments:previewApproval', (event, approvalId) => - loadApprovalPreview({ + typeof approvalId === 'string' && approvalId.startsWith('local-recovery:') + ? loadRecoveryApprovalPreview({ senderId: event.sender.id, approvalId, recovery: input.recovery, authorizeRecovery: input.authorizeRecovery, renderPreview: input.renderPreview }) + : loadApprovalPreview({ senderId: event.sender.id, approvalId, approvals: input.approvals, diff --git a/apps/desktop/src/main/browser-ipc-main.ts b/apps/desktop/src/main/browser-ipc-main.ts index e59bca71efa..02cf3020dd2 100644 --- a/apps/desktop/src/main/browser-ipc-main.ts +++ b/apps/desktop/src/main/browser-ipc-main.ts @@ -25,6 +25,7 @@ import { releaseBrowserSession, revokeHiddenBrowserActions } from './browser/ses import { type BrowserViewRect, viewportBoundsAtScale } from './browser/logic.js'; import type { createMainWindowController } from './main-window.js'; import { + desktopSessionKey, desktopSessionResourceKey, parseDesktopSessionResourceKey, requireDesktopTargetScope, @@ -37,10 +38,14 @@ interface BrowserIpcDeps { rendererParent(contents: Electron.WebContents): Electron.View | undefined; }; isHostActive(scope: DesktopTargetScope): boolean; + canUseSession(contents: Electron.WebContents, sessionId: string): boolean; + parkingParent: Electron.View; } export interface BrowserIpcController { refreshVisibility(): void; + transferSessions(sessionIds: readonly string[], source: Electron.WebContents | undefined, target: Electron.WebContents): void; + releaseRenderer(contents: Electron.WebContents): void; retireTarget(scope: DesktopTargetScope): Promise; } @@ -55,12 +60,14 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { const selections = new Map(); const observedRenderers = new WeakSet(); + const rendererParents = new WeakMap(); + const detachedSessions = new Set(); const ownsRenderer = (contents: Electron.WebContents): boolean => - !contents.isDestroyed() && (deps.mainWindowController.isMainRenderer(contents) || + !contents.isDestroyed() && (deps.mainWindowController.isWorkspaceRenderer(contents) || deps.auxiliaryWindowRegistry.rendererParent(contents) !== undefined); const browserParentForRenderer = (contents: Electron.WebContents): Electron.View | undefined => { if (contents.isDestroyed()) return undefined; - if (deps.mainWindowController.isMainRenderer(contents)) { + if (deps.mainWindowController.isWorkspaceRenderer(contents)) { const window = BrowserWindow.fromWebContents(contents); return window && !window.isDestroyed() ? window.contentView : undefined; } @@ -96,9 +103,10 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { window.isVisible() && !window.isMinimized(); }; const canRunInBackground = (sessionId: string): boolean => { + const ref = parseDesktopSessionResourceKey(sessionId); + if (detachedSessions.has(sessionId)) return deps.isHostActive(ref); const owner = ownerForSession(sessionId); if (!owner || !browserParentForRenderer(owner)) return false; - const ref = parseDesktopSessionResourceKey(sessionId); return isWorkHubCoordinationSessionId(ref.sessionId) && deps.isHostActive(ref); }; const revokeHiddenActions = (): void => { @@ -153,10 +161,25 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { revokeHiddenActions(); }; + const releaseRenderer = (contents: Electron.WebContents): void => { + const parent = rendererParents.get(contents); + if (!parent) return; + for (const sessionId of views.sessionIds()) { + const view = views.get(sessionId); + if (!view?.hasOwner(parent)) continue; + view.setOwner(deps.parkingParent); + view.park(); + detachedSessions.add(sessionId); + } + clearRendererSelection(contents); + }; + const observeRenderer = (contents: Electron.WebContents): void => { if (observedRenderers.has(contents)) return; observedRenderers.add(contents); const parent = browserParentForRenderer(contents); + if (parent) rendererParents.set(contents, parent); + const workspace = deps.mainWindowController.isWorkspaceRenderer(contents); const window = BrowserWindow.fromWebContents(contents); const parkPresentedPages = () => { if (!parent) return; @@ -177,6 +200,7 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { contents.on('zoom-changed', syncZoomedViewport); contents.on('render-process-gone', () => clearRendererSelection(contents)); contents.once('destroyed', () => { + if (workspace) releaseRenderer(contents); window?.removeListener('close', parkPresentedPages); window?.removeListener('hide', revokeHiddenActions); window?.removeListener('minimize', revokeHiddenActions); @@ -247,6 +271,8 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { selection: RendererSelection, sessionId: string | null, ): void => { + if (sessionId && deps.mainWindowController.isWorkspaceRenderer(contents) && + !deps.canUseSession(contents, desktopSessionKey(parseDesktopSessionResourceKey(sessionId)))) return; const previousOwner = sessionId ? ownerForSession(sessionId) : undefined; if (previousOwner && previousOwner !== contents && (!isCoordination(sessionId!) || deps.mainWindowController.isMainRenderer(previousOwner) === deps.mainWindowController.isMainRenderer(contents))) return; @@ -260,7 +286,7 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { } selection.sessionId = sessionId; if (changed) selection.viewport = null; - if (isCoordination(sessionId) && !deps.mainWindowController.isMainRenderer(contents)) { + if ((deps.mainWindowController.isWorkspaceRenderer(contents) && !isCoordination(sessionId)) || (isCoordination(sessionId) && !deps.mainWindowController.isMainRenderer(contents))) { const parent = browserParentForRenderer(contents); if (parent) views.get(sessionId)?.setOwner(parent); } @@ -275,7 +301,8 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { const contents = ownedRenderer(event); if (!contents) return undefined; const sessionId = requireBrowserTarget(scope, target); - return sessionId && browserParentForRenderer(contents) && + return sessionId && (!deps.mainWindowController.isWorkspaceRenderer(contents) || + deps.canUseSession(contents, desktopSessionKey(parseDesktopSessionResourceKey(sessionId)))) && browserParentForRenderer(contents) && selections.get(contents)?.sessionId === sessionId ? sessionId : undefined; @@ -366,6 +393,26 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { return { refreshVisibility: revokeHiddenActions, + releaseRenderer, + transferSessions(sessionIds, source, target) { + const parent = browserParentForRenderer(target); + if (!parent) throw new Error('Target browser window is unavailable'); + observeRenderer(target); + const members = new Set(sessionIds); + for (const sessionId of views.sessionIds()) { + if (!members.has(desktopSessionKey(parseDesktopSessionResourceKey(sessionId)))) continue; + const view = views.get(sessionId)!; + view.setOwner(parent); + view.park(); + detachedSessions.add(sessionId); + } + const selection = source && selections.get(source); + if (selection?.sessionId && members.has(desktopSessionKey(parseDesktopSessionResourceKey(selection.sessionId)))) { + selection.sessionId = null; + selection.viewport = null; + } + revokeHiddenActions(); + }, async retireTarget(scope) { for (const selection of selections.values()) { const sessionId = selection.sessionId; @@ -377,6 +424,7 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { const retired = views.sessionIds().filter((sessionId) => { return belongsToTarget(sessionId, scope); }); + for (const sessionId of retired) detachedSessions.delete(sessionId); await Promise.all(retired.map((sessionId) => releaseBrowserSession(sessionId))); }, }; diff --git a/apps/desktop/src/main/computer-use/pip-electron.ts b/apps/desktop/src/main/computer-use/pip-electron.ts index 67614f1d9d4..eb1d0896162 100644 --- a/apps/desktop/src/main/computer-use/pip-electron.ts +++ b/apps/desktop/src/main/computer-use/pip-electron.ts @@ -75,6 +75,7 @@ export interface PipWindowLike { * the controls cannot be driven by any other renderer in the app. */ onMessage(cb: (channel: string, payload: unknown) => void): void; + setParentWindow(parent: ParentWindowLike | undefined): void; setBounds(bounds: Rectangle): void; getBounds(): Rectangle; showInactive(): void; @@ -262,6 +263,7 @@ export function defaultCreateWindow( w.webContents.ipc.on(channel, (_event, payload) => cb(channel, payload)); } }, + setParentWindow: (parent) => w.setParentWindow(parent as import('electron').BrowserWindow ?? null), setBounds: (bounds) => w.setBounds(bounds), getBounds: () => w.getBounds(), showInactive: () => auxiliaryWindowRegistry.show('pip', w, revealMode), diff --git a/apps/desktop/src/main/computer-use/pip-window.ts b/apps/desktop/src/main/computer-use/pip-window.ts index 4356fd204fe..58eca73b02b 100644 --- a/apps/desktop/src/main/computer-use/pip-window.ts +++ b/apps/desktop/src/main/computer-use/pip-window.ts @@ -91,6 +91,8 @@ export interface ComputerUsePipController { complete(sessionId: string): void; clearForSession(sessionId: string): void; destroyAll(): void; + /** Rebind the live mirror after its session moves to another workspace. */ + refreshOwner(): void; isVisible(): boolean; /** * What the mirror's stop control runs. Same shape the status item uses, and @@ -151,14 +153,14 @@ export interface CreatePipControllerDeps { * falls back to the primary display's corner, as Codex's own * `defaultHostForPositioning` / `fallbackAnchor` do. */ - resolveAnchorRect?: () => Rectangle | undefined; + resolveAnchorRect?: (sessionId: string) => Rectangle | undefined; /** * Subscribe to app-window geometry changes; return an unsubscribe. * * Only resizes are acted on — see the subscription below for why a move is * deliberately ignored. */ - subscribeAnchorChanges?: (cb: () => void) => () => void; + subscribeAnchorChanges?: (sessionId: string, cb: () => void) => () => void; /** * The app window itself, to make the mirror its child. * @@ -182,7 +184,7 @@ export interface CreatePipControllerDeps { * The comment on the geometry subscription used to say repositioning a small * window was "cheap enough not to debounce". It is cheap. It is also late. */ - resolveParentWindow?: () => ParentWindowLike | undefined; + resolveParentWindow?: (sessionId: string) => ParentWindowLike | undefined; /** * Where the pointer is, in screen points. * @@ -230,7 +232,7 @@ export function createComputerUsePipController( deps: CreatePipControllerDeps = {}, ): ComputerUsePipController { const resolveBounds = - deps.resolveBounds ?? ((aspect: number) => defaultResolveBounds(aspect, deps.resolveAnchorRect?.())); + deps.resolveBounds ?? ((aspect: number) => defaultResolveBounds(aspect, resolveAnchorRect())); const preloadPath = deps.preloadPath ?? join(defaultDistDir(), 'pip-preload.cjs'); const htmlPath = deps.htmlPath ?? join(defaultDistDir(), 'pip.html'); @@ -359,8 +361,12 @@ export function createComputerUsePipController( if (w && !w.isDestroyed()) w.destroy(); } + function resolveAnchorRect(): Rectangle | undefined { + return sessionId ? deps.resolveAnchorRect?.(sessionId) : undefined; + } + function liveParent(): ParentWindowLike | undefined { - const candidate = deps.resolveParentWindow?.(); + const candidate = sessionId ? deps.resolveParentWindow?.(sessionId) : undefined; return candidate && !candidate.isDestroyed() ? candidate : undefined; } @@ -370,7 +376,7 @@ export function createComputerUsePipController( * `defaultHostForPositioning` falls back the same way. */ function hostRect(current: Rectangle): Rect { - return deps.resolveAnchorRect?.() ?? workAreaOf(current); + return resolveAnchorRect() ?? workAreaOf(current); } function workAreaOf(rect: Rectangle): Rect { @@ -596,9 +602,29 @@ export function createComputerUsePipController( } } + function bindAnchor(created: PipWindowLike): void { + unsubscribeAnchor?.(); + unsubscribeAnchor = + deps.subscribeAnchorChanges?.(sessionId!, () => { + if (win !== created || created.isDestroyed()) return; + // A drag outranks the app window: whatever the window is doing, the + // mirror is where the pointer is holding it. + if (drag) return; + const next = resolveAnchorRect(); + if (parented && next && anchorSize && + next.width === anchorSize.width && next.height === anchorSize.height) { + return; + } + anchorSize = next ? { width: next.width, height: next.height } : null; + const current = created.getBounds(); + moveTo(created, restPoint(current), { width: current.width, height: current.height }); + }) ?? null; + } + function ensure(nextSessionId: string): PipWindowLike { if (win && !win.isDestroyed() && sessionId === nextSessionId) return win; if (win) teardown(); + sessionId = nextSessionId; const parent = liveParent(); const bounds = resolveBounds(aspect); const options = pipWindowOptions(bounds, preloadPath, parent); @@ -610,7 +636,7 @@ export function createComputerUsePipController( ready = false; queue = []; parented = Boolean(parent); - const anchor = deps.resolveAnchorRect?.(); + const anchor = resolveAnchorRect(); anchorSize = anchor ? { width: anchor.width, height: anchor.height } : null; created.onMessage((channel, payload) => handleMessage(created, channel, payload)); // Queued until the page loads. A fresh window inherits whichever corner the @@ -640,25 +666,22 @@ export function createComputerUsePipController( // exactly the trailing the mirror used to show during a drag. A resize // leaves the child where it is while the corner moves out from under it, so // that one has to be recomputed. - unsubscribeAnchor = - deps.subscribeAnchorChanges?.(() => { - if (win !== created || created.isDestroyed()) return; - // A drag outranks the app window: whatever the window is doing, the - // mirror is where the pointer is holding it. - if (drag) return; - const next = deps.resolveAnchorRect?.(); - if (parented && next && anchorSize && - next.width === anchorSize.width && next.height === anchorSize.height) { - return; - } - anchorSize = next ? { width: next.width, height: next.height } : null; - const current = created.getBounds(); - moveTo(created, restPoint(current), { width: current.width, height: current.height }); - }) ?? null; + bindAnchor(created); return created; } return { + refreshOwner(): void { + if (!win || win.isDestroyed() || !sessionId) return; + const parent = liveParent(); + win.setParentWindow(parent); + parented = Boolean(parent); + bindAnchor(win); + const anchor = resolveAnchorRect(); + anchorSize = anchor ? { width: anchor.width, height: anchor.height } : null; + const current = win.getBounds(); + moveTo(win, restPoint(current), { width: current.width, height: current.height }); + }, present(input: PipPresentInput): void { if (typeof input.sessionId !== 'string' || input.sessionId.length === 0) return; if (!input.base64) return; diff --git a/apps/desktop/src/main/desktop-native-capability-assembly.ts b/apps/desktop/src/main/desktop-native-capability-assembly.ts index e7729e789c3..cec9d36693e 100644 --- a/apps/desktop/src/main/desktop-native-capability-assembly.ts +++ b/apps/desktop/src/main/desktop-native-capability-assembly.ts @@ -49,11 +49,11 @@ export interface DesktopNativeCapabilityAssemblyDeps { readonly revealMode: WindowRevealMode; readonly locale: Pick; readonly keepSystemAwake?: { hold(reason: string): void; release(reason: string): void }; - readonly mainWindow?: { + readonly sessionWindow?: (sessionId: string) => { windowBounds(): { x: number; y: number; width: number; height: number } | undefined; onWindowGeometryChanged(cb: () => void): () => void; browserWindow(): { isDestroyed(): boolean } | undefined; - }; + } | undefined; } /** Assemble the Desktop-owned capabilities that a Host may call. */ @@ -63,11 +63,11 @@ export function assembleDesktopNativeCapabilities( const browserTools = buildBrowserTools(); const computerUseOverlay = createCursorOverlayController({ revealMode: deps.revealMode }); const computerUsePip = createComputerUsePipController( - deps.mainWindow + deps.sessionWindow ? { - resolveAnchorRect: () => deps.mainWindow?.windowBounds(), - subscribeAnchorChanges: (cb) => deps.mainWindow?.onWindowGeometryChanged(cb) ?? (() => {}), - resolveParentWindow: () => deps.mainWindow?.browserWindow(), + resolveAnchorRect: (sessionId) => deps.sessionWindow?.(sessionId)?.windowBounds(), + subscribeAnchorChanges: (sessionId, cb) => deps.sessionWindow?.(sessionId)?.onWindowGeometryChanged(cb) ?? (() => {}), + resolveParentWindow: (sessionId) => deps.sessionWindow?.(sessionId)?.browserWindow(), cursorPoint: () => screen.getCursorScreenPoint(), workAreaFor: (rect) => screen.getDisplayMatching(rect).workArea, revealMode: deps.revealMode, diff --git a/apps/desktop/src/main/desktop-shell-presentation.ts b/apps/desktop/src/main/desktop-shell-presentation.ts index 91afa20ce36..21f33fa3295 100644 --- a/apps/desktop/src/main/desktop-shell-presentation.ts +++ b/apps/desktop/src/main/desktop-shell-presentation.ts @@ -38,7 +38,7 @@ export function installDesktopShellPresentation( isPackaged: app.isPackaged, dispatch: (command) => { if (deps.mainWindowController.hasOpenWindows()) { - deps.mainWindowController.send('window:command', { id: command }); + deps.mainWindowController.sendCommand('window:command', { id: command }); } else { deps.focusOrCreateWindow(); } diff --git a/apps/desktop/src/main/early-window.ts b/apps/desktop/src/main/early-window.ts index 4684d41a176..4233eddf093 100644 --- a/apps/desktop/src/main/early-window.ts +++ b/apps/desktop/src/main/early-window.ts @@ -85,7 +85,7 @@ export const workspaceRoot = join( // controller reads them lazily so the window never waits on that wiring. export const mainWindowDelegates = { onMainWindowClose: (): void => {}, - onMainWindowClosed: (): void => {}, + onWorkspaceWindowClosed: (): void => {}, }; export const desktopDiagnostics: DesktopDiagnosticsDeps = { @@ -256,7 +256,7 @@ export const mainWindowController = createMainWindowController({ window?.on('restore', settingsRecovery.onWindowReady); }, onClose: () => mainWindowDelegates.onMainWindowClose(), - onClosed: () => mainWindowDelegates.onMainWindowClosed(), + onClosed: () => mainWindowDelegates.onWorkspaceWindowClosed(), onRendererProcessGone: async (details) => { const diagnosticInput = createDesktopMainRendererDiagnosticInput({ title: "Maka main Renderer process exited unexpectedly", @@ -277,7 +277,8 @@ export const mainWindowController = createMainWindowController({ if (await mainWindowController.reloadMainRenderer()) return; if (!mainWindowController.browserWindow()) break; } - app.quit(); + mainWindowController.browserWindow()?.destroy(); + if (!mainWindowController.hasOpenWindows()) app.quit(); }, }); resolveBrowserDialogParent = () => { @@ -291,7 +292,7 @@ export const quitCoordinator = createAppQuitCoordinator({ prepareToQuit: () => bootContext.prepareToQuit?.() ?? Promise.resolve("ready" as const), cleanup: () => bootContext.cleanup?.() ?? Promise.resolve(), focusOrCreateWindow: (signal) => { - if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); + if (mainWindowController.browserWindow()) mainWindowController.focus(); else return mainWindowController.createWindow(signal); }, onPreparationError: (error) => { diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index a7043d9998f..0b056416d07 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -18,7 +18,7 @@ */ import { MAIN_WINDOW_DROP_GUARD_SCRIPT } from './main-window-drop-guard.js'; -import { app, BrowserWindow, dialog, nativeTheme, screen, shell, type View, webFrameMain } from 'electron'; +import { app, BrowserWindow, dialog, nativeTheme, screen, shell, webFrameMain } from 'electron'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { appIconForTheme, type AppSettings } from '@maka/core/settings'; @@ -40,6 +40,8 @@ import { isDarkAppearance, isThemePreference, toNativeThemeSource } from './them import { createWindowRevealGate, type WindowRevealMode } from './window-reveal.js'; import { createWindowsMaximizeRendererSync } from './windows-maximize-renderer-sync.js'; import { auxiliaryWindowRegistry } from './auxiliary-window-registry.js'; +import { workspaceWindowRegistry } from './workspace-window-registry.js'; +import type { SessionWindowContext } from '../shared/session-window-contract.js'; import { parseDesktopSessionResourceKey, } from '../shared/runtime-host-identity.js'; @@ -48,15 +50,15 @@ type SettingsReader = { get(): Promise; }; -export interface MainWindowController { +export interface WorkspaceWindowController { createWindow(signal: AbortSignal): Promise; /** * Reload only the crashed main Renderer, preserving the Desktop process, * Runtime Host, background services, and current BrowserWindow. */ reloadMainRenderer(): Promise; - send(channel: string, ...args: unknown[]): void; - isMainRenderer(contents: Electron.WebContents): boolean; + isRenderer(contents: Electron.WebContents): boolean; + reveal(): void; // PR-SHOW-AFTER-FIRST-COMMIT: reveal the hidden window after the renderer's // first React commit. Idempotent + e2e-fixture-safe (see notifyRendererReady). notifyRendererReady( @@ -66,10 +68,6 @@ export interface MainWindowController { setTitlebarControlsVisible(sender: Electron.WebContents, visible: unknown): void; setThemeSource(sender: Electron.WebContents, themePref: unknown): void; setTitleBarOverlayTheme(sender: Electron.WebContents, theme: unknown): void; - showOpenDialog(options: Electron.OpenDialogOptions): Promise; - showSaveDialog(options: Electron.SaveDialogOptions): Promise; - getBrowserViews(parentForSession: (sessionId: string) => Electron.View | undefined): BrowserViewManager; - disposeBrowserViews(): Promise; hasOpenWindows(): boolean; focus(): void; /** @@ -102,7 +100,7 @@ interface MainWindowControllerDeps { // main.ts computes this from the same isE2e gate that also guards userData // and the fake backend, so main-window.ts owns no env policy of its own. revealMode: WindowRevealMode; - onClose?: () => void; + onClose?: (event: Electron.Event) => void; onClosed?: () => void; onRendererProcessGone: (details: Electron.RenderProcessGoneDetails) => void | Promise; // Fires right after `new BrowserWindow` — main.ts defers the heavy Runtime @@ -111,14 +109,12 @@ interface MainWindowControllerDeps { onWindowConstructed?: () => void; } -let mainWindow: BrowserWindow | null = null; -let browserViews: BrowserViewManager | undefined; /** Broadcast once to each live owned renderer, even if the main window is closed. * Returns whether at least one IPC send was issued, not a renderer acknowledgment. */ export function safeSendToRenderer(channel: string, ...args: unknown[]): boolean { const recipients = new Set(auxiliaryWindowRegistry.renderers()); - if (mainWindow && !mainWindow.isDestroyed()) recipients.add(mainWindow.webContents); + for (const entry of workspaceWindowRegistry.entries()) recipients.add(entry.window.webContents); let emitted = false; for (const contents of recipients) { if (!contents.isDestroyed()) { @@ -169,9 +165,147 @@ const titleBarOverlayOptions = ( height: TITLEBAR_OVERLAY_HEIGHT, }); +type WorkspaceWindowInput = + | { readonly kind: 'main' } + | Omit, 'windowId'>; + +export interface MainWindowController extends Omit { + readonly registry: typeof workspaceWindowRegistry; + createSessionWindow(context: Extract, signal: AbortSignal): Promise; + isMainRenderer(contents: Electron.WebContents): boolean; + isWorkspaceRenderer(contents: Electron.WebContents): boolean; + browserWindowFor(sender: Electron.WebContents): BrowserWindow | undefined; + send(channel: string, ...args: unknown[]): void; + sendCommand(channel: string, ...args: unknown[]): boolean; + showOpenDialog(options: Electron.OpenDialogOptions, sender?: Electron.WebContents): Promise; + showSaveDialog(options: Electron.SaveDialogOptions, sender?: Electron.WebContents): Promise; + getBrowserViews(parentForSession: (sessionId: string) => Electron.View | undefined): BrowserViewManager; + disposeBrowserViews(): Promise; +} + +/** Application-scoped service. Every workspace uses the same window implementation. */ export function createMainWindowController(deps: MainWindowControllerDeps): MainWindowController { - const { workspaceRoot, e2eFixture, settingsStore } = deps; + const main = createWorkspaceWindowController(deps, { kind: 'main' }); + let browserViews: BrowserViewManager | undefined; const liveBrowserScopes = new Map(); + function getBrowserViews( + parentForSession: (sessionId: string) => Electron.View | undefined, + ): BrowserViewManager { + if (!browserViews) { + browserViews = new BrowserViewManager({ + create: (sessionId) => { + const parent = parentForSession(sessionId); + if (!parent) throw new Error('Embedded browser used without an active renderer parent.'); + return new BrowserViewController(parent, sessionId, (sid, state) => { + const ref = parseDesktopSessionResourceKey(sid); + safeSendToRenderer( + 'browser:state', + { hostId: ref.hostId, targetEpoch: ref.targetEpoch }, + { sessionId: ref.sessionId, state }, + ); + }); + }, + onLiveChange: (sessionIds) => { + const groups = new Map[]>(); + for (const sessionId of sessionIds) { + const ref = parseDesktopSessionResourceKey(sessionId); + const key = JSON.stringify([ref.targetEpoch, ref.hostId]); + const group = groups.get(key) ?? []; + group.push(ref); + groups.set(key, group); + } + const keys = new Set([...liveBrowserScopes.keys(), ...groups.keys()]); + for (const key of keys) { + const group = groups.get(key) ?? []; + const first = group[0]; + const scope = first + ? { hostId: first.hostId, targetEpoch: first.targetEpoch } + : liveBrowserScopes.get(key); + if (!scope) continue; + safeSendToRenderer( + 'browser:live', + scope, + { sessionIds: group.map(({ sessionId }) => sessionId) }, + ); + if (first) liveBrowserScopes.set(key, scope); + else liveBrowserScopes.delete(key); + } + }, + }); + } + return browserViews; + } + + async function disposeBrowserViews(): Promise { + await browserViews?.disposeAll(); + } + + return { + ...main, + registry: workspaceWindowRegistry, + async createSessionWindow(context, signal) { + if (context.kind !== 'session') throw new Error('A session window requires session context'); + let controller: WorkspaceWindowController; + controller = createWorkspaceWindowController({ + ...deps, + onWindowConstructed: undefined, + onClose: undefined, + onRendererProcessGone: async () => { + // A failed isolated surface must not terminate the other windows or Host. + if (!await controller.reloadMainRenderer()) controller.browserWindow()?.destroy(); + }, + }, context); + try { + await controller.createWindow(signal); + if (!controller.browserWindow()) throw new Error('Session window creation was cancelled'); + return controller; + } catch (error) { + controller.browserWindow()?.destroy(); + throw error; + } + }, + isMainRenderer: (contents) => main.isRenderer(contents), + isWorkspaceRenderer: (contents) => workspaceWindowRegistry.forSender(contents) !== undefined, + browserWindowFor: (sender) => workspaceWindowRegistry.forSender(sender)?.window, + send: safeSendToRenderer, + sendCommand: (channel, ...args) => workspaceWindowRegistry.sendCommand(channel, ...args), + hasOpenWindows: () => workspaceWindowRegistry.entries().length > 0, + notifyRendererReady(sender, frame) { + workspaceWindowRegistry.forSender(sender)?.controller.notifyRendererReady(sender, frame); + }, + setTitlebarControlsVisible(sender, visible) { + workspaceWindowRegistry.forSender(sender)?.controller.setTitlebarControlsVisible(sender, visible); + }, + setThemeSource(sender, theme) { + workspaceWindowRegistry.forSender(sender)?.controller.setThemeSource(sender, theme); + }, + setTitleBarOverlayTheme(sender, theme) { + workspaceWindowRegistry.forSender(sender)?.controller.setTitleBarOverlayTheme(sender, theme); + }, + showOpenDialog(options, sender) { + const parent = sender ? workspaceWindowRegistry.forSender(sender)?.window : workspaceWindowRegistry.active()?.window; + if (sender && !parent) return Promise.resolve({ canceled: true, filePaths: [] }); + return parent ? dialog.showOpenDialog(parent, options) : dialog.showOpenDialog(options); + }, + showSaveDialog(options, sender) { + const parent = sender ? workspaceWindowRegistry.forSender(sender)?.window : workspaceWindowRegistry.active()?.window; + if (sender && !parent) return Promise.resolve({ canceled: true, filePath: '' }); + return parent ? dialog.showSaveDialog(parent, options) : dialog.showSaveDialog(options); + }, + getBrowserViews, + disposeBrowserViews, + isFocused: () => workspaceWindowRegistry.focused() !== undefined, + }; +} + +function createWorkspaceWindowController( + deps: MainWindowControllerDeps, + context: WorkspaceWindowInput, +): WorkspaceWindowController { + let mainWindow: BrowserWindow | null = null; + let creatingWindow: Promise | undefined; + let handoffReady = context.kind === 'main'; + const { workspaceRoot, e2eFixture, settingsStore } = deps; // PR-SHOW-AFTER-FIRST-COMMIT: windows launched hidden (`hidden` covers // e2e-fixture capture and E2E — see main.ts) must never be revealed; @@ -202,7 +336,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main }; const armShowFallbackTimer = (target: BrowserWindow): void => { clearShowFallbackTimer(); - if (revealMode === 'hidden' || target.isDestroyed() || target.isVisible()) return; + if (!handoffReady || revealMode === 'hidden' || target.isDestroyed() || target.isVisible()) return; showFallbackTimer = setTimeout(() => { showFallbackTimer = undefined; if (!target.isDestroyed()) revealGate.markReady(target); @@ -220,66 +354,23 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main void Promise.resolve() .then(() => deps.onRendererProcessGone(details)) .catch((error) => { - console.error('[renderer] failed to handle main Renderer process exit:', error); - app.quit(); + console.error('[renderer] failed to handle workspace Renderer process exit:', error); + if (!target.isDestroyed()) target.destroy(); }); }, }); }; - function getBrowserViews( - parentForSession: (sessionId: string) => Electron.View | undefined, - ): BrowserViewManager { - if (!browserViews) { - browserViews = new BrowserViewManager({ - create: (sessionId) => { - const parent = parentForSession(sessionId); - if (!parent) throw new Error('Embedded browser used without an active renderer parent.'); - return new BrowserViewController(parent, sessionId, (sid, state) => { - const ref = parseDesktopSessionResourceKey(sid); - safeSendToRenderer( - 'browser:state', - { hostId: ref.hostId, targetEpoch: ref.targetEpoch }, - { sessionId: ref.sessionId, state }, - ); - }); - }, - onLiveChange: (sessionIds) => { - const groups = new Map[]>(); - for (const sessionId of sessionIds) { - const ref = parseDesktopSessionResourceKey(sessionId); - const key = JSON.stringify([ref.targetEpoch, ref.hostId]); - const group = groups.get(key) ?? []; - group.push(ref); - groups.set(key, group); - } - const keys = new Set([...liveBrowserScopes.keys(), ...groups.keys()]); - for (const key of keys) { - const group = groups.get(key) ?? []; - const first = group[0]; - const scope = first - ? { hostId: first.hostId, targetEpoch: first.targetEpoch } - : liveBrowserScopes.get(key); - if (!scope) continue; - safeSendToRenderer( - 'browser:live', - scope, - { sessionIds: group.map(({ sessionId }) => sessionId) }, - ); - if (first) liveBrowserScopes.set(key, scope); - else liveBrowserScopes.delete(key); - } - }, - }); - } - return browserViews; - } - - async function disposeBrowserViews(): Promise { - await browserViews?.disposeAll(); + function createWindow(signal: AbortSignal): Promise { + if (creatingWindow) return creatingWindow; + if (mainWindow && !mainWindow.isDestroyed()) return Promise.resolve(); + const operation = createWindowNow(signal); + creatingWindow = operation; + void operation.finally(() => { if (creatingWindow === operation) creatingWindow = undefined; }).catch(() => undefined); + return operation; } - async function createWindow(signal: AbortSignal): Promise { + async function createWindowNow(signal: AbortSignal): Promise { if (signal.aborted) return; // Restore previously-saved bounds when available; first launch and // legacy installs both fall back to the default 1240x820 frame. After @@ -294,7 +385,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // persisted pref), which is why it cannot leave the critical path. const [, savedBounds, persistedAppearance] = await Promise.all([ mkdir(workspaceRoot, { recursive: true }), - e2eFixture ? Promise.resolve(defaults) : readSavedBounds(workspaceRoot, defaults), + e2eFixture || context.kind === 'session' ? Promise.resolve(defaults) : readSavedBounds(workspaceRoot, defaults), settingsStore.get().then((settings) => settings.appearance), ]); const bounds = clampBoundsToVisibleDisplay(savedBounds); @@ -425,6 +516,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main ); mainWindowShutdownSignal = signal; observeRendererProcess(mainWindow, signal); + workspaceWindowRegistry.register({ context: { ...context, windowId: String(mainWindow.id) }, window: mainWindow, controller }); deps.onWindowConstructed?.(); // The designed `.maka-preload` surface is the loading UI: reveal on the // first painted frame instead of waiting out the whole React mount. @@ -433,7 +525,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // as no-op backstops. mainWindow.once('ready-to-show', () => { clearShowFallbackTimer(); - revealGate.markReady(mainWindow); + if (handoffReady) revealGate.markReady(mainWindow); }); installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntry.url); @@ -493,7 +585,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // doesn't write the file on every frame; flush on close. let saveTimer: NodeJS.Timeout | undefined; const scheduleSave = () => { - if (!mainWindow) return; + if (!mainWindow || context.kind !== 'main') return; if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { if (!mainWindow) return; @@ -512,17 +604,18 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main mainWindow.on('move', scheduleSave); mainWindow.on('maximize', handleWindowGeometryChange); mainWindow.on('unmaximize', scheduleSave); - mainWindow.on('close', () => { + mainWindow.on('close', (event) => { + deps.onClose?.(event); + if (event.defaultPrevented) return; clearShowFallbackTimer(); - deps.onClose?.(); if (saveTimer) clearTimeout(saveTimer); - if (!mainWindow) return; + if (!mainWindow || context.kind !== 'main') return; const final: SavedBounds = mainWindow.isMaximized() ? { ...mainWindow.getNormalBounds(), isMaximized: true } : { ...mainWindow.getBounds(), isMaximized: false }; void writeSavedBounds(workspaceRoot, final); }); - mainWindow.once('closed', () => deps.onClosed?.()); + mainWindow.once('closed', () => { mainWindow = null; deps.onClosed?.(); }); // Dev-server cache hygiene (issue #4775) — see main-renderer-dev-cache.ts // for why a stale immutable dep-chunk graph must never survive into a new @@ -540,13 +633,17 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // remain hidden for their whole lifecycle. armShowFallbackTimer(mainWindow); if (process.env.MAKA_REAL_WINDOW_SMOKE === '1') { - emitRealWindowSmokeDiagnostic('after-load'); - setTimeout(() => emitRealWindowSmokeDiagnostic('settled-1000ms'), 1000); + emitRealWindowSmokeDiagnostic(mainWindow, 'after-load'); + setTimeout(() => emitRealWindowSmokeDiagnostic(mainWindow, 'settled-1000ms'), 1000); } } - return { + const controller: WorkspaceWindowController = { createWindow, + reveal() { + handoffReady = true; + if (mainWindow && !mainWindow.isDestroyed()) revealGate.markReady(mainWindow); + }, async reloadMainRenderer() { const target = mainWindow; const signal = mainWindowShutdownSignal; @@ -558,6 +655,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main signal.aborted ) return false; clearShowFallbackTimer(); + if (context.kind === 'session') handoffReady = false; revealGate.reset(); target.hide(); const contents = target.webContents; @@ -609,8 +707,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main } } }, - send: safeSendToRenderer, - isMainRenderer: (contents) => !!mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents === contents, + isRenderer: (contents) => !!mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents === contents, notifyRendererReady(sender, senderFrame) { if (!mainWindow || mainWindow.isDestroyed() || sender !== mainWindow.webContents) return; const recovery = rendererRecoveryReadiness; @@ -626,7 +723,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // e2e-fixture windows. markReady also flushes a focus request that // arrived while the window was still hidden (second-instance/activate). clearShowFallbackTimer(); - revealGate.markReady(mainWindow); + if (handoffReady) revealGate.markReady(mainWindow); }, setTitlebarControlsVisible(sender, visible) { const target = BrowserWindow.fromWebContents(sender); @@ -649,18 +746,6 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main if (!isTitleBarOverlayTheme(theme)) return; mainWindow.setTitleBarOverlay(titleBarOverlayOptions(theme.isDark, theme.backgroundColor)); }, - showOpenDialog(options) { - return mainWindow - ? dialog.showOpenDialog(mainWindow, options) - : dialog.showOpenDialog(options); - }, - showSaveDialog(options) { - return mainWindow - ? dialog.showSaveDialog(mainWindow, options) - : dialog.showSaveDialog(options); - }, - getBrowserViews, - disposeBrowserViews, hasOpenWindows() { return mainWindow !== null && !mainWindow.isDestroyed(); }, @@ -706,6 +791,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main return !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused(); }, }; + return controller; } function rendererFrameIdentity(frame: Electron.WebFrameMain): MainRendererFrameIdentity { @@ -767,8 +853,7 @@ function e2eFixtureWindowBounds( return defaults; } -function emitRealWindowSmokeDiagnostic(stage: string): void { - const target = mainWindow; +function emitRealWindowSmokeDiagnostic(target: BrowserWindow | null, stage: string): void { if (!target) { console.log(`[real-window-smoke] diagnostic ${JSON.stringify({ stage, windowExists: false })}`); return; diff --git a/apps/desktop/src/main/notifications-main.ts b/apps/desktop/src/main/notifications-main.ts index f0a4f622ac3..a057a638a66 100644 --- a/apps/desktop/src/main/notifications-main.ts +++ b/apps/desktop/src/main/notifications-main.ts @@ -35,6 +35,8 @@ interface NotificationsDeps { settingsStore: { get(): Promise }; locale: Pick; mainWindowController: MainWindowController; + focusSession: (input: RunNotificationEvent) => void; + isSessionFocused: (input: RunNotificationEvent) => boolean; e2e: boolean; } @@ -47,7 +49,7 @@ export function createRunNotifier(deps: NotificationsDeps): (input: RunNotificat const gate = { enabled: settings.notifications.runComplete, supported, - windowFocused: deps.mainWindowController.isFocused(), + windowFocused: deps.isSessionFocused(input), e2e: deps.e2e, }; if (!shouldRaiseRunNotification(gate)) return; @@ -56,7 +58,7 @@ export function createRunNotifier(deps: NotificationsDeps): (input: RunNotificat input, deps.locale.observe(settings), ); - showNativeNotification(copy, () => deps.mainWindowController.focus()); + showNativeNotification(copy, () => deps.focusSession(input)); app.dock?.bounce('informational'); }); } diff --git a/apps/desktop/src/main/pet-pack-import.ts b/apps/desktop/src/main/pet-pack-import.ts index 4657e400a05..242e0b680bd 100644 --- a/apps/desktop/src/main/pet-pack-import.ts +++ b/apps/desktop/src/main/pet-pack-import.ts @@ -208,11 +208,11 @@ export function registerPetPackIpc(input: { } }, ); - input.ipcMain.handle('pets:importLocalDirectory', async (): Promise => { + input.ipcMain.handle('pets:importLocalDirectory', async (event): Promise => { const selection = await input.mainWindowController.showOpenDialog({ title: nativeFileDialogCopy(await input.resolveLocale()).importCustomPet, properties: ['openDirectory'], - }); + }, event.sender); if (selection.canceled || selection.filePaths.length === 0) { return { ok: false, reason: 'cancelled' }; } diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 2f52baf456e..d2b479003a5 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -163,7 +163,7 @@ export function registerRuntimeHostArtifactsIpc( const result = await deps.mainWindowController.showSaveDialog({ title: ARTIFACT_DIALOG_COPY[deps.uiLocale()].saveAs(artifact.name), defaultPath: artifact.name, - }); + }, _event.sender); if (result.canceled || !result.filePath) { return { ok: false, reason: "canceled" }; } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 37c11596fb0..de712002d78 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -22,6 +22,9 @@ import { app, autoUpdater as nativeAutoUpdater, type BrowserWindow, + type WebContents, + View, + dialog, clipboard, ipcMain, Menu, @@ -57,6 +60,7 @@ import { loadOrCreateRuntimeHostClientInstanceId, listRuntimeHostWslDistributions, runtimeHostProfileAccess, + projectSessionCatalogSummary, RuntimeHostProfileConnectionError, } from "@maka/runtime-host/client"; import { @@ -274,11 +278,18 @@ import { } from "./startup-context.js"; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; import { + desktopSessionKey, + parseDesktopSessionKey, parseDesktopSessionResourceKey, requireDesktopTargetScope, type DesktopTargetScope, } from "../shared/runtime-host-identity.js"; +import { SessionWindowGroups } from './session-window-group.js'; +import { SessionWorkspaceStateStore } from './session-workspace-state-store.js'; +import { createSessionWindowCoordinator, registerSessionWindowIpc } from './session-window-coordinator.js'; +import type { SessionWorkspaceSnapshot } from '../shared/session-workspace-state.js'; + const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; bootContext.prepareToQuit = prepareRuntimeHostDesktopQuit; bootContext.cleanup = closeRuntimeHostDesktop; @@ -417,7 +428,10 @@ const native = assembleDesktopNativeCapabilities({ revealMode, locale: desktopLocale, keepSystemAwake, - mainWindow: mainWindowController, + sessionWindow: (id) => { + const owner = sessionWindows.ownerForSession(desktopSessionKey(parseDesktopSessionResourceKey(id))); + return owner ? mainWindowController.registry.forSender(owner)?.controller : mainWindowController.registry.main()?.controller; + }, }); const riveWorkflowTool = buildRiveWorkflowTool(); const completeDesktopInteractionTurn = (sessionId: string): void => { @@ -440,10 +454,6 @@ const permissionOverlay = createPermissionOverlayMain({ resolveLocale: () => desktopLocale.resolve(), revealMode, }); -mainWindowDelegates.onMainWindowClose = () => { - native.computerUseOverlay.destroyAll(); - native.computerUsePip.destroyAll(); -}; const attachmentApprovals = createAttachmentApprovalRegistry(); /** * Dedicated task directories live in the user-owned `~/Maka/tasks` root — @@ -482,6 +492,57 @@ const sessionLocal = new DesktopSessionLocalService(sessionLocalStore, { changed: localSessionChanged, onError: (error) => console.error('[session-local] background synchronization failed:', error), }); +const sessionWorkspaceStore = new SessionWorkspaceStateStore(join(userDataDir, 'session-workspaces.sqlite'), { + approvals: attachmentApprovals, recovery: sessionLocal.attachmentRecovery, +}); +const sessionWindowGroups = new SessionWindowGroups(); +const sessionWindowLifetime = new AbortController(); + +function workspaceOwner(sender: Pick, hostId: string) { + const state = runtimeHostManager?.entries().find((entry) => entry.hostId === hostId); + if (!state || (state.readiness === 'unavailable' && state.error instanceof RuntimeHostProfileConnectionError && state.error.reason === 'credential_rejected')) { + throw new Error('Session Host authority is unavailable'); + } + return { + senderId: sender.id, + partition: desktopSessionLocalPartition({ profileId: state.target.profile.id, hostId, incarnation: state.target.profileIncarnationId, credential: state.target.credential }), + scope: { hostId, targetEpoch: state.epoch }, + }; +} +async function saveWorkspaceSnapshot(sender: WebContents, snapshot: SessionWorkspaceSnapshot): Promise { + const keys = [ + ...snapshot.drafts.map((draft) => draft.sessionId), + ...snapshot.sideConversations.map((side) => side.panel.sourceSessionId), + ...Object.keys(snapshot.workbar ?? {}), ...Object.keys(snapshot.readingAnchors ?? {}), + ]; + const hosts = new Set(keys.map((key) => parseDesktopSessionKey(key).hostId)); + for (const hostId of hosts) { + const belongs = (key: string) => parseDesktopSessionKey(key).hostId === hostId; + await sessionWorkspaceStore.save(workspaceOwner(sender, hostId), { + capturedAt: snapshot.capturedAt, + drafts: snapshot.drafts.filter((draft) => belongs(draft.sessionId)), + sideConversations: snapshot.sideConversations.filter((side) => belongs(side.panel.sourceSessionId)), + workbar: Object.fromEntries(Object.entries(snapshot.workbar ?? {}).filter(([key]) => belongs(key))), + readingAnchors: Object.fromEntries(Object.entries(snapshot.readingAnchors ?? {}).filter(([key]) => belongs(key))), + }); + } +} +async function loadWorkspaceSnapshot(sender: WebContents, sessionIds: readonly string[]): Promise { + const hosts = new Set(sessionIds.map((key) => parseDesktopSessionKey(key).hostId)); + const snapshots: SessionWorkspaceSnapshot[] = []; + try { + for (const hostId of hosts) snapshots.push(sessionWorkspaceStore.load(workspaceOwner(sender, hostId), sessionIds.filter((key) => parseDesktopSessionKey(key).hostId === hostId))); + } catch (error) { + for (const snapshot of snapshots) sessionWorkspaceStore.releaseSnapshot(sender.id, snapshot); + throw error; + } + return { + capturedAt: Date.now(), drafts: snapshots.flatMap((snapshot) => snapshot.drafts), + sideConversations: snapshots.flatMap((snapshot) => snapshot.sideConversations), + workbar: Object.assign({}, ...snapshots.map((snapshot) => snapshot.workbar)), + readingAnchors: Object.assign({}, ...snapshots.map((snapshot) => snapshot.readingAnchors)), + }; +} registerDesktopSessionLocalIpc({ ipcMain, service: sessionLocal, approvals: attachmentApprovals, resizeImage: resizeImageForAttachment, changed: localSessionChanged, @@ -808,7 +869,47 @@ const browserIpc = registerBrowserIpc({ mainWindowController, auxiliaryWindowRegistry, isHostActive: (scope) => runtimeHostManager?.ownsScope(scope) === true, + canUseSession: (sender, id) => sessionWindows.canUseSession(sender, id), + parkingParent: new View(), }); +const sessionWindows = createSessionWindowCoordinator({ + windows: mainWindowController, signal: sessionWindowLifetime.signal, + ensureMainWindow: () => quitCoordinator.focusOrCreateWindow(), + isQuitting: () => quitCoordinator.isQuitting(), + resolveGroup: async (id) => { + const { hostId } = parseDesktopSessionKey(id); + const state = runtimeHostManager?.entries().find((entry) => entry.hostId === hostId); + if (!state) throw new Error('Session Host is unavailable'); + const cached = sessionLocal.catalog().find((entry) => entry.scope.hostId === hostId)?.sessions ?? []; + const sessions = state.readiness === 'ready' + ? [...cached, ...(await state.candidate.client.listSessions()).map((session) => projectSessionCatalogSummary(session))] + : cached; + return sessionWindowGroups.resolve(id, sessions); + }, + saveSnapshot: saveWorkspaceSnapshot, + loadSnapshot: loadWorkspaceSnapshot, + releaseSnapshot: (sender, snapshot) => sessionWorkspaceStore.releaseSnapshot(sender.id, snapshot), + transferResources: async ({ group, source, target }) => { + browserIpc.transferSessions(group.sessionIds, source, target); + native.computerUsePip.refreshOwner(); + }, + releaseResources: (sender) => { + sessionLocal.attachmentRecovery.releaseSender(sender.id); + browserIpc.releaseRenderer(sender); + const sessionId = native.computerUsePip.currentSessionId(); + if (sessionId) { + const owner = sessionWindows.ownerForSession(desktopSessionKey(parseDesktopSessionResourceKey(sessionId))); + if (owner === sender || (!owner && mainWindowController.isMainRenderer(sender))) native.computerUsePip.clearForSession(sessionId); + } + }, + onError: (error, sender) => { + console.error('[session-window]', error); + const options = { type: 'error' as const, title: 'Maka', message: error instanceof Error ? error.message : String(error) }; + const window = sender && mainWindowController.browserWindowFor(sender); + void (window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)); + }, +}); +registerSessionWindowIpc(sessionWindows, ipcMain); let workHubEnabled = false; const workHubPresentation = createWorkHubPresentation({ isEnabled: () => workHubEnabled, @@ -843,10 +944,10 @@ const windowsAppTray = createWindowsAppTray({ quit: () => app.quit(), onError: (error) => console.error('[tray]', error), }); -mainWindowDelegates.onMainWindowClosed = () => { +mainWindowDelegates.onWorkspaceWindowClosed = () => { // A hidden WorkHub host window can keep window-all-closed from firing. // Without a tray, use the existing quit flow; cancelling it restores Maka. - if (process.platform !== 'darwin' && !windowsAppTray.hasTray()) app.quit(); + if (!mainWindowController.hasOpenWindows() && process.platform !== 'darwin' && !windowsAppTray.hasTray()) app.quit(); }; const mcpCapabilityPublisher = createCapabilityRevisionPublisher(() => mcpManager.toolSnapshot().revision, @@ -1014,6 +1115,16 @@ const notifyRun = createRunNotifier({ locale: desktopLocale, mainWindowController, e2e: isE2e, + focusSession: (input) => { + const state = runtimeHostManager?.entries().find((entry) => entry.readiness === 'ready' && entry.candidate.client.hostEpoch === input.hostEpoch); + if (state) void sessionWindows.focusSession(desktopSessionKey({ hostId: state.hostId, sessionId: input.sessionId })).catch(console.error); + }, + isSessionFocused: (input) => { + const state = runtimeHostManager?.entries().find((entry) => entry.readiness === 'ready' && entry.candidate.client.hostEpoch === input.hostEpoch); + if (!state) return false; + const owner = sessionWindows.ownerForSession(desktopSessionKey({ hostId: state.hostId, sessionId: input.sessionId })); + return (owner ? mainWindowController.browserWindowFor(owner) : mainWindowController.browserWindow())?.isFocused() ?? false; + }, }); const sessionCopyOwnerProcessId = randomUUID(); @@ -1052,6 +1163,16 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( ipcMain, workspaceRoot, attachmentApprovals, + recoverAttachmentItems: (senderId, scope, sessionId, items) => { + const owner = workspaceOwner({ id: senderId }, scope.hostId); + if (owner.scope.targetEpoch !== scope.targetEpoch) throw new Error('Attachment Host authority changed'); + return sessionLocal.attachmentRecovery.prepare({ ...owner, sessionId }, items); + }, + transferRecoveryAttachments: (senderId, scope, sourceSessionId, targetSessionId, ids) => { + const owner = workspaceOwner({ id: senderId }, scope.hostId); + if (owner.scope.targetEpoch !== scope.targetEpoch) throw new Error('Attachment Host authority changed'); + sessionLocal.attachmentRecovery.transfer({ ...owner, sessionId: sourceSessionId }, targetSessionId, ids); + }, stat: (path) => import("node:fs/promises").then(({ stat }) => stat(path)), resizeImage: resizeImageForAttachment, mainWindowController, @@ -1200,6 +1321,10 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( ...(e2eFixture?.scenario === "chat-partial-history" ? { transcriptHistoryBytes: PARTIAL_HISTORY_TRANSCRIPT_BYTES } : {}), + canUseSession: (senderId, scope, sessionId) => { + const sender = mainWindowController.registry.entries().find((entry) => entry.window.webContents.id === senderId)?.window.webContents; + return !!sender && sessionWindows.canUseSession(sender, desktopSessionKey({ ...scope, sessionId })); + }, retireCancelledMessages: (scope, sessionId, messageIds) => sessionLocal.retireCancelledMessages(scope, sessionId, messageIds), failNotAdmittedMessages: (scope, sessionId, messageIds) => @@ -1237,7 +1362,7 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( if (localTarget) { sessionLocal.connectionChanged(localTarget); sessionLocalStore.bindAuthority(localTarget.profileId, localTarget.partition); - if (state.readiness === 'unavailable' && state.error instanceof RuntimeHostProfileConnectionError && state.error.reason === 'credential_rejected') sessionLocal.purge(localTarget); + if (state.readiness === 'unavailable' && state.error instanceof RuntimeHostProfileConnectionError && state.error.reason === 'credential_rejected') { sessionLocal.purge(localTarget); sessionWorkspaceStore.clear(localTarget.partition); sessionWindowGroups.forgetHost(state.hostId); } } sessionLocal.wake(); const event = runtimeHostTargetEvent(state); @@ -1281,7 +1406,8 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( }, onTargetRemoved: (state) => { const localTarget = localSessionTarget(state); - if (localTarget) sessionLocal.purge(localTarget); + if (localTarget) { sessionLocal.purge(localTarget); sessionWorkspaceStore.clear(localTarget.partition); } + sessionWindowGroups.forgetHost(state.hostId); mainWindowController.send("runtime-host-profiles:changed", { ...runtimeHostTargetEvent(state), readiness: "unavailable", @@ -2020,7 +2146,7 @@ function registerPersistentClientIpc(): void { }), ); registerDesktopDiagnosticsIpc({ ipcMain, ...desktopDiagnostics }); - ipcMain.handle('directories:pick', async () => { + ipcMain.handle('directories:pick', async (event) => { const local = runtimeHostManager?.entries().find( (state) => state.target.profile.kind === 'local', ); @@ -2029,7 +2155,7 @@ function registerPersistentClientIpc(): void { const result = await mainWindowController.showOpenDialog({ title: nativeFileDialogCopy(await desktopLocale.resolve()).referenceFolder, properties: ['openDirectory'], - }); + }, event.sender); if (result.canceled || !result.filePaths[0]) return { ok: false, reason: 'cancelled' }; return { ok: true, reference: { hostId, path: result.filePaths[0] } }; }); @@ -2037,7 +2163,7 @@ function registerPersistentClientIpc(): void { const result = await mainWindowController.showOpenDialog({ title: nativeFileDialogCopy(await desktopLocale.resolve()).addAttachments, properties: ["openFile", "multiSelections"], - }); + }, event.sender); if (result.canceled || !result.filePaths[0]) return { ok: false, reason: "cancelled" }; const { stat } = await import("node:fs/promises"); @@ -2054,6 +2180,11 @@ function registerPersistentClientIpc(): void { registerAttachmentPreviewIpc({ ipcMain, approvals: attachmentApprovals, + recovery: sessionLocal.attachmentRecovery, + authorizeRecovery: (owner) => { + try { return sessionLocal.target(owner.scope).partition === owner.partition; } + catch { return false; } + }, readFile: readFileCapped, renderPreview: renderAttachmentPreview, }); @@ -2109,7 +2240,11 @@ function emitSessionsChanged( ): void { sessionLocal.changed(scope); if (reason === 'deleted' && sessionId) { - try { sessionLocalStore.removeSession(sessionLocal.target(scope).partition, sessionId); } catch { /* A retired target cannot repopulate its cache. */ } + try { + const partition = sessionLocal.target(scope).partition; + sessionLocalStore.removeSession(partition, sessionId); + sessionWorkspaceStore.delete(partition, [desktopSessionKey({ ...scope, sessionId })]); + } catch { /* A retired target cannot repopulate its cache. */ } } const event: SessionChangedEvent = { reason, @@ -2118,6 +2253,9 @@ function emitSessionsChanged( ...(extra?.modelId ? { modelId: extra.modelId } : {}), ...(extra?.turnId ? { turnId: extra.turnId } : {}), }; + if (sessionId && reason !== 'deleted') { + void sessionWindows.refreshGroup(desktopSessionKey({ ...scope, sessionId })).catch(() => undefined); + } mainWindowController.send("sessions:changed", scope, event); } @@ -2156,16 +2294,25 @@ function wireLifecycle(): void { } async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> { - const preparation = await prepareRuntimeHostQuit(runtimeHostManager, { - confirmInterrupt: async () => { - const locale = await desktopLocale.resolve(); - const dialog = buildRuntimeHostActiveQuitDialog(locale); - const { response } = await showDesktopMessageBox(dialog.options, { locale }); - return dialog.decisions[response] === 'quit'; - }, - }); - if (preparation === 'ready') mainWindowController.browserWindow()?.destroy(); - return preparation; + // Save drafts while Host admission is still available. Cancelling quit must + // return every renderer to an editable workspace. + await sessionWindows.flushAll(); + try { + const preparation = await prepareRuntimeHostQuit(runtimeHostManager, { + confirmInterrupt: async () => { + const locale = await desktopLocale.resolve(); + const dialog = buildRuntimeHostActiveQuitDialog(locale); + const { response } = await showDesktopMessageBox(dialog.options, { locale }); + return dialog.decisions[response] === 'quit'; + }, + }); + if (preparation === 'cancelled') await sessionWindows.resumeAll(); + else for (const entry of mainWindowController.registry.entries()) entry.window.destroy(); + return preparation; + } catch (error) { + await sessionWindows.resumeAll(); + throw error; + } } function closeRuntimeHostDesktop(): Promise { @@ -2173,6 +2320,9 @@ function closeRuntimeHostDesktop(): Promise { } async function disposeRuntimeHostDesktop(): Promise { + sessionWindowLifetime.abort(); + sessionWindows.dispose(); + sessionWorkspaceStore.close(); sessionLocal.close(); powerMonitor.off("resume", wakePeerRecoveryAfterResume); clientSettingsWatcher.stop(); diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 4f6f39160f9..99ade475a94 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -105,7 +105,7 @@ export function registerRuntimeHostConfigIpc( title: CONFIG_DIALOG_COPY[deps.uiLocale()].exportTitle, defaultPath: `maka-config-${today}.json`, filters: [{ name: 'Maka Config', extensions: ['json'] }], - }); + }, _event.sender); if (result.canceled || !result.filePath) { return { ok: false as const, reason: 'canceled' as const }; } @@ -134,7 +134,7 @@ export function registerRuntimeHostConfigIpc( title: CONFIG_DIALOG_COPY[deps.uiLocale()].importTitle, properties: ['openFile'], filters: [{ name: 'Maka Config', extensions: ['json'] }], - }); + }, _event.sender); const filePath = result.filePaths?.[0]; if (result.canceled || !filePath) { return { ok: false as const, reason: 'canceled' as const }; diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 47202ab2170..d9af29240ff 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -129,6 +129,7 @@ import { type CandidateIpcMain = ReconnectableReadIpcMain & Pick; export interface DesktopRuntimeHostCandidateDeps { + readonly canUseSession?: (senderId: number, scope: DesktopTargetScope, sessionId: string) => boolean; readonly terminalCloses?: import('./terminal-close-intents.js').TerminalCloseIntents; readonly retireCancelledMessages?: (scope: DesktopTargetScope, sessionId: string, messageIds: readonly string[]) => void; readonly failNotAdmittedMessages?: (scope: DesktopTargetScope, sessionId: string, messageIds: readonly string[]) => void; @@ -136,6 +137,8 @@ export interface DesktopRuntimeHostCandidateDeps { readonly ipcMain: RuntimeHostTargetIpcMain; readonly workspaceRoot: string; readonly attachmentApprovals: AttachmentApprovalRegistry; + readonly recoverAttachmentItems?: (senderId: number, scope: DesktopTargetScope, sessionId: string, items: unknown) => { items: unknown[]; validate(): void; dispose(): void }; + readonly transferRecoveryAttachments?: (senderId: number, scope: DesktopTargetScope, sourceSessionId: string, targetSessionId: string, ids: unknown) => void; readonly stat: (path: string) => Promise<{ size: number }>; readonly resizeImage: (bytes: Uint8Array) => Promise; /** Native file dialogs for moving a Session in or out as a bundle. */ @@ -780,6 +783,7 @@ export async function createDesktopRuntimeHostCandidate( { client, terminalCloses, + canUseSession: deps.canUseSession ? (sender, id) => deps.canUseSession!(sender.id, scope, id) : undefined, sessionObserver, emitModeChanged, ...(deps.renderer ? { sendToRenderer } : {}), @@ -994,6 +998,8 @@ export async function createDesktopRuntimeHostCandidate( observer: sessionObserver, retireCancelledMessages, attachmentApprovals: deps.attachmentApprovals, + ...(deps.recoverAttachmentItems ? { recoverAttachmentItems: (senderId: number, sessionId: string, items: unknown) => deps.recoverAttachmentItems!(senderId, scope, sessionId, items) } : {}), + ...(deps.transferRecoveryAttachments ? { transferRecoveryAttachments: (senderId: number, source: string, target: string, ids: unknown) => deps.transferRecoveryAttachments!(senderId, scope, source, target, ids) } : {}), emitSessionsChanged, stat: deps.stat, resizeImage: deps.resizeImage, diff --git a/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts index 0dd32d8922a..fe91f22153b 100644 --- a/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-bundle-ipc-main.ts @@ -18,7 +18,7 @@ */ import { createHash } from 'node:crypto'; -import type { IpcMain } from 'electron'; +import type { IpcMain, WebContents } from 'electron'; import type { SessionChangedReason } from '@maka/core/session'; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import type { @@ -53,12 +53,12 @@ interface DialogController { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }>; - }): Promise<{ canceled: boolean; filePath?: string }>; + }, sender?: WebContents): Promise<{ canceled: boolean; filePath?: string }>; showOpenDialog(options: { title?: string; properties?: string[]; filters?: Array<{ name: string; extensions: string[] }>; - }): Promise<{ canceled: boolean; filePaths: string[] }>; + }, sender?: WebContents): Promise<{ canceled: boolean; filePaths: string[] }>; } export interface RuntimeHostSessionBundleIpcDeps { @@ -84,7 +84,7 @@ export function registerRuntimeHostSessionBundleIpc( const picked = await deps.mainWindowController.showSaveDialog({ defaultPath: `${bundleFileName(suggestedName)}.${BUNDLE_EXTENSION}`, filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], - }); + }, _event.sender); if (picked.canceled || !picked.filePath) { return { ok: false, reason: 'canceled' } satisfies SessionBundleExportIpcResult; } @@ -109,11 +109,11 @@ export function registerRuntimeHostSessionBundleIpc( } }); - ipcMain.handle('session-bundle:import', async () => { + ipcMain.handle('session-bundle:import', async (_event) => { const picked = await deps.mainWindowController.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'Maka session', extensions: [BUNDLE_EXTENSION] }], - }); + }, _event.sender); const source = picked.filePaths[0]; if (picked.canceled || !source) { return { ok: false, reason: 'canceled' } satisfies SessionBundleImportIpcResult; diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index f43fbe52527..dd7f9e5bcf2 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -76,6 +76,7 @@ type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & export interface RuntimeHostSessionDomainsIpcDeps { terminalCloses: import('./terminal-close-intents.js').TerminalCloseIntents; + canUseSession?: (target: import('./runtime-host-session-observer.js').RuntimeHostSessionObserverTarget, sessionId: string) => boolean; client: RuntimeHostSessionDomainClient; emitModeChanged(sessionId: string): void; sessionObserver: Pick; @@ -106,7 +107,7 @@ export function registerRuntimeHostSessionDomainsIpc( const now = deps.now ?? Date.now; const shellRuns = registerRuntimeHostShellRunsIpc( { client: deps.client, newId, sessionObserver: deps.sessionObserver, - terminalCloses: deps.terminalCloses }, + terminalCloses: deps.terminalCloses, canUseSession: deps.canUseSession }, ipcMain, ); const shellRunQueries = registerRuntimeHostShellRunQueriesIpc( diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index eeb24552100..d5ed24d69c8 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -170,6 +170,8 @@ export interface RuntimeHostSessionExecutionIpcDeps { client: RuntimeHostSessionExecutionClient; observer: RuntimeHostSessionObserver; attachmentApprovals: AttachmentApprovalRegistry; + recoverAttachmentItems?: (senderId: number, sessionId: string, items: unknown) => { items: unknown[]; validate(): void; dispose(): void }; + transferRecoveryAttachments?: (senderId: number, sourceSessionId: string, targetSessionId: string, ids: unknown) => void; emitSessionsChanged: ( reason: SessionChangedReason, sessionId?: string, @@ -198,33 +200,41 @@ type MessageAttachmentResult = | { readonly ok: false; readonly reason: 'attachment_blocked'; readonly code: AttachmentIngestBlockedCode }; async function prepareMessageAttachments(input: { - deps: Pick; + deps: Pick; getSenderId: () => number; sessionId: string; retainedAttachments: readonly AttachmentRef[]; attachmentItems: unknown; }): Promise { const attachments = retainedAttachmentsForSession(input.sessionId, input.retainedAttachments); + let recovery: ReturnType> | undefined; try { if (input.attachmentItems !== undefined) { + recovery = input.deps.recoverAttachmentItems?.(input.getSenderId(), input.sessionId, input.attachmentItems); const files = await resolveIngestItems({ senderId: input.getSenderId(), - items: input.attachmentItems, + items: recovery?.items ?? input.attachmentItems, approvals: input.deps.attachmentApprovals, stat: input.deps.stat, }); + recovery?.validate(); attachments.push(...await resolveAttachmentRefs({ files, resizeImage: input.deps.resizeImage, snapshot: ({ name, mimeType, content }) => input.deps.client.ingestAttachment({ sessionId: input.sessionId, name, mimeType, content }), })); + recovery?.validate(); } } catch (error) { if (error instanceof AttachmentIngestBlockedError) { return { ok: false, reason: 'attachment_blocked', code: error.code }; } throw error; + } finally { + // The renderer retains a failed send's draft and releases its approvals on + // success. Only hold the byte snapshot while it is being ingested here. + recovery?.dispose(); } return attachments.length > MAX_ATTACHMENT_COUNT ? { ok: false, reason: 'attachment_blocked', code: 'count_limit' } @@ -798,6 +808,11 @@ export function registerRuntimeHostSessionExecutionIpc( "sessions:branchFromTurn", async (event, sessionId: string, input: unknown) => { const normalized = normalizeRuntimeHostBranchFromTurnInput(input); + const recoveryIds = input && typeof input === 'object' && 'recoveryAttachmentIds' in input + ? input.recoveryAttachmentIds : undefined; + if (recoveryIds !== undefined && (!normalized.sideConversation || !Array.isArray(recoveryIds) || + recoveryIds.length > MAX_ATTACHMENT_COUNT || recoveryIds.some((id) => typeof id !== 'string' || !id.startsWith('local-recovery:')))) + throw new Error('Invalid side conversation recovery attachments'); const createBranch = () => deps.client.copySession("branch", { sourceSessionId: sessionId, @@ -838,6 +853,10 @@ export function registerRuntimeHostSessionExecutionIpc( } throw error; } + if (Array.isArray(recoveryIds) && recoveryIds.length > 0) { + if (!deps.transferRecoveryAttachments) throw new Error('Attachment recovery is unavailable'); + deps.transferRecoveryAttachments(event.sender.id, sessionId, branch.id, recoveryIds); + } if (normalized.name) { branch = await deps.client.updateSessionMetadata(branch.id, { name: normalized.name, diff --git a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts index d3dfe318baf..d8093637593 100644 --- a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts @@ -78,6 +78,7 @@ export function registerRuntimeHostShellRunsIpc( client: RuntimeHostShellRunsClient; terminalCloses: TerminalCloseIntents; newId?: () => string; + canUseSession?: (target: RuntimeHostSessionObserverTarget, sessionId: string) => boolean; sessionObserver: { observe( sessionId: string, @@ -97,9 +98,11 @@ export function registerRuntimeHostShellRunsIpc( deps.client, newId, deps.sessionObserver, + deps.canUseSession, ); - ipcMain.handle('shell-runs:start', async (_event, sessionId: unknown) => { + ipcMain.handle('shell-runs:start', async (event, sessionId: unknown) => { const normalizedSessionId = requiredId(sessionId, 'Session'); + controllers.assertOwner(event.sender as RuntimeHostSessionObserverTarget, normalizedSessionId); const launchId = `${DESKTOP_TERMINAL_LAUNCH_PREFIX}${newId()}`; const started = await deps.client.startRuntimeResource({ sessionId: normalizedSessionId, @@ -120,19 +123,20 @@ export function registerRuntimeHostShellRunsIpc( event.sender as RuntimeHostSessionObserverTarget, ), ); - ipcMain.handle('shell-runs:detach', (_event, value: unknown) => - controllers.detach(runtimeResourceIdentity(value, 'detach')), + ipcMain.handle('shell-runs:detach', (event, value: unknown) => + controllers.detach(runtimeResourceIdentity(value, 'detach'), event.sender as RuntimeHostSessionObserverTarget), ); - ipcMain.handle('shell-runs:write', (_event, value: unknown) => - controllers.control(runtimeResourceControl(value)), + ipcMain.handle('shell-runs:write', (event, value: unknown) => + controllers.control(runtimeResourceControl(value), event.sender as RuntimeHostSessionObserverTarget), ); handleReconnectableRead(ipcMain, 'shell-runs:recover', (_event, sessionId: unknown) => { const id = requiredId(sessionId, 'Session'); return closes.recover(id, () => deps.client.listRuntimeResources(id)); }); - ipcMain.handle('shell-runs:stop', (_event, value: unknown) => { + ipcMain.handle('shell-runs:stop', (event, value: unknown) => { const input = runtimeResourceIdentity(value, 'stop'); - return closes.stop(input, () => controllers.stop(input)); + controllers.assertOwner(event.sender as RuntimeHostSessionObserverTarget, input.sessionId); + return closes.stop(input, () => controllers.stop(input, event.sender as RuntimeHostSessionObserverTarget)); }); return { close: () => controllers.close() }; @@ -171,6 +175,7 @@ interface RuntimeResourceControllerState { readonly controllerId: string; readonly observerId: string; nextSequence: number; + target?: RuntimeHostSessionObserverTarget; } class RuntimeResourceControllers { @@ -202,6 +207,7 @@ class RuntimeResourceControllers { ): Promise; unobserve(observerId: string): Promise; }, + private readonly canUseSession?: (target: RuntimeHostSessionObserverTarget, sessionId: string) => boolean, ) { this.#client = client; this.#newId = newId; @@ -213,20 +219,32 @@ class RuntimeResourceControllers { target: RuntimeHostSessionObserverTarget, ): Promise { return this.#run(input, async () => { + this.assertOwner(target, input.sessionId); const state = this.#state(input); + if (state.target && state.target !== target) await this.#releaseObservation(state); + state.target = target; await this.#sessionObserver.observe(input.sessionId, state.observerId, target, false, input.ref); return this.#acquire(input, state); }); } - control(input: RuntimeResourceControl) { + assertOwner(target: RuntimeHostSessionObserverTarget, sessionId: string): void { + if (this.canUseSession && !this.canUseSession(target, sessionId)) { + throw new Error('Session is displayed in another window'); + } + } + + control(input: RuntimeResourceControl, target: RuntimeHostSessionObserverTarget) { return this.#run(input, async () => { + this.assertOwner(target, input.sessionId); let state = this.#states.get(resourceIdentity(input)); if (!state) { state = this.#state(input); await this.#acquire(input, state); } if (!state) throw new Error('Runtime Resource controller was not acquired'); + if (state.target && state.target !== target) throw new Error('Terminal belongs to another window'); + state.target = target; const sequence = state.nextSequence; await this.#client.controlRuntimeResource({ sessionId: input.sessionId, @@ -239,10 +257,10 @@ class RuntimeResourceControllers { }); } - detach(input: RuntimeResourceIdentity): Promise { + detach(input: RuntimeResourceIdentity, target: RuntimeHostSessionObserverTarget): Promise { return this.#run(input, async () => { const state = this.#states.get(resourceIdentity(input)); - if (!state) return; + if (!state || state.target !== target) return; await this.#client.releaseRuntimeResourceController({ ...input, controllerId: state.controllerId, @@ -252,8 +270,9 @@ class RuntimeResourceControllers { }); } - stop(input: RuntimeResourceIdentity): Promise { + stop(input: RuntimeResourceIdentity, target: RuntimeHostSessionObserverTarget): Promise { return this.#run(input, async () => { + this.assertOwner(target, input.sessionId); await this.#client.stopRuntimeResource(input); const state = this.#states.get(resourceIdentity(input)); this.#states.delete(resourceIdentity(input)); diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index 4a03fd831ae..486fc77f6eb 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -238,7 +238,7 @@ export function registerRuntimeHostSkillsIpc( }, ); - deps.ipcMain.handle("skills:sources:importLocalFile", async () => { + deps.ipcMain.handle("skills:sources:importLocalFile", async (event) => { if (deps.allowLocalPaths === false) { throw new Error("Local Skill import is unavailable for a remote Runtime Host"); } @@ -251,7 +251,7 @@ export function registerRuntimeHostSkillsIpc( { name: "Skill Markdown", extensions: ["md"] }, { name: copy.allFiles, extensions: ["*"] }, ], - }); + }, event.sender); if (result.canceled || result.filePaths.length === 0) { return { ok: false as const, reason: "cancelled" as const }; } diff --git a/apps/desktop/src/main/session-local-attachment-recovery.ts b/apps/desktop/src/main/session-local-attachment-recovery.ts index 2ad90f368e8..58e97fdf378 100644 --- a/apps/desktop/src/main/session-local-attachment-recovery.ts +++ b/apps/desktop/src/main/session-local-attachment-recovery.ts @@ -23,7 +23,7 @@ import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; import { ATTACHMENT_APPROVAL_TTL_MS, type IssuedAttachmentApproval } from './attachment-approval.js'; import { MAX_LOCAL_MESSAGE_BYTES } from './session-local-store.js'; -interface RecoveryOwner { +export interface RecoveryOwner { senderId: number; partition: string; scope: DesktopTargetScope; @@ -33,6 +33,8 @@ interface RecoveryEntry { owner: string; senderId: number; partition: string; + scope: DesktopTargetScope; + sessionId: string; issuedAt: number; leases: number; releaseRequested: boolean; @@ -43,7 +45,7 @@ function ownerKey(owner: RecoveryOwner): string { return JSON.stringify([owner.senderId, owner.partition, owner.scope.hostId, owner.scope.targetEpoch, owner.sessionId]); } -/** Main-only byte snapshots. Tokens cannot be redeemed by preview or Host-direct ingest. */ +/** Main-only byte snapshots. Previews are decoded and re-encoded; Host-direct ingest cannot redeem these tokens. */ export class SessionLocalAttachmentRecovery { readonly #entries = new Map(); constructor(private readonly now: () => number = Date.now) {} @@ -66,7 +68,7 @@ export class SessionLocalAttachmentRecovery { return attachments.map((attachment) => { const approvalId = `local-recovery:${randomUUID()}`; this.#entries.set(approvalId, { - owner: ownerKey(owner), senderId: owner.senderId, partition: owner.partition, issuedAt: this.now(), + owner: ownerKey(owner), senderId: owner.senderId, partition: owner.partition, scope: { ...owner.scope }, sessionId: owner.sessionId, issuedAt: this.now(), leases: 0, releaseRequested: false, attachment: { ...attachment, content: new Uint8Array(attachment.content) }, }); @@ -74,6 +76,23 @@ export class SessionLocalAttachmentRecovery { }); } + /** Main-internal lookup: callers must revalidate the current Host authority. */ + ownerForPreview(senderId: number, approvalId: string): RecoveryOwner | undefined { + this.#prune(); + const entry = this.#entries.get(approvalId); + if (!entry || entry.senderId !== senderId || entry.releaseRequested) return undefined; + return { senderId, partition: entry.partition, scope: { ...entry.scope }, sessionId: entry.sessionId }; + } + + /** Main-only copy for durable workspace custody. No lease is consumed or revoked. */ + snapshot(owner: RecoveryOwner, approvalId: string): { name: string; mimeType: string; content: Uint8Array } { + this.#prune(); + const entry = this.#entries.get(approvalId); + if (!entry || entry.owner !== ownerKey(owner) || entry.releaseRequested) + throw new AttachmentIngestBlockedError('source_expired'); + return { ...entry.attachment, content: new Uint8Array(entry.attachment.content) }; + } + /** Cleanup is sender-scoped, not target-scoped: an abandoned old target can still be released. */ release(senderId: number, ids: unknown): void { if (!Array.isArray(ids) || ids.length > 1000 || @@ -89,8 +108,37 @@ export class SessionLocalAttachmentRecovery { } } + /** A closing renderer relinquishes its drafts without revoking an in-flight send. */ + releaseSender(senderId: number): void { + this.release(senderId, [...this.#entries].filter(([, entry]) => entry.senderId === senderId).map(([id]) => id)); + } + + /** Main calls this only after creating the specific side-conversation copy. */ + transfer(owner: RecoveryOwner, targetSessionId: string, ids: unknown): void { + if (!Array.isArray(ids) || ids.length > MAX_ATTACHMENT_COUNT || + ids.some((id) => typeof id !== 'string' || !id.startsWith('local-recovery:') || id.length > 256)) + throw new AttachmentIngestBlockedError('items_invalid'); + if (new Set(ids).size !== ids.length) throw new AttachmentIngestBlockedError('duplicate_source'); + this.#prune(); + const source = ownerKey(owner); + const target = ownerKey({ ...owner, sessionId: targetSessionId }); + const entries = ids.map((id) => { + const entry = this.#entries.get(id); + // A replay of the same acknowledged copy may already have moved these + // exact handles. Neither case grants another window or Session access. + if (!entry || (entry.owner !== source && entry.owner !== target) || entry.releaseRequested || entry.leases) + throw new AttachmentIngestBlockedError('source_expired'); + return entry; + }); + for (const entry of entries) { + entry.owner = target; + entry.sessionId = targetSessionId; + } + } + prepare(owner: RecoveryOwner, items: unknown): { items: unknown[]; + validate(): void; commit(admit: () => T): T; dispose(): void; } { @@ -122,6 +170,10 @@ export class SessionLocalAttachmentRecovery { let disposed = false; return { items: expanded, + validate: () => { + if (disposed) throw new AttachmentIngestBlockedError('source_expired'); + for (const id of ids) requireEntry(id, true); + }, commit: (admit) => { if (disposed) throw new AttachmentIngestBlockedError('source_expired'); // Admission is synchronous (SQLite enqueue): no event-loop turn may diff --git a/apps/desktop/src/main/session-window-coordinator.ts b/apps/desktop/src/main/session-window-coordinator.ts new file mode 100644 index 00000000000..003fe7aa748 --- /dev/null +++ b/apps/desktop/src/main/session-window-coordinator.ts @@ -0,0 +1,513 @@ +/* + * 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 { randomUUID } from 'node:crypto'; +import type { IpcMain, WebContents } from 'electron'; +import type { + SessionWindowCommand, SessionWindowCommandReply, SessionWindowCommandResult, + SessionWindowGroup, SessionWindowNavigation, +} from '../shared/session-window-contract.js'; +import type { SessionWorkspaceSnapshot } from '../shared/session-workspace-state.js'; +import type { MainWindowController } from './main-window.js'; +import type { WorkspaceWindowEntry } from './workspace-window-registry.js'; + +type CommandInput = SessionWindowCommand extends infer Command + ? Command extends SessionWindowCommand ? Omit : never : never; + +export interface SessionWindowCoordinatorDeps { + readonly windows: MainWindowController; + readonly signal: AbortSignal; + readonly ensureMainWindow: () => Promise; + readonly resolveGroup: (sessionId: string) => Promise; + readonly saveSnapshot: (sender: WebContents, snapshot: SessionWorkspaceSnapshot) => Promise; + readonly loadSnapshot: (sender: WebContents, sessionIds: readonly string[]) => Promise; + readonly releaseSnapshot: (sender: WebContents, snapshot: SessionWorkspaceSnapshot) => void; + readonly transferResources: (input: { + readonly group: SessionWindowGroup; + readonly source: WebContents | undefined; + readonly target: WebContents; + }) => Promise; + readonly releaseResources: (sender: WebContents) => void | Promise; + readonly isQuitting: () => boolean; + readonly onError: (error: unknown, sender?: WebContents) => void; + readonly commandTimeoutMs?: number; +} + +/** A process-scoped ownership authority; a renderer never chooses its own owner. */ +export function createSessionWindowCoordinator(deps: SessionWindowCoordinatorDeps) { + const registry = deps.windows.registry; + const owners = new Map(); + const groups = new Map(); + const memberGroups = new Map(); + const queues = new Map>(); + let mainPresentation: Promise = Promise.resolve(); + const ready = new Set(); + const selections = new Map(); + const readyWaiters = new Map void>>(); + const pending = new Map void; + reject: (error: Error) => void; + }>(); + const closing = new Set(); + const timeoutMs = deps.commandTimeoutMs ?? 20_000; + const groupKey = (group: SessionWindowGroup): string => JSON.stringify([group.hostId, group.groupId]); + const reportError = (error: unknown, sender?: WebContents): void => deps.onError(error, sender); + + function requireWindow(sender: WebContents): WorkspaceWindowEntry { + const entry = registry.forSender(sender); + if (!entry) throw new Error('This operation requires a workspace window'); + return entry; + } + + function rejectWindowRequests(windowId: string): void { + ready.delete(windowId); + for (const operation of pending.values()) { + if (operation.windowId === windowId) operation.reject(new Error('The workspace window closed')); + } + } + + function attachWindow(entry: WorkspaceWindowEntry): void { + const windowId = entry.context.windowId; + const sender = entry.window.webContents; + sender.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => { + if (isMainFrame && !isInPlace) rejectWindowRequests(windowId); + }); + sender.on('render-process-gone', () => { + rejectWindowRequests(windowId); + void Promise.resolve().then(() => deps.releaseResources(sender)).catch((error) => reportError(error, sender)); + }); + entry.window.once('closed', () => { + rejectWindowRequests(windowId); + void Promise.resolve().then(() => deps.releaseResources(sender)).catch((error) => reportError(error, sender)); + for (const [key, owner] of owners) if (owner === windowId) owners.delete(key); + selections.delete(windowId); + }); + entry.window.on('close', (event) => { + if (deps.isQuitting() || !ready.has(windowId)) return; + event.preventDefault(); + if (closing.has(windowId)) return; + closing.add(windowId); + void (async () => { + // Wait for transfers involving this window before taking its final snapshot. + await Promise.allSettled([...queues.values(), mainPresentation]); + if (entry.window.isDestroyed()) return; + const result = await request(entry, { kind: 'flush' }); + if (!result.snapshot) throw new Error('The window did not confirm its saved workspace'); + await saveSnapshot(sender, result.snapshot); + await deps.releaseResources(sender); + entry.window.destroy(); + })().catch(async (error) => { + if (!entry.window.isDestroyed()) await request(entry, { kind: 'resume', ...(entry.context.kind === 'session' ? { group: entry.context.group } : {}) }).catch(() => undefined); + reportError(error, sender); + }).finally(() => closing.delete(windowId)); + }); + } + for (const entry of registry.entries()) attachWindow(entry); + const unsubscribe = registry.onRegistered(attachWindow); + + async function resolveGroup(sessionId: string): Promise { + const resolved = await deps.resolveGroup(sessionId); + // Keep a known group stable when its root is deleted or temporarily absent. + const knownKeys = new Set(resolved.sessionIds.map((id) => memberGroups.get(id)).filter((id): id is string => !!id)); + const ownKnown = memberGroups.get(sessionId); + const key = ownKnown ?? [...knownKeys].find((id) => owners.has(id)) ?? groupKey(resolved); + const previous = groups.get(key); + const group: SessionWindowGroup = { + ...resolved, + groupId: previous?.groupId ?? resolved.groupId, + sessionIds: [...new Set([...(previous?.sessionIds ?? []), ...resolved.sessionIds])], + }; + const conflicting = [...knownKeys].filter((id) => id !== key && owners.has(id)); + if (conflicting.length) throw new Error('Related sessions are already assigned to different windows'); + groups.set(key, group); + for (const member of group.sessionIds) memberGroups.set(member, key); + return group; + } + + function serial(key: string, operation: () => Promise): Promise { + const prior = queues.get(key) ?? Promise.resolve(); + const next = prior.catch(() => undefined).then(operation); + queues.set(key, next); + void next.finally(() => { if (queues.get(key) === next) queues.delete(key); }).catch(() => undefined); + return next; + } + + // Every transfer touches the one main workspace. Keep its snapshot/restore + // transaction intact across unrelated groups; always acquire group first. + function withMainPresentation(operation: () => Promise): Promise { + const next = mainPresentation.catch(() => undefined).then(operation); + mainPresentation = next; + return next; + } + + async function waitReady(entry: WorkspaceWindowEntry): Promise { + const id = entry.context.windowId; + if (ready.has(id)) return; + await new Promise((resolve, reject) => { + const complete = (): void => { clearTimeout(timer); waiters.delete(complete); resolve(); }; + const waiters = readyWaiters.get(id) ?? new Set<() => void>(); + readyWaiters.set(id, waiters); + waiters.add(complete); + const timer = setTimeout(() => { + waiters.delete(complete); + reject(new Error('The workspace window did not become ready')); + }, timeoutMs); + }); + } + + async function request(entry: WorkspaceWindowEntry, input: CommandInput): Promise { + await waitReady(entry); + if (entry.window.isDestroyed()) throw new Error('The workspace window is unavailable'); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => complete(new Error(`Workspace ${input.kind} was not confirmed`)), timeoutMs); + const complete = (error?: Error, result?: SessionWindowCommandResult): void => { + clearTimeout(timer); + pending.delete(requestId); + if (error) reject(error); + else resolve(result ?? {}); + }; + pending.set(requestId, { + windowId: entry.context.windowId, + resolve: (result) => complete(undefined, result), + reject: (error) => complete(error), + }); + try { entry.window.webContents.send('session-window:command', { ...input, requestId }); } + catch (error) { complete(error instanceof Error ? error : new Error(String(error))); } + }); + } + + function owner(group: SessionWindowGroup): WorkspaceWindowEntry | undefined { + const id = owners.get(groupKey(group)); + return id ? registry.get(id) : undefined; + } + + async function ensureMain(): Promise { + await deps.ensureMainWindow(); + const entry = registry.main(); + if (!entry) throw new Error('The main window is unavailable'); + await waitReady(entry); + return entry; + } + + async function navigate(entry: WorkspaceWindowEntry, group: SessionWindowGroup, navigation: SessionWindowNavigation): Promise { + const apply = () => navigateNow(entry, group, navigation); + return entry.context.kind === 'main' ? withMainPresentation(apply) : apply(); + } + + async function navigateNow(entry: WorkspaceWindowEntry, group: SessionWindowGroup, navigation: SessionWindowNavigation): Promise { + await request(entry, { kind: 'navigate', group, navigation }); + selections.set(entry.context.windowId, navigation); + entry.controller.reveal(); + entry.controller.focus(); + } + + async function saveSnapshot(sender: WebContents, snapshot: SessionWorkspaceSnapshot): Promise { + requireWindow(sender); + const sessionIds = new Set([ + ...snapshot.drafts.map((draft) => draft.sessionId), + ...snapshot.sideConversations.map((side) => side.panel.sourceSessionId), + ...Object.keys(snapshot.workbar ?? {}), ...Object.keys(snapshot.readingAnchors ?? {}), + ]); + for (const id of sessionIds) { + if (!memberGroups.has(id)) await resolveGroup(id); + if (!canUseSession(sender, id)) throw new Error('This workspace belongs to another window'); + } + await deps.saveSnapshot(sender, snapshot); + } + + function transfer(group: SessionWindowGroup, navigation: SessionWindowNavigation, target: WorkspaceWindowEntry, source?: WorkspaceWindowEntry): Promise { + return withMainPresentation(() => transferNow(group, navigation, target, source)); + } + + async function transferNow(group: SessionWindowGroup, navigation: SessionWindowNavigation, target: WorkspaceWindowEntry, source?: WorkspaceWindowEntry): Promise { + const key = groupKey(group); + const targetSender = target.window.webContents; + const sourceSender = source?.window.webContents; + const previousOwner = owners.get(key); + let sourcePrepared = false; + let sourceCleared = false; + let resourcesMoved = false; + let committed = false; + let activeSessionId: string | undefined; + let targetRetained: SessionWindowCommandResult | undefined; + let snapshot: SessionWorkspaceSnapshot | undefined; + try { + await waitReady(target); + if (target.context.kind === 'main') { + const retained = await request(target, { kind: 'flush' }); + targetRetained = retained; + if (!retained.snapshot) throw new Error('The main window did not save its current workspace'); + await saveSnapshot(targetSender, retained.snapshot); + } + if (source) { + const prepared = await request(source, { kind: 'prepare-transfer', group }); + sourcePrepared = true; + if (!prepared.snapshot) throw new Error('The source window did not save its workspace'); + activeSessionId = prepared.activeSessionId; + await saveSnapshot(sourceSender!, prepared.snapshot); + } + // Issue handles only to the renderer that will receive them. A read is an + // allocation, so obtain it once and before clearing the source workspace. + snapshot = await deps.loadSnapshot(targetSender, group.sessionIds); + if (source) { + sourceCleared = true; + await request(source, { kind: 'clear', group }); + } + if (target.context.kind === 'session') owners.set(key, target.context.windowId); + else owners.delete(key); + committed = true; + resourcesMoved = true; + await deps.transferResources({ group, source: sourceSender, target: targetSender }); + await request(target, { kind: 'restore', group, navigation, snapshot }); + selections.set(target.context.windowId, navigation); + target.controller.reveal(); + target.controller.focus(); + if (source?.context.kind === 'session') source.window.destroy(); + } catch (error) { + // Revoke the destination before reactivating the source. If it cannot + // acknowledge revocation, destroy it; never leave two operable owners. + if (committed && !target.window.isDestroyed()) { + try { await request(target, { kind: 'clear', group }); } + catch { target.window.destroy(); } + } + if (snapshot) deps.releaseSnapshot(targetSender, snapshot); + if (previousOwner) owners.set(key, previousOwner); else owners.delete(key); + if (source && !source.window.isDestroyed()) { + if (resourcesMoved) { + await deps.transferResources({ group, source: targetSender, target: sourceSender! }); + } + if (sourceCleared && activeSessionId && group.sessionIds.includes(activeSessionId)) { + const restored = await deps.loadSnapshot(sourceSender!, group.sessionIds); + try { await request(source, { kind: 'restore', group, navigation: { sessionId: activeSessionId }, snapshot: restored }); } + catch (restoreError) { deps.releaseSnapshot(sourceSender!, restored); throw restoreError; } + } else if (sourcePrepared) { + await request(source, { kind: 'resume', group }); + } + } + if (target.context.kind === 'main' && !target.window.isDestroyed() && targetRetained) { + if (committed && targetRetained.activeSessionId && targetRetained.snapshot) { + const previousGroup = await resolveGroup(targetRetained.activeSessionId); + await request(target, { kind: 'restore', group: previousGroup, + navigation: { sessionId: targetRetained.activeSessionId }, snapshot: targetRetained.snapshot }); + } else { + await request(target, { kind: 'resume', group }); + } + } + throw error; + } + } + + async function routeSession(sender: WebContents, navigation: SessionWindowNavigation): Promise<'local' | 'routed'> { + const origin = requireWindow(sender); + const group = await resolveGroup(navigation.sessionId); + return serial(groupKey(group), async () => { + const destination = owner(group); + if (destination) { + if (destination === origin) { selections.set(origin.context.windowId, navigation); return 'local'; } + await navigate(destination, group, navigation); + return 'routed'; + } + if (origin.context.kind === 'main') return withMainPresentation(async () => { + selections.set(origin.context.windowId, navigation); + return 'local' as const; + }); + await navigate(await ensureMain(), group, navigation); + return 'routed'; + }); + } + + async function openSessionWindow(sender: WebContents, sessionId: string): Promise { + requireWindow(sender); + const group = await resolveGroup(sessionId); + await serial(groupKey(group), async () => { + const existing = owner(group); + if (existing) { await navigate(existing, group, { sessionId }); return; } + const source = registry.main(); + const controller = await deps.windows.createSessionWindow({ kind: 'session', group, initialSessionId: sessionId }, deps.signal); + const window = controller.browserWindow(); + if (!window) throw new Error('The session window could not be created'); + const target = requireWindow(window.webContents); + try { await transfer(group, { sessionId }, target, source); } + catch (error) { if (!window.isDestroyed()) window.destroy(); throw error; } + }); + } + + async function openInMainWindow(sender: WebContents): Promise { + const origin = requireWindow(sender); + if (origin.context.kind !== 'session') return; + const initial = origin.context.initialSessionId; + const group = await resolveGroup(initial); + await serial(groupKey(group), async () => { + if (owner(group) !== origin) throw new Error('The session window no longer owns this workspace'); + // prepare-transfer returns the current member; use it when available in + // transfer rather than switching the user back to the original root. + const current = await request(origin, { kind: 'flush' }); + if (current.snapshot) await saveSnapshot(sender, current.snapshot); + const sessionId = current.activeSessionId && group.sessionIds.includes(current.activeSessionId) ? current.activeSessionId : initial; + try { await transfer(group, { sessionId }, await ensureMain(), origin); } + catch (error) { + if (!origin.window.isDestroyed()) await request(origin, { kind: 'resume', group }).catch(() => undefined); + throw error; + } + }); + } + + function ownerForSession(sessionId: string): WebContents | undefined { + const key = memberGroups.get(sessionId); + const windowId = key && owners.get(key); + return windowId ? registry.get(windowId)?.window.webContents : undefined; + } + + function canUseSession(sender: WebContents, sessionId: string): boolean { + const entry = registry.forSender(sender); + if (!entry) return false; + const owned = ownerForSession(sessionId); + if (owned) return owned === sender; + return entry.context.kind === 'main'; + } + + async function resumeAll(): Promise { + const retained = registry.entries().filter((entry) => ready.has(entry.context.windowId)); + const results = await Promise.allSettled(retained.map((entry) => request(entry, { + kind: 'resume', ...(entry.context.kind === 'session' ? { group: entry.context.group } : {}), + }))); + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failure) throw failure.reason; + } + + return { + context: (sender: WebContents) => requireWindow(sender).context, + routeSession, openSessionWindow, openInMainWindow, ownerForSession, canUseSession, saveSnapshot, + refreshGroup: resolveGroup, + async loadSnapshot(sender: WebContents, sessionIds: readonly string[]) { + requireWindow(sender); + for (const id of sessionIds) { + if (!memberGroups.has(id)) await resolveGroup(id); + if (!canUseSession(sender, id)) throw new Error('This workspace belongs to another window'); + } + requireWindow(sender); + return deps.loadSnapshot(sender, sessionIds); + }, + sessionWindowReady(sender: WebContents): void { + const entry = requireWindow(sender); + if (ready.has(entry.context.windowId)) return; + ready.add(entry.context.windowId); + for (const callback of readyWaiters.get(entry.context.windowId) ?? []) callback(); + readyWaiters.delete(entry.context.windowId); + if (entry.context.kind === 'session') { + const key = memberGroups.get(entry.context.initialSessionId); + const group = key ? groups.get(key) : undefined; + if (key && group && owners.get(key) === entry.context.windowId) { + const navigation = selections.get(entry.context.windowId) ?? { sessionId: entry.context.initialSessionId }; + void serial(key, async () => { + if (owner(group) !== entry) return; + const snapshot = await deps.loadSnapshot(sender, group.sessionIds); + try { await request(entry, { kind: 'restore', group, navigation, snapshot }); } + catch (error) { deps.releaseSnapshot(sender, snapshot); throw error; } + entry.controller.reveal(); + }).catch((error) => reportError(error, sender)); + } + } + }, + reply(sender: WebContents, reply: SessionWindowCommandReply): void { + const entry = requireWindow(sender); + const operation = pending.get(reply.requestId); + if (!operation || operation.windowId !== entry.context.windowId) return; + if (reply.ok) operation.resolve(reply.result ?? {}); + else operation.reject(new Error(reply.error)); + }, + async focusSession(sessionId: string): Promise { + const group = await resolveGroup(sessionId); + await serial(groupKey(group), async () => navigate(owner(group) ?? await ensureMain(), group, { sessionId })); + }, + async commandMain(sender: WebContents, command: 'newTask' | 'openSettings' | 'openHelp'): Promise { + requireWindow(sender); + const entry = await ensureMain(); + await withMainPresentation(async () => { + entry.window.webContents.send('window:command', { id: command }); + entry.controller.focus(); + }); + }, + resumeAll, + async flushAll(): Promise { + await Promise.allSettled([...queues.values(), mainPresentation]); + const retained = registry.entries().filter((entry) => ready.has(entry.context.windowId)); + try { + for (const entry of retained) { + const result = await request(entry, { kind: 'flush' }); + if (!result.snapshot) throw new Error('A window did not confirm its saved workspace'); + await saveSnapshot(entry.window.webContents, result.snapshot); + } + } catch (error) { + await resumeAll().catch(reportError); + throw error; + } + }, + dispose(): void { + unsubscribe(); + for (const operation of pending.values()) operation.reject(new Error('Window coordination stopped')); + }, + }; +} + +export type SessionWindowCoordinator = ReturnType; + +export function registerSessionWindowIpc(coordinator: SessionWindowCoordinator, ipc: Pick): void { + function handle(channel: string, action: (sender: WebContents, input: any) => unknown): void { + ipc.handle(`session-window:${channel}`, (event, input: unknown) => { + if (event.senderFrame !== event.sender.mainFrame) throw new Error('Session windows require the main frame'); + coordinator.context(event.sender); + return action(event.sender, input); + }); + } + handle('context', (sender) => coordinator.context(sender)); + handle('ready', (sender) => coordinator.sessionWindowReady(sender)); + handle('route', (sender, input: unknown) => coordinator.routeSession(sender, requireNavigation(input))); + handle('open', (sender, input: unknown) => coordinator.openSessionWindow(sender, requireSessionId(input))); + handle('open-main', (sender) => coordinator.openInMainWindow(sender)); + handle('reply', (sender, input: unknown) => { + if (!input || typeof input !== 'object' || !('requestId' in input) || typeof input.requestId !== 'string' || !('ok' in input) || typeof input.ok !== 'boolean') throw new TypeError('Invalid window reply'); + if (!input.ok && (!('error' in input) || typeof input.error !== 'string')) throw new TypeError('Invalid window reply'); + return coordinator.reply(sender, input as SessionWindowCommandReply); + }); + handle('save', (sender, input) => coordinator.saveSnapshot(sender, input)); + handle('load', (sender, input: unknown) => { + if (!Array.isArray(input)) throw new TypeError('Invalid session list'); + return coordinator.loadSnapshot(sender, input.map(requireSessionId)); + }); + handle('command-main', (sender, input: unknown) => { + if (input !== 'newTask' && input !== 'openSettings' && input !== 'openHelp') throw new TypeError('Invalid main window command'); + return coordinator.commandMain(sender, input); + }); +} + +function requireSessionId(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > 4096) throw new TypeError('Invalid session identity'); + return value; +} + +function requireNavigation(input: unknown): SessionWindowNavigation { + if (!input || typeof input !== 'object' || !('sessionId' in input)) throw new TypeError('Invalid session navigation'); + const sessionId = requireSessionId(input.sessionId); + if ('turnId' in input && input.turnId !== undefined && (typeof input.turnId !== 'string' || input.turnId.length > 4096)) throw new TypeError('Invalid turn identity'); + if ('sequence' in input && input.sequence !== undefined && (typeof input.sequence !== 'number' || !Number.isSafeInteger(input.sequence) || input.sequence < 0)) throw new TypeError('Invalid turn sequence'); + return { sessionId, ...('turnId' in input && input.turnId !== undefined ? { turnId: input.turnId as string } : {}), ...('sequence' in input && input.sequence !== undefined ? { sequence: input.sequence as number } : {}) }; +} diff --git a/apps/desktop/src/main/session-window-group.ts b/apps/desktop/src/main/session-window-group.ts new file mode 100644 index 00000000000..86f14ed8aa4 --- /dev/null +++ b/apps/desktop/src/main/session-window-group.ts @@ -0,0 +1,72 @@ +/* + * 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 { linkedSubagentParentSessionId, sessionRevisionFamilyId, type SessionSummary } from '@maka/core/session'; +import { isSideConversationSession } from '@maka/core/side-conversation'; +import { desktopSessionKey, parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; +import type { SessionWindowGroup } from '../shared/session-window-contract.js'; + +export type WindowGroupSession = Pick; + +/** Retain ancestry for this app lifetime so deleting a parent cannot split its open family. */ +export class SessionWindowGroups { + readonly #catalogs = new Map>(); + + resolve(sessionKey: string, sessions: readonly WindowGroupSession[]): SessionWindowGroup { + const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); + let catalog = this.#catalogs.get(hostId); + if (!catalog) this.#catalogs.set(hostId, catalog = new Map()); + for (const session of sessions) catalog.set(session.id, session); + if (!catalog.has(sessionId)) throw new Error('Session is unavailable'); + const familyOf = (id: string): string => { + const session = catalog.get(id); + return session ? sessionRevisionFamilyId(session) : id; + }; + const parents = new Map(); + for (const session of catalog.values()) { + const parent = linkedSubagentParentSessionId(session) + ?? (isSideConversationSession(session.labels) ? session.parentSessionId : undefined); + const family = familyOf(session.id); + if (parent && familyOf(parent) !== family) parents.set(family, familyOf(parent)); + } + const rootOf = (id: string): string => { + let current = familyOf(id); + const seen: string[] = []; + while (parents.has(current)) { + const cycleStart = seen.indexOf(current); + if (cycleStart >= 0) return seen.slice(cycleStart).sort()[0]!; + seen.push(current); + current = parents.get(current)!; + } + return current; + }; + const root = rootOf(sessionId); + const members = [...catalog.keys()].filter((id) => rootOf(id) === root); + // Include a deleted/missing root identity in ownership checks as well. + if (!members.includes(root)) members.push(root); + return { + hostId, + groupId: desktopSessionKey({ hostId, sessionId: root }), + sessionIds: members.sort().map((id) => desktopSessionKey({ hostId, sessionId: id })), + }; + } + + forgetHost(hostId: string): void { this.#catalogs.delete(hostId); } +} diff --git a/apps/desktop/src/main/session-workspace-state-store.ts b/apps/desktop/src/main/session-workspace-state-store.ts new file mode 100644 index 00000000000..27d1332306c --- /dev/null +++ b/apps/desktop/src/main/session-workspace-state-store.ts @@ -0,0 +1,331 @@ +/* + * 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 { chmodSync, mkdirSync } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { AttachmentIngestBlockedError, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, resolveAttachmentMimeType } from '@maka/core/attachments'; +import { isAttachmentRef, isDirectoryReference, isInlineReference, isQuoteRef } from '@maka/core/events'; +import type { AttachmentApprovalRegistry } from './attachment-approval.js'; +import { prepareIngestItems, resolveAttachmentRefs } from './attachment-ingest.js'; +import { SessionLocalAttachmentRecovery, type RecoveryOwner } from './session-local-attachment-recovery.js'; +import { MAX_LOCAL_MESSAGE_BYTES } from './session-local-store.js'; +import { parseDesktopSessionKey, type DesktopTargetScope } from '../shared/runtime-host-identity.js'; +import type { SessionWorkspaceAttachment, SessionWorkspaceDraft, SessionWorkspaceSnapshot, SideConversationWorkspaceState } from '../shared/session-workspace-state.js'; + +export interface SessionWorkspaceOwner { + readonly senderId: number; + readonly partition: string; + readonly scope: DesktopTargetScope; +} + +type StoredAttachment = Exclude | { + readonly type: 'stored'; readonly ordinal: number; readonly name: string; readonly mimeType: string; readonly size: number; + readonly source?: { readonly approvalId: string; readonly senderId: number; readonly hostId: string; readonly targetEpoch: string; readonly sessionId: string }; +}; +type StoredDraft = Omit & { attachments: readonly StoredAttachment[] }; +type StoredSide = Omit & { draft: StoredDraft }; +type AttachmentBytes = { name: string; mimeType: string; content: Uint8Array }; + +/** Client-owned drafts, separate from admitted messages and window restoration. */ +export class SessionWorkspaceStateStore { + readonly #db: DatabaseSync; + readonly #pending = new Map>(); + #revision = 0; + readonly #deleted = new Set(); + #wasDeleted(partition: string, sessionId: string): boolean { return this.#deleted.has(JSON.stringify([partition, sessionId])); } + constructor(path: string, private readonly sources: { + approvals: AttachmentApprovalRegistry; + recovery: SessionLocalAttachmentRecovery; + }) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + this.#db = new DatabaseSync(path); + chmodSync(path, 0o600); + this.#db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS workspace_states ( + partition TEXT NOT NULL, item_key TEXT NOT NULL, source_key TEXT NOT NULL, + kind TEXT NOT NULL, payload TEXT NOT NULL, captured_at INTEGER NOT NULL, + PRIMARY KEY(partition, item_key) + ); + CREATE TABLE IF NOT EXISTS workspace_ui ( + partition TEXT NOT NULL, session_key TEXT NOT NULL, payload TEXT NOT NULL, captured_at INTEGER NOT NULL, + PRIMARY KEY(partition, session_key) + ); + CREATE TABLE IF NOT EXISTS workspace_attachments ( + partition TEXT NOT NULL, item_key TEXT NOT NULL, ordinal INTEGER NOT NULL, + name TEXT NOT NULL, mime_type TEXT NOT NULL, content BLOB NOT NULL, + PRIMARY KEY(partition, item_key, ordinal), + FOREIGN KEY(partition, item_key) REFERENCES workspace_states(partition, item_key) ON DELETE CASCADE + ); + `); + } + + #recoveryOwner(owner: SessionWorkspaceOwner, key: string): RecoveryOwner { + const ref = parseDesktopSessionKey(key); + if (ref.hostId !== owner.scope.hostId) throw new Error('Workspace draft belongs to a different Host'); + return { ...owner, sessionId: ref.sessionId }; + } + + #custodiedAttachment(owner: SessionWorkspaceOwner, itemKey: string, sessionId: string, approvalId: string): AttachmentBytes | undefined { + const row = this.#db.prepare('SELECT payload, kind FROM workspace_states WHERE partition = ? AND item_key = ?').get(owner.partition, itemKey); + if (!row) return undefined; + const payload = JSON.parse(String(row.payload)) as StoredDraft | StoredSide; + const draft = row.kind === 'draft' ? payload as StoredDraft : (payload as StoredSide).draft; + const item = draft.attachments.find((item) => item.type === 'stored' && item.source?.approvalId === approvalId && + item.source.senderId === owner.senderId && item.source.hostId === owner.scope.hostId && + item.source.targetEpoch === owner.scope.targetEpoch && item.source.sessionId === sessionId); + if (!item || item.type !== 'stored') return undefined; + const saved = this.#db.prepare('SELECT name, mime_type, content FROM workspace_attachments WHERE partition = ? AND item_key = ? AND ordinal = ?').get(owner.partition, itemKey, item.ordinal); + if (!saved) throw new Error('Saved workspace attachment is missing'); + return { name: String(saved.name), mimeType: String(saved.mime_type), content: saved.content as Uint8Array }; + } + + async #prepareDraft(owner: SessionWorkspaceOwner, itemKey: string, draft: SessionWorkspaceDraft): Promise<{ draft: StoredDraft; bytes: AttachmentBytes[]; revalidate(): void }> { + this.#recoveryOwner(owner, draft.sessionId); + if (typeof draft.text !== 'string' || draft.text.length > 2_000_000 || !Array.isArray(draft.references) || + !Array.isArray(draft.quotes) || !Array.isArray(draft.directoryReferences) || !Array.isArray(draft.attachments) || + !draft.references.every(isInlineReference) || !draft.quotes.every(isQuoteRef) || !draft.directoryReferences.every(isDirectoryReference)) + throw new Error('Invalid workspace draft'); + if (draft.attachments.length > MAX_ATTACHMENT_COUNT) throw new AttachmentIngestBlockedError('count_limit'); + const bytes: AttachmentBytes[] = []; + const attachments: StoredAttachment[] = []; + const seen = new Set(); + const revalidations: Array<() => void> = []; + for (const attachment of draft.attachments) { + if (attachment.type === 'retained') { + if (!isAttachmentRef(attachment.attachment)) throw new Error('Invalid retained workspace attachment'); + attachments.push(structuredClone(attachment)); + continue; + } + let saved: AttachmentBytes; + if (attachment.type === 'approval') { + if (seen.has(attachment.approvalId)) throw new AttachmentIngestBlockedError('duplicate_source'); + seen.add(attachment.approvalId); + } + // Custody was established by an earlier authenticated save of this exact + // draft and owner. Reuse those bytes after picker/recovery TTL expiry; never + // reopen a path or accept a token merely because another draft saved it. + const custodied = attachment.type === 'approval' + ? this.#custodiedAttachment(owner, itemKey, draft.sessionId, attachment.approvalId) : undefined; + if (custodied) { + saved = custodied; + } else if (attachment.type === 'approval' && attachment.approvalId.startsWith('local-recovery:')) { + revalidations.push(() => { this.sources.recovery.snapshot(this.#recoveryOwner(owner, draft.sessionId), attachment.approvalId); }); + saved = this.sources.recovery.snapshot(this.#recoveryOwner(owner, draft.sessionId), attachment.approvalId); + } else { + const prepared = await prepareIngestItems({ + senderId: owner.senderId, approvals: this.sources.approvals, stat, + items: attachment.type === 'approval' ? [attachment] : attachment.type === 'upload' ? [attachment] : null, + maxBytes: MAX_ATTACHMENT_BYTES, maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, + }); + const snapshots = await resolveAttachmentRefs({ + files: prepared.files, maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, + snapshot: async (item) => ({ name: item.name, mimeType: item.mimeType, content: item.content }), + }); + // Keep the source approval alive: periodic saves and rollback still use it. + if (attachment.type === 'approval' && !this.sources.approvals.peekApproval(owner.senderId, attachment.approvalId)) + throw new AttachmentIngestBlockedError('source_expired'); + if (attachment.type === 'approval') revalidations.push(() => { + if (!this.sources.approvals.peekApproval(owner.senderId, attachment.approvalId)) throw new AttachmentIngestBlockedError('source_expired'); + }); + saved = snapshots[0]!; + } + saved.mimeType = resolveAttachmentMimeType(saved.content, saved.mimeType, saved.name); + bytes.push(saved); + if (bytes.reduce((sum, entry) => sum + entry.content.byteLength, 0) > MAX_LOCAL_MESSAGE_BYTES) + throw new AttachmentIngestBlockedError('total_size_exceeded'); + attachments.push({ type: 'stored', ordinal: bytes.length - 1, name: saved.name, mimeType: saved.mimeType, size: saved.content.byteLength, + ...(attachment.type === 'approval' ? { source: { approvalId: attachment.approvalId, senderId: owner.senderId, + hostId: owner.scope.hostId, targetEpoch: owner.scope.targetEpoch, sessionId: draft.sessionId } } : {}), + }); + } + const safeDraft: StoredDraft = { + sessionId: draft.sessionId, text: draft.text, references: structuredClone(draft.references), + ...(draft.revision ? { revision: structuredClone(draft.revision) } : {}), + ...(draft.retractedReferences ? { retractedReferences: structuredClone(draft.retractedReferences) } : {}), + ...(draft.replacesMessageId ? { replacesMessageId: draft.replacesMessageId } : {}), + quotes: structuredClone(draft.quotes), directoryReferences: structuredClone(draft.directoryReferences), attachments, + }; + if (JSON.stringify(safeDraft).length > 4_000_000) throw new Error('Workspace draft is too large'); + return { draft: safeDraft, bytes, revalidate: () => { for (const validate of revalidations) validate(); } }; + } + + save(owner: SessionWorkspaceOwner, snapshot: SessionWorkspaceSnapshot): Promise { + // Serialize asynchronous file reads as well as the SQLite write, so a slow + // earlier save cannot overwrite a final close snapshot. + const pendingKey = JSON.stringify([owner.partition, owner.scope.hostId]); + const revision = this.#revision; + const previous = this.#pending.get(pendingKey) ?? Promise.resolve(); + const next = previous.catch(() => {}).then(() => this.#save(owner, snapshot, revision)); + this.#pending.set(pendingKey, next); + void next.finally(() => { if (this.#pending.get(pendingKey) === next) this.#pending.delete(pendingKey); }).catch(() => {}); + return next; + } + + async #save(owner: SessionWorkspaceOwner, snapshot: SessionWorkspaceSnapshot, revision: number): Promise { + if (!snapshot || !Array.isArray(snapshot.drafts) || !Array.isArray(snapshot.sideConversations) || !Number.isFinite(snapshot.capturedAt)) + throw new Error('Invalid workspace snapshot'); + const revalidations: Array<{ source: string; validate(): void }> = []; + const entries: Array<{ key: string; source: string; kind: string; payload: StoredDraft | StoredSide; bytes: AttachmentBytes[] }> = []; + const members = new Set(snapshot.drafts.map((draft) => draft.sessionId).filter((key) => !this.#wasDeleted(owner.partition, key))); + for (const draft of snapshot.drafts) { + if (!members.has(draft.sessionId)) continue; + let prepared; + try { prepared = await this.#prepareDraft(owner, `draft:${draft.sessionId}`, draft); } + catch (error) { + if (this.#wasDeleted(owner.partition, draft.sessionId)) continue; + throw error; + } + revalidations.push({ source: draft.sessionId, validate: prepared.revalidate }); + entries.push({ key: `draft:${draft.sessionId}`, source: draft.sessionId, kind: 'draft', payload: prepared.draft, bytes: prepared.bytes }); + } + for (const side of snapshot.sideConversations) { + if (this.#wasDeleted(owner.partition, side.panel.sourceSessionId)) continue; + if (!members.has(side.panel.sourceSessionId)) throw new Error('Side conversation is outside the workspace snapshot'); + let prepared; + try { prepared = await this.#prepareDraft(owner, `side:${side.panel.id}`, side.draft); } + catch (error) { + if (this.#wasDeleted(owner.partition, side.panel.sourceSessionId)) continue; + throw error; + } + revalidations.push({ source: side.panel.sourceSessionId, validate: prepared.revalidate }); + entries.push({ key: `side:${side.panel.id}`, source: side.panel.sourceSessionId, kind: 'side', + payload: { ...structuredClone(side), draft: prepared.draft }, bytes: prepared.bytes }); + } + for (const member of members) if (this.#wasDeleted(owner.partition, member)) members.delete(member); + for (let index = entries.length - 1; index >= 0; index -= 1) { + if (!members.has(entries[index]!.source)) entries.splice(index, 1); + } + for (const item of revalidations) if (members.has(item.source)) item.validate(); + if (revision !== this.#revision) throw new Error('Workspace authorization was cleared during save'); + this.#db.exec('BEGIN IMMEDIATE'); + try { + for (const source of members) { + this.#db.prepare('DELETE FROM workspace_states WHERE partition = ? AND source_key = ? AND captured_at <= ?') + .run(owner.partition, source, snapshot.capturedAt); + } + for (const source of members) { + const ui = { workbar: snapshot.workbar?.[source], readingAnchor: snapshot.readingAnchors?.[source] }; + const payload = JSON.stringify(ui); + if (payload.length > 1_000_000) throw new Error('Workspace layout is too large'); + this.#db.prepare(`INSERT INTO workspace_ui VALUES (?, ?, ?, ?) + ON CONFLICT(partition, session_key) DO UPDATE SET payload=excluded.payload, captured_at=excluded.captured_at + WHERE excluded.captured_at >= workspace_ui.captured_at`).run(owner.partition, source, payload, snapshot.capturedAt); + } + const write = this.#db.prepare('INSERT OR REPLACE INTO workspace_states VALUES (?, ?, ?, ?, ?, ?)'); + const writeAttachment = this.#db.prepare('INSERT INTO workspace_attachments VALUES (?, ?, ?, ?, ?, ?)'); + for (const entry of entries) { + const newer = this.#db.prepare('SELECT captured_at FROM workspace_states WHERE partition = ? AND item_key = ?').get(owner.partition, entry.key); + if (newer && Number(newer.captured_at) > snapshot.capturedAt) continue; + write.run(owner.partition, entry.key, entry.source, entry.kind, JSON.stringify(entry.payload), snapshot.capturedAt); + entry.bytes.forEach((item, ordinal) => writeAttachment.run(owner.partition, entry.key, ordinal, item.name, item.mimeType, item.content)); + } + const storedBytes = this.#db.prepare('SELECT COALESCE(SUM(length(content)), 0) AS bytes FROM workspace_attachments').get(); + if (Number(storedBytes?.bytes) > 256 * 1024 * 1024) throw new AttachmentIngestBlockedError('total_size_exceeded'); + this.#db.exec('COMMIT'); + } catch (error) { + this.#db.exec('ROLLBACK'); + throw error; + } + } + + load(owner: SessionWorkspaceOwner, sessionIds: readonly string[]): SessionWorkspaceSnapshot { + const drafts: SessionWorkspaceDraft[] = []; + const sideConversations: SideConversationWorkspaceState[] = []; + const workbar: Record = {}; + const readingAnchors: Record = {}; + let capturedAt = 0; + const issued: string[] = []; + const restore = (key: string, draft: StoredDraft): SessionWorkspaceDraft => ({ + ...draft, + attachments: draft.attachments.map((item): SessionWorkspaceAttachment => { + if (item.type === 'retained') return item; + const row = this.#db.prepare('SELECT name, mime_type, content FROM workspace_attachments WHERE partition = ? AND item_key = ? AND ordinal = ?').get(owner.partition, key, item.ordinal); + if (!row) throw new Error('Saved workspace attachment is missing'); + const approval = this.sources.recovery.issue(this.#recoveryOwner(owner, draft.sessionId), [{ + name: String(row.name), mimeType: String(row.mime_type), content: row.content as Uint8Array, + }])[0]!; + issued.push(approval.approvalId); + return { type: 'approval', ...approval }; + }), + }); + try { + for (const sessionId of new Set(sessionIds)) { + this.#recoveryOwner(owner, sessionId); + const uiRow = this.#db.prepare('SELECT payload FROM workspace_ui WHERE partition = ? AND session_key = ?').get(owner.partition, sessionId); + if (uiRow) { + const ui = JSON.parse(String(uiRow.payload)); + if (ui.workbar) workbar[sessionId] = ui.workbar; + if (ui.readingAnchor) readingAnchors[sessionId] = ui.readingAnchor; + } + const rows = this.#db.prepare('SELECT item_key, kind, payload, captured_at FROM workspace_states WHERE partition = ? AND source_key = ?').all(owner.partition, sessionId); + let hasDraft = false; + for (const row of rows) { + capturedAt = Math.max(capturedAt, Number(row.captured_at)); + if (row.kind === 'draft') { + drafts.push(restore(String(row.item_key), JSON.parse(String(row.payload)) as StoredDraft)); + hasDraft = true; + } else { + const side = JSON.parse(String(row.payload)) as StoredSide; + sideConversations.push({ ...side, draft: restore(String(row.item_key), side.draft) }); + } + } + if (!hasDraft) drafts.push({ sessionId, text: '', references: [], attachments: [], directoryReferences: [], quotes: [] }); + } + return { capturedAt, drafts, sideConversations, workbar, readingAnchors }; + } catch (error) { + this.sources.recovery.release(owner.senderId, issued); + throw error; + } + } + + /** Discard destination approvals after an interrupted or failed restoration. */ + releaseSnapshot(senderId: number, snapshot: SessionWorkspaceSnapshot): void { + const ids = new Set(); + for (const draft of [...snapshot.drafts, ...snapshot.sideConversations.map((side) => side.draft)]) { + for (const item of draft.attachments) { + if (item.type === 'approval' && item.approvalId.startsWith('local-recovery:')) ids.add(item.approvalId); + } + } + this.sources.recovery.release(senderId, [...ids]); + } + + delete(partition: string, sessionIds: readonly string[]): void { + for (const key of sessionIds) { + this.#deleted.add(JSON.stringify([partition, key])); + this.#db.prepare('DELETE FROM workspace_states WHERE partition = ? AND source_key = ?').run(partition, key); + this.#db.prepare('DELETE FROM workspace_ui WHERE partition = ? AND session_key = ?').run(partition, key); + } + } + + clear(partition?: string): void { + this.#revision += 1; + if (partition === undefined) this.#db.exec('DELETE FROM workspace_states; DELETE FROM workspace_ui'); + else { + this.#db.prepare('DELETE FROM workspace_states WHERE partition = ?').run(partition); + this.#db.prepare('DELETE FROM workspace_ui WHERE partition = ?').run(partition); + } + } + close(): void { this.#db.close(); } +} diff --git a/apps/desktop/src/main/workspace-window-registry.ts b/apps/desktop/src/main/workspace-window-registry.ts new file mode 100644 index 00000000000..239506e13a0 --- /dev/null +++ b/apps/desktop/src/main/workspace-window-registry.ts @@ -0,0 +1,82 @@ +/* + * 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 { BrowserWindow, WebContents } from 'electron'; +import type { SessionWindowContext } from '../shared/session-window-contract.js'; +import type { WorkspaceWindowController } from './main-window.js'; + +export interface WorkspaceWindowEntry { + readonly window: BrowserWindow; + readonly controller: WorkspaceWindowController; + readonly context: SessionWindowContext; +} + +/** Only trusted workspace surfaces participate; auxiliary windows remain separate. */ +export function createWorkspaceWindowRegistry() { + const windows = new Map(); + const registeredListeners = new Set<(entry: WorkspaceWindowEntry) => void>(); + let lastFocusedId: string | undefined; + return { + register(entry: WorkspaceWindowEntry): void { + if (entry.window.isDestroyed()) throw new Error('Cannot register a destroyed workspace window'); + if (entry.context.kind === 'main' && [...windows.values()].some((item) => item.context.kind === 'main')) { + throw new Error('The main workspace window already exists'); + } + windows.set(entry.context.windowId, entry); + for (const listener of registeredListeners) listener(entry); + entry.window.on('focus', () => { lastFocusedId = entry.context.windowId; }); + entry.window.once('closed', () => { + windows.delete(entry.context.windowId); + if (lastFocusedId === entry.context.windowId) lastFocusedId = undefined; + }); + }, + onRegistered(listener: (entry: WorkspaceWindowEntry) => void): () => void { + registeredListeners.add(listener); + return () => registeredListeners.delete(listener); + }, + entries(): WorkspaceWindowEntry[] { + return [...windows.values()].filter((entry) => !entry.window.isDestroyed()); + }, + forSender(sender: WebContents): WorkspaceWindowEntry | undefined { + if (sender.isDestroyed()) return undefined; + return this.entries().find((entry) => entry.window.webContents === sender); + }, + get(windowId: string): WorkspaceWindowEntry | undefined { + const entry = windows.get(windowId); + return entry && !entry.window.isDestroyed() ? entry : undefined; + }, + main(): WorkspaceWindowEntry | undefined { + return this.entries().find((entry) => entry.context.kind === 'main'); + }, + focused(): WorkspaceWindowEntry | undefined { + return this.entries().find((entry) => entry.window.isFocused()); + }, + active(): WorkspaceWindowEntry | undefined { + return this.focused() ?? (lastFocusedId === undefined ? undefined : this.get(lastFocusedId)) ?? this.main(); + }, + sendCommand(channel: string, ...args: unknown[]): boolean { + const target = this.active(); + if (!target || target.window.webContents.isDestroyed()) return false; + target.window.webContents.send(channel, ...args); + return true; + }, + }; +} + +export const workspaceWindowRegistry = createWorkspaceWindowRegistry(); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 5287b9e34f0..2bc427f8844 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -344,6 +344,8 @@ export type RendererIngestInput = export type DesktopBranchFromTurnInput = BranchFromTurnInput & { /** Stable target identity for retrying one Desktop copy action. */ copyId: string; + /** Existing side-draft handles transferred by Main after this exact copy succeeds. */ + recoveryAttachmentIds?: readonly string[]; }; export type DesktopSideConversationBranchResult = @@ -1912,7 +1914,7 @@ export interface MakaBridge { * rejection by showing the disabled / fallback form. */ }; - appWindow: { + appWindow: import('../shared/session-window-contract.js').SessionWindowBridge & { popupMenu(input: import('../shared/native-menu.js').NativeMenuRequest): Promise; setTitlebarControlsVisible(visible: boolean): Promise; setThemeSource(themePref: ThemePreference): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index aba743dc2cd..149882e5ae6 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3878,6 +3878,20 @@ const makaBridge = { }, }, appWindow: { + context: () => invokeWhenReady('session-window:context'), + routeSession: (navigation) => invokeWhenReady('session-window:route', navigation), + openSessionWindow: (sessionId) => invokeWhenReady('session-window:open', sessionId), + openInMainWindow: () => invokeWhenReady('session-window:open-main'), + sessionWindowReady: () => invokeWhenReady('session-window:ready'), + subscribeSessionWindowCommand(handler) { + const listener = (_event: Electron.IpcRendererEvent, command: import('../shared/session-window-contract.js').SessionWindowCommand) => handler(command); + ipcRenderer.on('session-window:command', listener); + return () => ipcRenderer.off('session-window:command', listener); + }, + ackSessionWindowCommand: (reply) => invokeWhenReady('session-window:reply', reply), + saveWorkspaceSnapshot: (snapshot) => invokeWhenReady('session-window:save', snapshot), + loadWorkspaceSnapshot: (sessionIds) => invokeWhenReady('session-window:load', sessionIds), + commandMain: (command) => invokeWhenReady('session-window:command-main', command), popupMenu(input: import('../shared/native-menu.js').NativeMenuRequest): Promise { return invokeWhenReady('window:popupMenu', input); }, diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index f59dc758a5c..76c2a919514 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -31,7 +31,7 @@ import { } from './platform/desktop/default-runtime-host-operation.js'; import { buildCommandList } from "./command-palette-commands.js"; import type { CopyManualDiagnosticReport } from './features/diagnostics/index.js'; -import type { Command, OverlayPaletteActions } from './features/overlays/index.js'; +import type { OverlayPaletteActions } from './features/overlays/index.js'; import type { SessionCatalogController } from './application/contracts/session-catalog/session-catalog-state.js'; import { commandPaletteActionErrorMessage, @@ -61,6 +61,7 @@ type ComposerImportOwner = { type RefBox = { current: T }; export interface AppShellCommandListOptions { + standalone?: boolean; uiLocale: UiLocale; activeId: string | undefined; activePermissionMode: PermissionMode | undefined; @@ -132,6 +133,7 @@ export function buildAppShellCommandList( const copy = getShellCopy(locale).commandActions; return buildCommandList({ + allowGlobalNavigation: !options.standalone, locale, activeSessionId: options.activeId, themePref: options.themePref, @@ -392,13 +394,7 @@ export function buildAppShellCommandList( export function useAppShellCommands( paletteOpen: boolean, commandOptions: AppShellCommandListOptions, -): { - commands: Command[]; - sessionCatalog: SessionCatalogController; - hiddenSessionIds: ReadonlySet; - activeSessionId: string | undefined; - onSelectSession: (id: string) => void; -} { +) { const optionsRef = useRef(commandOptions); optionsRef.current = commandOptions; const { uiLocale } = commandOptions; diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 0232d8a273b..7a8b456412c 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -65,17 +65,14 @@ export function useAppShellHostEffects() { } export function useAppShellPersistenceEffects(options: { - navigationState: NavigationState; + navigationState?: NavigationState; themePalette: ThemePalette; themePref: ThemePreference; }) { // Keep in sync with the active preference. The Settings // modal also calls applyTheme on local change so the effect is immediate, // but this keeps the listener for 'auto' alive at the app level. - useEffect(() => { - const unsubscribe = applyTheme(options.themePref); - return unsubscribe; - }, [options.themePref]); + useEffect(() => applyTheme(options.themePref), [options.themePref]); // PR-THEME-APPLY-AND-DONE-POLISH-0 (WAWQAQ msg `dec85e5b`): re-apply the // palette data attribute whenever the persisted setting changes, so @@ -90,7 +87,7 @@ export function useAppShellPersistenceEffects(options: { // Strict localStorage availability check — Vite dev sometimes runs through // a worker where it isn't defined. useEffect(() => { - safeLocalStorageSet('maka-nav-selection-v1', JSON.stringify(options.navigationState)); + if (options.navigationState) safeLocalStorageSet('maka-nav-selection-v1', JSON.stringify(options.navigationState)); }, [options.navigationState]); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index caf41f824c8..a42f552da06 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -31,16 +31,13 @@ import { type SetStateAction, } from 'react'; import type { QuoteRef } from '@maka/core/events'; -import type { OrchestrationMode } from '@maka/core/orchestration'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; -import { collapseSessionRevisions } from '@maka/core/session-revisions'; import { isLinkedSubagentSession } from '@maka/core/session'; import { resolveUiLocale } from '@maka/core/ui-locale'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { ChatSurfaceLayout, type ComposerHandle, - type MakaUriDest, MakaUriContext, AstryxLocaleProvider, LocaleProvider, @@ -60,11 +57,13 @@ import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery import { AgentGraphPanel } from './agent-graph-panel'; import { ChatComposerRegion } from './chat-composer-region'; import { WorkbarHost, WorkbarProvider, WorkbarShellRoot, type WorkbarShellProjection } from './features/workbar'; +import { SessionWindowRoot, SessionWindowBinding, SessionWindowEmptyState, type SessionWindowProjection } from './features/session-windows/index.js'; import { AppUpdateProvider } from './features/app-update/index.js'; import * as Goals from './features/goals'; import * as ModuleHub from './features/module-hub'; import { SessionNavigationProvider, + SessionNavigationCommands, createSessionOpenCommand, sessionRailLayoutStore, useSessionNavigationReads, @@ -97,7 +96,7 @@ import * as Diagnostics from './features/diagnostics/index.js'; import { getDesktopConversationCopy } from './application/contracts/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; -import { SessionSettingsProvider, useSessionSettingIntent } from './features/session-settings'; +import { SessionSettingsProvider, useSessionSettingIntent, createSessionModeCommands, deriveSessionSettingsPresentation } from './features/session-settings'; import { pendingSessionView } from './pending-session-view'; import { readScrollMotionBehavior } from './scroll-motion-policy'; import { readNavigationState, selectNavigation } from './nav-selection'; @@ -136,12 +135,6 @@ import { useShellConnections } from './use-shell-connections'; import { useSystemUiLocale } from './use-system-ui-locale'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; -type ComposerImportOwner = { - sessionId: string | undefined; - navSection: NavSelection['section']; - newTaskDraftKey?: string; -}; - export function AppShell() { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); @@ -159,8 +152,10 @@ export function AppShell() { - - + + {(sessionWindow) => <> + {!sessionWindow.standalone && } + {(taskEntry) => ( {(overlays) => ( @@ -168,13 +163,13 @@ export function AppShell() { {(sharedSessionDialog) => ( {(workbar) => ( - + {(onboarding) => ( {(copyManualDiagnosticReport) => ( )} @@ -188,7 +183,8 @@ export function AppShell() { )} )} - + } + @@ -209,6 +205,7 @@ export function AppShell() { const SESSION_RAIL = ; function AppShellContent({ + sessionWindow, taskEntry, overlays, sharedSessionDialog, @@ -219,6 +216,7 @@ function AppShellContent({ setUiLocaleOverride, setUiLocalePreference, }: { + sessionWindow: SessionWindowProjection; taskEntry: TaskEntryShellProjection; overlays: OverlaysShellProjection; sharedSessionDialog: SessionCollaborationDialogProjection; @@ -241,7 +239,7 @@ function AppShellContent({ refreshChangedSession, activeId, activeIdRef, - bootstrapSelectionLease, + readSelectionRevision, setActiveId, startNewSession, clearOwnedSessionState, @@ -264,18 +262,21 @@ function AppShellContent({ const sessionCount = authoritativeSessionIds?.size ?? 0; const { - openHelp, + openHelp: openLocalHelp, closePalette, openSearch, setSearchScrollTarget, - openSettings, - openSettingsSection, + openSettings: openLocalSettings, + openSettingsSection: openLocalSettingsSection, openProjectSettings, openProviderCatalog, openConnectionDetail, openProviderCreate, setSettingsProfileId, } = overlays.commands; + const openHelp = sessionWindow.standalone ? () => sessionWindow.commandMain('openHelp') : openLocalHelp; + const openSettings = sessionWindow.standalone ? () => sessionWindow.commandMain('openSettings') : openLocalSettings; + const openSettingsSection = sessionWindow.standalone ? openSettings : openLocalSettingsSection; const { searchScrollTarget } = overlays.selectors; const settingsOpen = overlays.selectors.settings.open; @@ -297,15 +298,16 @@ function AppShellContent({ const composerStaging = useMemo(Conversation.createComposerStagingCommands, []); const composerSubmission = useMemo(Conversation.createComposerSubmissionCommands, []); const [petCompletionNonce, setPetCompletionNonce] = useState(0); - const [navigationState, setNavigationState] = useState(() => readNavigationState()); + const [navigationState, setNavigationState] = useState(() => readNavigationState(!sessionWindow.standalone)); const navSelection = navigationState.selection; const sessionsSelected = navSelection.section === 'sessions'; const setNavSelection = useCallback>>((nextSelection) => { + if (sessionWindow.standalone) return; setNavigationState((current) => selectNavigation( current, typeof nextSelection === 'function' ? nextSelection(current.selection) : nextSelection, )); - }, []); + }, [sessionWindow.standalone]); const navSelectionRef = useRef(navSelection); // Navigation only: whether WorkHub is enabled at all is the client switch // the WorkHub enablement authority owns (see WorkHubEnablementWatch below). @@ -422,20 +424,8 @@ function AppShellContent({ const composerEditing = queueSurface.editing; const rendererMountedRef = useRef(true); const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands; - const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration; - const activeSessionForModelControls = activeSession - ? { - ...activeSession, - ...(modelConfigurationOverlay - ? { - llmConnectionId: modelConfigurationOverlay.modelTarget.llmConnectionId, - llmConnectionSlug: modelConfigurationOverlay.modelTarget.llmConnectionSlug, - model: modelConfigurationOverlay.modelTarget.model, - thinkingLevel: modelConfigurationOverlay.thinkingLevel ?? undefined, - } - : {}), - } - : undefined; + const { activeSessionForModelControls, activePlanMode, activeOrchestrationMode } = + deriveSessionSettingsPresentation(activeSession, activeId, sessionSettingIntent); // Surface a credential-lifecycle alert directly in the chat header when // the active session's connection is in `needs_reauth` / `error` or has // been deleted entirely with no usable default. Main resolves credential @@ -516,43 +506,10 @@ function AppShellContent({ sessionSettingIntent.commands.clear(sessionId); } - // Stable: the rail's row actions are built from it, and it only reaches - // registries and refs that are themselves stable (#4109). - function setPlanMode(active: boolean): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - sessionSettingIntent.commands.setNewTaskPlanMode(active); - return Promise.resolve(true); - } - if (active === activePlanMode) return Promise.resolve(true); - return sessionSettingIntent.commands.setPlanMode(sessionId, active); - } - - /** - * The + menu's orchestration choice and the `/swarm` and `/graph` commands - * all land here, so every entry point spells the field the same way. - * - * `/swarm off` means "leave swarm", not "go to default": a Session already - * in Graph has nothing for it to do. - */ - function setOrchestrationMode(mode: OrchestrationMode): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - sessionSettingIntent.commands.setNewTaskOrchestrationMode(mode); - return Promise.resolve(true); - } - if (mode === activeOrchestrationMode) return Promise.resolve(true); - return sessionSettingIntent.commands.setOrchestrationMode(sessionId, mode); - } - - function setOrchestrationModeActive( - mode: Exclude, - active: boolean, - ): Promise { - if (active) return setOrchestrationMode(mode); - if (activeOrchestrationMode !== mode) return Promise.resolve(true); - return setOrchestrationMode('default'); - } + const { setPlanMode, setOrchestrationMode, setOrchestrationModeActive } = createSessionModeCommands({ + sessionId: () => activeIdRef.current, planMode: () => activePlanMode, + orchestrationMode: () => activeOrchestrationMode, commands: sessionSettingIntent.commands, + }); const openSessionInChatRef = useRef< (sessionId: string, turnId?: string, sequence?: number) => void @@ -597,18 +554,6 @@ function AppShellContent({ permissionMode: newSessionPermissionMode, }) : undefined); - // Each control reads its own field. There is nothing to project and nothing - // to keep in sync: a Session in Plan with Swarm as its orchestration default - // says both, because it is both. - const activePlanMode = activeId - ? sessionSettingIntent.overlay.planMode - ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') - : newTaskSettings.planMode; - const activeOrchestrationMode: OrchestrationMode = activeId - ? sessionSettingIntent.overlay.orchestrationMode - ?? activeSessionForView?.orchestrationMode - ?? 'default' - : newTaskSettings.orchestrationMode; const { boundary: activeExecutionBoundary, unreadable: activeExecutionBoundaryUnreadable, @@ -741,8 +686,9 @@ function AppShellContent({ }, [composerEditing, composerStaging, sessionSettingIntent.commands, setNavSelection, setSearchScrollTarget, startNewSession]); const createSession = useCallback(async () => { - openNewTaskSurface(); - }, [openNewTaskSurface]); + if (sessionWindow.standalone) sessionWindow.commandMain('newTask'); + else openNewTaskSurface(); + }, [openNewTaskSurface, sessionWindow.standalone, sessionWindow.commandMain]); // Stable, because the rail's Project rows carry it: a fresh identity here // rebuilt the whole list on every AppShell commit (#4109). @@ -782,7 +728,7 @@ function AppShellContent({ const hasModalOpen = overlays.selectors.anyModalOpen || sharedSessionDialog.isOpen; const shellObscured = hasModalOpen || settingsOpen; const exitWorkHub = useCallback(() => setWorkHubActive(false), []); - const openSession = useMemo( + const openLocalSession = useMemo( () => createSessionOpenCommand({ activateSession: setActiveId, @@ -792,6 +738,9 @@ function AppShellContent({ }), [exitWorkHub, setNavSelection, setActiveId, setSearchScrollTarget], ); + const openSession = useCallback((sessionId: string, turnId?: string, sequence?: number) => { + sessionWindow.openSession({ sessionId, turnId, sequence }, () => openLocalSession(sessionId, turnId, sequence)); + }, [sessionWindow.openSession, openLocalSession]); useLayoutEffect(() => { openSessionInChatRef.current = openSession; }, [openSession]); @@ -800,6 +749,7 @@ function AppShellContent({ // their identity carries no information and this object never has to be // held still by hand (#4109). const sessionNavigationPorts: SessionNavigationPorts = { + openInNewWindow: sessionWindow.openInNewWindow, sessionsRef, acquireAutomaticQueryBlock: sessionCatalogController.acquireAutomaticQueryBlock, clearSessionRendererState, @@ -879,89 +829,16 @@ function AppShellContent({ toastApi, }); useAppShellPersistenceEffects({ - navigationState, + navigationState: sessionWindow.standalone ? undefined : navigationState, themePalette, themePref, }); - function captureComposerImportOwner(): ComposerImportOwner { - return { - sessionId: activeIdRef.current, - navSection: navSelectionRef.current.section, - ...(activeIdRef.current === undefined - ? { newTaskDraftKey: currentNewTaskDraftKey } - : {}), - }; - } - - /** - * "Is this owner still the surface the user is looking at." One rule, both - * halves: an async result that lands after the user moved on must not toast, - * navigate or steal focus, and `selectNavigation` never clears `activeId` - * (nav-selection.ts) — so the session id alone answers yes long after the - * user left for 扩展 or 设置. - * - * The two below are this same question with a precondition on what KIND of - * owner the caller wants, not second opinions about the question. They were - * three independent spellings once, and the one that re-derived it from an - * id drifted: it lost the section half, which is exactly what let a failed - * send pull a user out of 技能 and into 设置 · 模型. - */ - function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean { - return navSelectionRef.current.section === owner.navSection && - isSessionSelected(owner.sessionId) && - (owner.sessionId !== undefined || owner.newTaskDraftKey === currentNewTaskDraftKey); - } + const { captureComposerImportOwner, isShellSurfaceOwnerActive, isComposerImportOwnerActive, isNewChatSendSurfaceActive } = + Conversation.createComposerSurfaceOwner({ activeIdRef, navSelectionRef, newTaskDraftKey: currentNewTaskDraftKey, isSessionSelected }); - /** …and the owner was captured on the chat surface. */ - function isComposerImportOwnerActive(owner: ComposerImportOwner): boolean { - return owner.navSection === 'sessions' && isShellSurfaceOwnerActive(owner); - } + function bootstrapSessions() { return sessionWindow.bootstrap(refreshSessions, readSelectionRevision, openSession); } - /** …and it was the new-chat surface, which by definition has no session. */ - function isNewChatSendSurfaceActive(owner: ComposerImportOwner): boolean { - return owner.sessionId === undefined && isComposerImportOwnerActive(owner); - } - - async function bootstrapSessions() { - const next = await refreshSessions(); - bootstrapSelectionLease.reconcile(collapseSessionRevisions(next)); - bootstrapSelectionLease.release(); - } - - /** - * PR-UI-RENDER-2 - single chokepoint for the Markdown internal-URI - * router. Receives a typed `MakaUriDest` from the link override in - * `` and dispatches to the existing app navigation - * surfaces: - * - * - `kind: 'settings'` → `openSettingsSection(section)` (existing - * Settings modal jump, persisted via localStorage). - * - `kind: 'compose'` → replace the composer's text through - * `composerEditing.replaceText(...)` and focus it. We do NOT - * auto-submit the prompt; the user still presses Enter. That - * keeps an injected `maka://compose?text=ransfer my keys...` - * from sending without a human in the loop. - * - * No other cases exist today by design — the parser only emits - * these two discriminants. If a new variant is added in `MakaUriDest`, - * TypeScript's exhaustiveness check below trips and a new branch - * must be wired here with corresponding fixture and journey coverage. - */ - function dispatchMakaUri(dest: MakaUriDest) { - switch (dest.kind) { - case 'settings': - openSettingsSection(dest.section); - return; - case 'compose': - composerEditing.replaceText(dest.text); - composerEditing.focus(); - return; - default: { - const _exhaustive: never = dest; - return _exhaustive; - } - } - } + const dispatchMakaUri = Conversation.createComposerUriDispatcher(openSettingsSection, composerEditing); function closeSettings() { overlays.commands.closeSettings(); @@ -1026,6 +903,7 @@ function AppShellContent({ // `ConversationHomeSurface` reads for the displayed Session. const homeSurfaceEligible = sessionsSelected && transcriptEmpty; const commandOptions: AppShellCommandListOptions = { + standalone: sessionWindow.standalone, uiLocale, activeId, activePermissionMode, @@ -1174,9 +1052,10 @@ function AppShellContent({ - + {(conversation) => openLocalSession(navigation.sessionId, navigation.turnId, navigation.sequence)} + clearLocal={() => { setNavSelection({ section: 'sessions' }); setSearchScrollTarget(null); }} /> + } + {sessionWindow.standalone && } + {!sessionWindow.standalone && } void defaultHostConnections.refreshConnections()} /> - { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} /> + {!sessionWindow.standalone && { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} />} sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} onOpenSearchModal={openSearch} workbar={{ togglePosition: workbarTogglePosition }} @@ -1277,7 +1167,7 @@ function AppShellContent({ mobileNav={{ breakpoint: 'none', hasToggle: false }} aria-hidden={shellObscured} inert={shellObscured} - sideNav={ + sideNav={sessionWindow.standalone ? undefined : ( - commands.toggleTool('inspector')} onToggleWorkbar={commands.toggleRightPanel} onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} /> - + } + {sessionWindow.standalone && !activeId && }