diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index ac3734fb0c6..8dbc57c7afb 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": "revealing an existing hidden BrowserWindow, native close and main recreation, real WebContentsView identity across renderer/main transfer, and restart with neither restored session windows nor persisted handoff drafts" } } } diff --git a/apps/desktop/e2e/session-windows.spec.ts b/apps/desktop/e2e/session-windows.spec.ts new file mode 100644 index 00000000000..e4c89ff8e94 --- /dev/null +++ b/apps/desktop/e2e/session-windows.spec.ts @@ -0,0 +1,118 @@ +/* + * 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 reveals a hidden 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.hide()); + await expect.poll(() => childNative.evaluate((window) => window.isVisible())).toBe(false); + await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id); + // Inactive E2E fixtures reveal without taking OS focus; active-mode + // minimized restoration is covered by the production reveal-gate test. + 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, +// including in-memory handoff between renderers and a fresh main-process lifetime. +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('draft moving back to main'); + 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('draft moving back to main'); + const secondChild = await detach(app, main, id); + await expect(secondChild.locator(COMPOSER_INPUT)).toHaveText('draft moving back to main'); + // Closing a child discards its local drafts; retention is intentionally outside this version. + await secondChild.locator(COMPOSER_INPUT).fill(''); + 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(''); + 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(''); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index e65b9627155..7248a975eb8 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -348,6 +348,7 @@ "ConversationMessageConsumer", "ConversationProvider", "ConversationTranscriptRegion", + "ConversationWindowState", "NEW_TASK_PENDING_KEY", "PlanExecutionSurface", "PlanProvider", @@ -456,8 +457,9 @@ }, "src/renderer/features/session-navigation/index.ts": { "appShell": [ + "SessionNavigationCommands", "SessionNavigationProvider", - "createSessionOpenCommand", + "createSessionNavigationCommands", "sessionRailLayoutStore", "useSessionNavigationReads" ], @@ -474,6 +476,16 @@ "SessionSettingsServicesProvider" ] }, + "src/renderer/features/session-windows/index.ts": { + "appShell": [ + "SessionWindowBinding", + "SessionWindowEmptyState", + "SessionWindowRoot" + ], + "composition": [ + "SessionWindowServicesProvider" + ] + }, "src/renderer/features/storage-usage/index.ts": { "appShell": [ "ArchiveRetentionNotices" @@ -535,7 +547,7 @@ "@maka/ui": 1 }, "importSpecifiers": 0, - "nonTriviaTokens": 122 + "nonTriviaTokens": 117 }, "src/renderer/app-shell-command-actions.ts": { "importDeclarations": 4, @@ -559,7 +571,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2143 + "nonTriviaTokens": 2117 }, "src/renderer/app-shell-copy.ts": { "importDeclarations": 1, @@ -621,7 +633,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 843 + "nonTriviaTokens": 841 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 4, @@ -650,10 +662,10 @@ "nonTriviaTokens": 780 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 34, + "importDeclarations": 33, "bridgePaths": {}, "environmentCapabilities": { - "window.requestAnimationFrame": 3 + "window.requestAnimationFrame": 2 }, "hookCalls": { "useActiveExecutionBoundary": 1, @@ -663,8 +675,7 @@ "useAppShellPersistenceEffects": 1, "useAppShellProjectContext": 1, "useAppShellSessionWorkspace": 1, - "useLayoutEffect": 1, - "useRef": 4, + "useRef": 3, "useSessionNavigationReads": 1, "useSessionSettingIntent": 1, "useShellAppearance": 1, @@ -708,6 +719,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, @@ -734,13 +746,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": 7274 + "importSpecifiers": 41, + "nonTriviaTokens": 7244 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 3, diff --git a/apps/desktop/scripts/workhub-browser-presentation-smoke.mjs b/apps/desktop/scripts/workhub-browser-presentation-smoke.mjs index 10afa229fe8..1b62be48d02 100644 --- a/apps/desktop/scripts/workhub-browser-presentation-smoke.mjs +++ b/apps/desktop/scripts/workhub-browser-presentation-smoke.mjs @@ -29,9 +29,9 @@ import { createServer } from 'node:http'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { app, BrowserWindow, desktopCapturer, screen } from 'electron'; +import { app, BrowserWindow, desktopCapturer, screen, View } from 'electron'; import { createWorkHubPresentation } from '../dist/main/workhub-presentation.js'; -import { desktopSessionResourceKey } from '../dist/shared/runtime-host-identity.js'; +import { desktopSessionKey, desktopSessionResourceKey } from '../dist/shared/runtime-host-identity.js'; import { registerBrowserIpc } from '../dist/main/browser-ipc-main.js'; import { BrowserViewController } from '../dist/main/browser/controller.js'; import { BrowserViewManager } from '../dist/main/browser/view-manager.js'; @@ -74,18 +74,22 @@ async function run() { }); presentation.registerIpc(); presentation.attachMainWindow(main); + const isMainRenderer = (wc) => !main.isDestroyed() && wc === main.webContents; registerBrowserIpc({ mainWindowController: { getBrowserViews: (resolve) => { parentForSession = resolve; return views; }, - isMainRenderer: (wc) => !main.isDestroyed() && wc === main.webContents, + isMainRenderer, + isWorkspaceRenderer: isMainRenderer, }, auxiliaryWindowRegistry: { rendererParent: (wc) => wc === owner ? ownerParent : undefined, }, isHostActive: (ref) => ref.hostId === scope.hostId && ref.targetEpoch === scope.targetEpoch, + canUseSession: (wc, id) => isMainRenderer(wc) && id === desktopSessionKey({ ...scope, sessionId }), + parkingParent: new View(), }); const host = { visible: true, rect: { x: 0, y: 0, width: 470, height: 700 } }; const rect = { x: 500, y: 80, width: 430, height: 560 }; 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 b538eac3b80..a62f9090743 100644 --- a/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts @@ -158,14 +158,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(WORKHUB_COORDINATION_SESSION_ID) && (!id.includes('main-session') || contents === sessionOwner), mainWindowController: mainWindowController as never, auxiliaryWindowRegistry: { rendererParent: (contents: Electron.WebContents) => @@ -358,8 +364,7 @@ test('browser IPC isolates owned renderer documents and their native parents', a assert.equal(controllers.get(mainKey), mainController, 'destroying WorkHub preserves the main browser session'); assert.equal(mainController.disposed, false); - // Closing Main destroys its WebContents before selection cleanup runs. - // Electron rejects fromWebContents on that destroyed native object. + // Collapsing the browser keeps the selected Session's background lease. emit('browser:active-session', main, scope, 'main-session', 'main-document', 6); emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 6); emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: null }, 'main-document', 6); @@ -389,10 +394,51 @@ test('browser IPC isolates owned renderer documents and their native parents', a resolveNext({ scope, sessionId: 'replacement' }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(await browserViewHost().canDrive(mainKey, 'observe'), false); + + // Transfer the selected page after the pending-selection scenario settles. + emit('browser:active-session', main, scope, 'main-session', 'main-document', 13); + emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 13); + 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'); + emit('browser:active-session', child, scope, WORKHUB_COORDINATION_SESSION_ID, 'child-document', 1); + await invoke('browser:navigate', child, scope, WORKHUB_COORDINATION_SESSION_ID, 'https://forbidden-presenter.example/'); + assert.equal(controllers.has(coordinationKey), false, 'a child cannot recreate or present the WorkHub Coordination browser'); + emit('browser:document-ready', child, 'child-document-owning-session'); + 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-owning-session', 1); + emit('browser:setViewport', child, scope, { sessionId: 'main-session', rect: mainRect }, 'child-document-owning-session', 1); + emit('browser:active-session', main, scope, 'main-session', 'main-document', 14); + emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 14); + 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/']); + childWindow.minimized = true; + browserIpc.refreshVisibility(); + assert.equal(await browserViewHost().canDrive(mainKey, 'mutate'), true, 'the selected child follows the same background policy as Main'); + emit('browser:hide-active-session', child, 'child-document-owning-session', 2); + assert.equal(await browserViewHost().canDrive(mainKey, 'mutate'), false, 'clearing the child selection revokes background access after transfer'); + childWindow.minimized = false; + emit('browser:active-session', child, scope, 'main-session', 'child-document-owning-session', 3); + emit('browser:setViewport', child, scope, { sessionId: 'main-session', rect: mainRect }, 'child-document-owning-session', 3); + // Closing Main destroys its WebContents before selection cleanup runs. + // Electron rejects fromWebContents on that destroyed native object. 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(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'), false); + assert.equal(await browserViewHost().canDrive(mainKey, 'mutate'), false); + 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 39443cbff8e..258abe4c498 100644 --- a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts @@ -274,6 +274,7 @@ test('StagedComposer keeps the submitted recovery attachment while waiting for S assert.ok(form); form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); }); + assert.equal(view.composer.current!.hasPendingAction(), true, 'the handoff guard includes reference preparation'); await act(() => view.removeAttachment()); assert.deepEqual(released, [], 'early Composer ownership protects the approval before onSend starts'); assert.deepEqual(submissions, []); @@ -282,7 +283,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'); + assert.equal(view.composer.current!.hasPendingAction(), true, 'the handoff guard includes pending admission'); await act(async () => { finishAdmission(false); await sent; }); + assert.equal(view.composer.current!.hasPendingAction(), false); assert.deepEqual(released, [['local-recovery:waiting-reference']]); }); @@ -411,6 +414,7 @@ async function mountNavigatingRecovery(text = 'original text') { return createElement(Fragment, null, editor && createElement(StagedComposer, { ref: queue.composer, draftKey, disabled, + pendingSessionReferences: mentions.pendingSessionReferences, onSend: async (body, metadata, context) => { sent.push({ text: body, metadata, staging: context }); return false; }, onStop() {}, stagingEnabled: true, canStageContext: true, contextPickEnabled: true, directoryPickerEnabled: true, allowAttachmentOnlySend: true, @@ -885,6 +889,53 @@ test('Desktop backs the attachment port with the bridge reader; AppShell no long assert.deepEqual(shell.split('\n').filter((line) => /\battachments\s*\.\s*readBytes\b|\bonReadAttachmentBytes\b/.test(line)), []); }); +for (const kind of ['attachments', 'directories', 'quotes'] as const) { + test(`window handoff detects ${kind} on a non-displayed family member`, async () => { + const view = await mount(); + await act(() => { + if (kind === 'quotes') view.commands.addQuote({ text: 'unsent quote' }); + else view.stage(kind === 'attachments' ? { attachments: [attachment] } + : { directoryReferences: [{ hostId: 'host-a', path: '/work/source' }] }); + }); + await view.render('draft-b'); + assert.equal(view.commands.hasPendingContext(['draft-b']), false); + assert.equal(view.commands.hasPendingContext(['draft-a', 'draft-b']), true); + await view.render('draft-a'); + await act(() => { + const submitted = view.commands.captureSubmission(); + submitted.clearSubmittedContext(submitted.submittableAttachments); + submitted.clearQuotes(); + }); + assert.equal(view.commands.hasPendingContext(['draft-a', 'draft-b']), false); + }); +} + +test('pending session-reference chips block moving or silently closing an otherwise empty composer', async () => { + const h = await mountNavigatingRecovery(); + await act(async () => h.mentions.onPickSessionReference!({ id: 'draft-b', name: 'B' })); + assert.equal(h.composer.current!.getText(), ''); + assert.equal(h.commands.hasPendingContext(['draft-a']), false); + assert.equal(h.composer.current!.hasPendingAction(), true); + await act(() => h.mentions.onRemovePendingSessionReference('draft-b')); + assert.equal(h.composer.current!.hasPendingAction(), false); +}); + +test('local Edit recovery blocks handoff until its existing claim settles, including background A', async () => { + const h = await mountNavigatingRecovery(); + await h.edit(); + assert.equal(h.composer.current!.getText(), ''); + assert.equal(h.commands.hasPendingContext(['draft-a']), false); + assert.equal(h.composer.current!.hasPendingAction(), true); + await h.render('draft-b'); + assert.equal(h.composer.current!.hasPendingAction(), true); + await h.fail(); + assert.equal(h.composer.current!.hasPendingAction(), false); + await h.render('draft-a'); await h.edit(); await h.complete(); + assert.equal(h.composer.current!.hasPendingAction(), false); + assert.equal(h.composer.current!.captureDrafts(['draft-a'])[0]?.replacesMessageId, 'original'); + assert.equal(h.commands.hasPendingContext(['draft-a']), true); +}); + test('#4695 review: StagedComposer begins the setting settlement at submit, before Session references load', async () => { const events: string[] = []; let finishReference!: (ready: boolean) => void; diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index c97624df3b6..9c73a7b6d4e 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -216,10 +216,9 @@ test('routes the first-paint IPC only to the active Renderer recovery listener', mainWindowSource.indexOf('setTitlebarControlsVisible(sender', readyHandlerStart), ); const reloadStart = mainWindowSource.indexOf(' async reloadMainRenderer() {'); - const reloadHandler = mainWindowSource.slice( - reloadStart, - mainWindowSource.indexOf(' send: safeSendToRenderer', reloadStart), - ); + const reloadEnd = mainWindowSource.indexOf(' isRenderer:', reloadStart); + assert.ok(reloadStart >= 0 && reloadEnd > reloadStart); + const reloadHandler = mainWindowSource.slice(reloadStart, reloadEnd); assert.match( ipcHandler, @@ -227,7 +226,7 @@ test('routes the first-paint IPC only to the active Renderer recovery listener', ); assert.match( reloadHandler, - /clearShowFallbackTimer\(\);\s*revealGate\.reset\(\);\s*target\.hide\(\);/u, + /clearShowFallbackTimer\(\);\s*if \(context\.kind === 'session'\) handoffReady = false;\s*revealGate\.reset\(\);\s*target\.hide\(\);/u, ); assert.match(reloadHandler, /reloadMainRendererProcess\(/u); assert.match(reloadHandler, /subscribeMainFrameCommitted:/u); 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..4e5f71c3e8c 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 = { + hasPendingAction: () => false, captureDrafts: () => [], restoreDrafts() {}, setText() {}, appendText() {}, getText: () => '', clearDraft() {}, setDraft: (key, text) => { restoredDrafts.push([key, text]); }, getDraft: () => '', diff --git a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts index 36edbf671ea..ed308d4a380 100644 --- a/apps/desktop/src/main/__tests__/overlays-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/overlays-boundary.test.ts @@ -209,7 +209,6 @@ describe('Overlays feature boundary', () => { } } assert.deepEqual(imports.sort(), [ - 'src/renderer/app-shell-command-actions.ts: Command', 'src/renderer/app-shell-command-actions.ts: OverlayPaletteActions', 'src/renderer/app-shell-overlays.tsx: *', 'src/renderer/app-shell-overlays.tsx: OverlaysShellProjection', diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index be3addb487b..6c644c61457 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1305,6 +1305,7 @@ test('does not release or report a Revision the Host retained during cleanup', a createSessionCopyCleanup: ({ removeSession }) => { removeSessionCopy = removeSession; return { + adopt: async () => {}, ownCreation: (_creation, operation) => operation(), rejectCreation: async () => undefined, cleanup: async () => undefined, @@ -1684,6 +1685,7 @@ function deps( completeDesktopInteractionTurn() {}, notifyRun: async () => {}, createSessionCopyCleanup: () => ({ + adopt: async () => {}, ownCreation: (_creation, operation) => operation(), rejectCreation: async () => undefined, cleanup: async () => undefined, 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__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index ba3b36f3746..4340025b5ed 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -36,6 +36,7 @@ import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeSessionNavigationServices, createSessionOpenCommand, + createSessionNavigationCommands, deriveSessionRail, sessionMatchesRail, SessionNavigationProvider, @@ -718,6 +719,78 @@ describe('SessionNavigationProvider selection', () => { }); }); +describe('window-aware session navigation commands', () => { + function harness() { + const calls: string[] = []; + const targets: unknown[] = []; + const routes: unknown[] = []; + const window = { + owns: true, + openSession(navigation: { sessionId: string }, activate: () => void) { + routes.push(navigation); + if (!window.owns) return false; + activate(); return true; + }, + requestNewTask(activate: () => void) { activate(); }, + async bootstrap(refresh: () => Promise, revision: () => number, open: (id: string) => void) { + await refresh(); assert.equal(revision(), 7); open('a'); + }, + }; + const commands = createSessionNavigationCommands({ + window, + activateSession: (id) => calls.push(`activate:${id}`), + setWorkHubActive: (active) => calls.push(`workhub:${active}`), + selectSessionSurface: () => calls.push('sessions'), + closeSettings: () => calls.push('close-settings'), + setSearchTarget: (target) => targets.push(target), + newTask: { prepare: () => calls.push('prepare'), start: () => { calls.push('start'); return 'owner'; }, + resetPlanMode: () => calls.push('reset-plan'), focus: () => calls.push('focus') }, + projects: { selectProject: (key) => key === 'available', renameProject: async () => {}, archiveProject: async () => {}, + restoreProject: async () => {}, relinkProject: async () => {} }, + hasProjects: true, + refreshSessions: async () => { calls.push('refresh'); return []; }, + readSelectionRevision: () => 7, + }); + return { calls, targets, routes, window, commands }; + } + + it('routes foreign navigation without changing the local selection or search, including startup', async () => { + const h = harness(); h.window.owns = false; + assert.equal(h.commands.openSession('a', 'turn', 4), false); + assert.deepEqual(h.calls, []); assert.deepEqual(h.targets, []); + assert.deepEqual(h.routes, [{ sessionId: 'a', turnId: 'turn', sequence: 4 }]); + await h.commands.bootstrapSessions(); + assert.deepEqual(h.calls, ['refresh']); + h.window.owns = true; + assert.equal(h.commands.openSession('a', 'turn', 4), true); + assert.deepEqual(h.calls, ['refresh', 'workhub:false', 'sessions', 'activate:a']); + assert.deepEqual({ ...h.targets[0] as object, nonce: 0 }, { sessionId: 'a', turnId: 'turn', sequence: 4, nonce: 0 }); + h.commands.openLocal({ sessionId: 'child' }); + assert.deepEqual(h.calls.slice(-3), ['workhub:false', 'sessions', 'activate:child']); + assert.equal(h.targets.at(-1), null); + }); + + it('retains new-task setup, project availability and WorkHub navigation order', async (t) => { + installReactRenderer(); + const frames: FrameRequestCallback[] = []; + t.mock.method(globalThis, 'requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback); return 1; }); + const h = harness(); + h.commands.projectRowActions!.onNew('missing'); assert.deepEqual(h.calls, []); + assert.equal(h.commands.openNewTaskSurface(), 'owner'); + assert.deepEqual(h.calls, ['prepare', 'start', 'reset-plan', 'sessions']); + assert.deepEqual(h.targets, [null]); frames.shift()!(0); assert.equal(h.calls.at(-1), 'focus'); + h.calls.length = 0; + await h.commands.createSession(); + assert.deepEqual(h.calls, ['prepare', 'start', 'reset-plan', 'sessions']); + h.calls.length = 0; + h.commands.projectRowActions!.onNew('available'); + assert.deepEqual(h.calls, ['prepare', 'start', 'reset-plan', 'sessions']); + h.calls.length = 0; + h.commands.openWorkHub(); h.commands.exitWorkHub(); h.commands.enableWorkHub(); + assert.deepEqual(h.calls, ['close-settings', 'sessions', 'workhub:true', 'workhub:false', 'workhub:true', 'sessions']); + }); +}); + describe('createSessionOpenCommand', () => { it('distinguishes successive jumps even when the clock does not advance', (t) => { t.mock.method(Date, 'now', () => 1); diff --git a/apps/desktop/src/main/__tests__/session-window-binding.test.tsx b/apps/desktop/src/main/__tests__/session-window-binding.test.tsx new file mode 100644 index 00000000000..333a7cd6a96 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-binding.test.tsx @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, useState, type ComponentProps } from 'react'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import { LocaleProvider, ToastProvider, type ComposerHandle } from '@maka/ui'; +import type { SessionWindowCommand, SessionWindowCommandReply, SessionWindowOwnership } from '../../shared/session-window-contract.js'; +import { SessionWindowBinding, SessionWindowRoot, SessionWindowServicesProvider, type SessionWindowServices, type SessionWindowProjection } from '../../renderer/features/session-windows/index.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +type BindingProps = ComponentProps; +const group = { hostId: 'host', groupId: 'a', sessionIds: ['a', 'child'] }; +async function mountBinding() { + const { root } = installReactRenderer(); + let command!: (command: SessionWindowCommand) => void; + let ownership!: (snapshot: SessionWindowOwnership) => void; + let deleted!: (id: string) => void; + const replies: SessionWindowCommandReply[] = []; + const drafts = new Map([['a', 'keep A'], ['child', 'keep child'], ['b', 'keep B']]); + const state = { active: 'a' as string | undefined, frozen: false, inputPending: false, staged: new Set(), edits: new Set(), sideContent: new Set(), unavailable: false }; + let select!: (id: string | undefined) => void; + const services: SessionWindowServices = { + context: async () => ({ kind: 'main', windowId: 'main' }), ownership: async () => ({ version: 0, sessionIds: ['a', 'child', 'b'] }), + subscribeSessionWindowOwnership: (handler) => { ownership = handler; return () => {}; }, + routeSession: async () => {}, openSessionWindow: async () => {}, openInMainWindow: async () => {}, sessionWindowReady: async () => {}, + subscribeSessionWindowCommand: (handler) => { command = handler; return () => {}; }, + ackSessionWindowCommand: async (reply) => { replies.push(reply); }, + commandMain: async () => {}, subscribeDeleted: (handler) => { deleted = handler; return () => {}; }, + }; + const composer: ComposerHandle = { + hasPendingAction: () => state.inputPending, + captureDrafts: (ids) => ids.map((id) => ({ draftKey: id, text: drafts.get(id) ?? '', references: [] })), + restoreDrafts: (items) => { if (state.unavailable) throw new Error('composer unavailable'); for (const draft of items) drafts.set(draft.draftKey, draft.text); }, + clearDraft: (id) => { drafts.delete(id); }, + setText() {}, appendText() {}, getText: () => '', setDraft() {}, getDraft: () => '', captureEmptyDraft: () => undefined, appendDraft() {}, focus() {}, openModelPicker() {}, + }; + function Harness() { + const [active, setActive] = useState('a'); const [frozen, setFrozen] = useState(false); + select = setActive; state.active = active; state.frozen = frozen; + const window: BindingProps['window'] = { + frameProps: { 'data-window-kind': 'main', inert: frozen || undefined }, + titlebarProps: { standalone: false, returningToMain: false, onOpenInMainWindow() {} }, standalone: false, setFrozen, + bootstrap: async () => {}, openSession: () => true, openInNewWindow: async () => {}, mainActions: (local) => local, requestNewTask: (local) => local(), + }; + return + ids.some((id) => state.staged.has(id)) }} + submission={{ hasPendingEdit: (ids) => ids.some((id) => state.edits.has(id)) }} + workbar={{ hasSessionContent: (ids) => ids.some((id) => state.sideContent.has(id)) }} + conversation={{ composer: { current: composer }, readActiveSessionId: () => state.active, + clearOwnedSessionState: (id) => { if (state.active === id) select(undefined); } }} + openLocal={({ sessionId }) => select(sessionId)} clearLocal={() => {}} /> + ; + } + await act(async () => root.render()); + return { state, drafts, replies, + command: async (value: SessionWindowCommand) => { await act(async () => command(value)); return replies.at(-1)!; }, + ownership: async (version: number, sessionIds: string[]) => { await act(async () => ownership({ version, sessionIds })); }, + select: async (id: string) => { await act(async () => select(id)); }, + deleted: async (id: string) => { await act(async () => deleted(id)); }, + }; +} +afterEach(cleanupFakeDom); + +for (const blocked of ['inputPending', 'staged', 'edits', 'sideContent'] as const) { + test(`handoff refuses ${blocked}, including content on a non-displayed family member`, async () => { + const h = await mountBinding(); + if (blocked === 'inputPending') h.state.inputPending = true; else h.state[blocked].add('child'); + const reply = await h.command({ kind: 'prepare', requestId: '1', transferId: 'move', group }); + assert.equal(reply.ok, false); assert.equal(h.state.active, 'a'); assert.equal(h.state.frozen, false); + assert.equal(h.drafts.get('a'), 'keep A'); assert.equal(h.drafts.get('child'), 'keep child'); + const closing = await h.command({ kind: 'check-close', requestId: '2', group }); + assert.equal(closing.ok && closing.result?.hasUnsent, true); + await h.command({ kind: 'cancel-close', requestId: '3' }); + assert.equal(h.state.frozen, false); + }); +} + +test('prepare parks the display while retaining drafts, abort restores it, and committed ownership releases only this family', async () => { + const h = await mountBinding(); + assert.equal((await h.command({ kind: 'prepare', requestId: '1', transferId: 'first', group })).ok, true); + assert.equal(h.state.active, undefined); assert.equal(h.state.frozen, true); assert.equal(h.drafts.get('a'), 'keep A'); + await h.command({ kind: 'abort', requestId: '2', transferId: 'first' }); + assert.equal(h.state.active, 'a'); assert.equal(h.state.frozen, false); + await h.command({ kind: 'prepare', requestId: '3', transferId: 'second', group }); + await h.ownership(2, ['b']); + assert.equal(h.drafts.has('a'), false); assert.equal(h.drafts.get('b'), 'keep B'); assert.equal(h.state.frozen, false); +}); + +test('target stages text without replacing B, rejects stale ownership, and activates only after commitment', async () => { + const h = await mountBinding(); await h.select('b'); + await h.ownership(5, ['b']); + await h.command({ kind: 'receive', requestId: '1', transferId: 'move', group, + navigation: { sessionId: 'child' }, drafts: [{ sessionId: 'child', text: 'arriving', references: [] }] }); + assert.equal(h.state.active, 'b'); assert.equal(h.drafts.get('b'), 'keep B'); assert.equal(h.state.frozen, true); + await h.ownership(4, ['a', 'child', 'b']); assert.equal(h.state.active, 'b'); + await h.ownership(6, ['a', 'child', 'b']); + assert.equal(h.state.active, 'child'); assert.equal(h.drafts.get('child'), 'arriving'); assert.equal(h.state.frozen, false); +}); + +test('failed target adoption does not navigate, clear B or leave the main window frozen', async () => { + const h = await mountBinding(); await h.select('b'); h.state.unavailable = true; + const reply = await h.command({ kind: 'receive', requestId: '1', transferId: 'move', group, + navigation: { sessionId: 'a' }, drafts: [{ sessionId: 'a', text: 'arriving', references: [] }] }); + assert.equal(reply.ok, false); assert.equal(h.state.active, 'b'); assert.equal(h.state.frozen, false); assert.equal(h.drafts.get('b'), 'keep B'); +}); + +test('close detects hidden unsent content and cancellation leaves the original selection and drafts', async () => { + const h = await mountBinding(); h.drafts.delete('a'); + const reply = await h.command({ kind: 'check-close', requestId: '1', group }); + assert.equal(reply.ok && reply.result?.hasUnsent, true); assert.equal(h.state.frozen, true); + await h.command({ kind: 'cancel-close', requestId: '2' }); + assert.equal(h.state.active, 'a'); assert.equal(h.state.frozen, false); assert.equal(h.drafts.get('child'), 'keep child'); +}); + +test('the initial ownership read cannot overwrite a newer pushed membership', async () => { + const { root } = installReactRenderer(); const initial = deferred(); + let update!: (snapshot: SessionWindowOwnership) => void; + let projection: SessionWindowProjection | undefined; + const routes: string[] = []; const activated: string[] = []; + const services: SessionWindowServices = { + context: async () => ({ kind: 'main', windowId: 'main' }), ownership: () => initial.promise, + subscribeSessionWindowOwnership: (handler) => { update = handler; return () => {}; }, + routeSession: async ({ sessionId }) => { routes.push(sessionId); }, + openSessionWindow: async () => {}, openInMainWindow: async () => {}, sessionWindowReady: async () => {}, + subscribeSessionWindowCommand: () => () => {}, ackSessionWindowCommand: async () => {}, commandMain: async () => {}, subscribeDeleted: () => () => {}, + }; + await act(async () => root.render( + {(value) => { projection = value; return null; }} + )); + await act(async () => { update({ version: 2, sessionIds: ['b'] }); initial.resolve({ version: 1, sessionIds: ['a', 'b'] }); }); + assert.ok(projection); + projection.openSession({ sessionId: 'a' }, () => activated.push('a')); + assert.deepEqual(routes, ['a']); assert.equal(activated.length, 0); + assert.equal(projection.openSession({ sessionId: 'b' }, () => activated.push('b')), true); + assert.deepEqual(activated, ['b']); +}); + +for (const kind of ['main', 'session'] as const) { + test(`${kind} window routes global actions and exposes its own frame controls`, async () => { + const { root } = installReactRenderer(); + let projection!: SessionWindowProjection; + const calls: string[] = []; + const returning = deferred(); + const services: SessionWindowServices = { + context: async () => kind === 'main' ? { kind, windowId: 'main' } : { kind, windowId: 'child', group, initialSessionId: 'a' }, + ownership: async () => ({ version: 1, sessionIds: ['a', 'child'] }), + subscribeSessionWindowOwnership: () => () => {}, + routeSession: async () => {}, openSessionWindow: async () => {}, openInMainWindow: () => returning.promise, + sessionWindowReady: async () => {}, subscribeSessionWindowCommand: () => () => {}, + ackSessionWindowCommand: async () => {}, commandMain: async (command) => { calls.push(`main:${command}`); }, subscribeDeleted: () => () => {}, + }; + await act(async () => root.render( + {(value) => { projection = value; return null; }} + )); + const actions = projection.mainActions({ + openHelp: () => calls.push('help'), openSettings: () => calls.push('settings'), + openSettingsSection: (section) => calls.push(`section:${section}`), openExternalAgentSettings: () => calls.push('external-agent'), + }); + await act(async () => { + actions.openHelp(); actions.openSettings(); actions.openSettingsSection(); actions.openExternalAgentSettings(); + projection.requestNewTask(() => calls.push('new-task')); + }); + assert.deepEqual(calls, kind === 'main' ? ['help', 'settings', 'section:undefined', 'external-agent', 'new-task'] + : ['main:openHelp', 'main:openSettings', 'main:openSettings', 'main:openSettings', 'main:newTask']); + assert.equal(projection.frameProps['data-window-kind'], kind); + assert.equal(projection.titlebarProps.standalone, kind === 'session'); + await act(() => projection.setFrozen(true)); assert.equal(projection.frameProps.inert, true); + await act(() => projection.setFrozen(false)); assert.equal(projection.frameProps.inert, undefined); + await act(() => projection.titlebarProps.onOpenInMainWindow()); assert.equal(projection.titlebarProps.returningToMain, true); + await act(async () => returning.resolve()); assert.equal(projection.titlebarProps.returningToMain, false); + }); +} 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..94615062c12 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-coordinator.test.ts @@ -0,0 +1,327 @@ +/* + * 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 { deferred } from '@maka/core/test-only/async-primitives'; +import { createSessionWindowCoordinator, registerSessionWindowIpc } from '../session-window-coordinator.js'; +import { createWorkspaceWindowRegistry } from '../workspace-window-registry.js'; +import { createSessionWindowCommandHandler } from '../../renderer/features/session-windows/testing.js'; +import type { SessionWindowCommand, SessionWindowContext, SessionWindowGroup, SessionWindowOwnership } from '../../shared/session-window-contract.js'; + +const group: SessionWindowGroup = { hostId: 'local', groupId: 'A', sessionIds: ['A', 'A-child', 'A-version'] }; +const single = (id: string): SessionWindowGroup => ({ hostId: 'local', groupId: id, sessionIds: [id] }); +function harness() { + const registry = createWorkspaceWindowRegistry(); + const commands: { windowId: string; kind: SessionWindowCommand['kind'] }[] = []; + const errors: unknown[] = []; + const resources: string[] = []; + const known = [group, single('B')]; + let nextId = 0; + let failCreation = false; + let failReceive: 'main' | 'session' | undefined; + let failResources = false; + let dropReply: SessionWindowCommand['kind'] | undefined; + let transferGate: { entered(): void; wait: Promise } | undefined; + let confirmation: boolean | Error = false; + let confirmations = 0; + let quitting = false; + let coordinator: ReturnType; + class FakeWindow extends EventEmitter { + readonly id = String(++nextId); + destroyed = false; + focused = false; + revealed = false; + frozen = false; + unsupported = false; + active: string | undefined; + readonly drafts = new Map(); + readonly context: SessionWindowContext; + handler = this.createHandler(); + private createHandler() { return createSessionWindowCommandHandler({ + activeSessionId: () => this.active, + freeze: (value) => { this.frozen = value; }, + capture: (ids) => { + if (this.unsupported) throw new Error('Send or clear staged content'); + return ids.map((sessionId) => ({ sessionId, text: this.drafts.get(sessionId) ?? '', references: [] })); + }, + hasUnsent: (ids) => this.unsupported || ids.some((id) => Boolean(this.drafts.get(id)?.trim())), + restore: (drafts) => { for (const draft of drafts) this.drafts.set(draft.sessionId, draft.text); }, + clear: (ids) => { for (const id of ids) this.drafts.delete(id); }, + navigate: ({ sessionId }) => { this.active = sessionId; }, + suspend: (ids) => { if (ids.includes(this.active ?? '')) this.active = undefined; }, + }); } + resetRenderer() { this.drafts.clear(); this.active = undefined; this.handler = this.createHandler(); } + readonly contents = Object.assign(new EventEmitter(), { + id: Number(this.id), isDestroyed: () => this.destroyed, + send: (channel: string, payload: SessionWindowCommand | SessionWindowOwnership) => { + if (channel === 'session-window:ownership') { + this.handler.ownershipChanged(new Set((payload as SessionWindowOwnership).sessionIds)); + return; + } + if (channel !== 'session-window:command') return; + const command = payload as SessionWindowCommand; + commands.push({ windowId: this.id, kind: command.kind }); + queueMicrotask(() => { + if (this.destroyed) return; + try { + if (command.kind === 'receive' && this.context.kind === failReceive) { + failReceive = undefined; + throw new Error('receive failed'); + } + const result = this.handler.execute(command); + if (command.kind === dropReply) { dropReply = undefined; return; } + coordinator.reply(this.contents as never, { requestId: command.requestId, ok: true, result }); + } catch (error) { + coordinator.reply(this.contents as never, { requestId: command.requestId, ok: false, error: String(error) }); + } + }); + }, + }); + get webContents() { + if (this.destroyed) throw new Error('Object has been destroyed'); + return this.contents; + } + constructor(context: Omit, 'windowId'> | { kind: 'main' }) { + super(); + this.context = { ...context, windowId: this.id }; + this.active = context.kind === 'main' ? 'A' : undefined; + } + isDestroyed() { return this.destroyed; } + isFocused() { return this.focused; } + destroy() { if (this.destroyed) return; 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.contents as never); + return { window, controller }; + } + const main = add({ kind: 'main' }).window; + coordinator = createSessionWindowCoordinator({ + windows: { registry, createSessionWindow: async (context: Parameters[0]) => { + if (failCreation) throw new Error('creation failed'); + return add(context).controller; + } } as never, + signal: new AbortController().signal, + ensureMainWindow: async () => { if (!registry.main()) add({ kind: 'main' }); }, + resolveGroup: async (id) => known.find((item) => item.sessionIds.includes(id)) ?? single(id), + knownGroups: () => known, + confirmClose: async () => { confirmations += 1; if (confirmation instanceof Error) throw confirmation; return confirmation; }, + transferResources: async ({ source, target }) => { + resources.push(`${source?.id}->${target.id}`); + if (transferGate) { const gate = transferGate; transferGate = undefined; gate.entered(); await gate.wait; } + if (failResources) { failResources = false; throw new Error('resource transfer failed'); } + }, + releaseResources: (sender) => { resources.push(`release:${sender.id}`); }, + isQuitting: () => quitting, onError: (error) => { errors.push(error); }, commandTimeoutMs: 30, + }); + coordinator.sessionWindowReady(main.contents as never); + return { + coordinator, main, windows, commands, resources, errors, known, + open: (id = 'A') => coordinator.openSessionWindow(main.contents as never, id), + returnToMain: (window = windows.at(-1)!) => coordinator.openInMainWindow(window.contents as never), + failCreation: () => { failCreation = true; }, + failReceive: (kind: 'main' | 'session') => { failReceive = kind; }, + failResources: () => { failResources = true; }, + dropReply: (kind: SessionWindowCommand['kind']) => { dropReply = kind; }, + gate: (value: NonNullable) => { transferGate = value; }, + confirm: (value: boolean | Error) => { confirmation = value; }, + confirmations: () => confirmations, + quit: () => { quitting = true; }, + }; +} +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +test('opening a family parks its source, moves all drafts and admits only one owner', async () => { + const h = harness(); + h.main.drafts.set('A', 'root draft'); h.main.drafts.set('A-child', 'child draft'); + await h.open(); + const child = h.windows[1]!; + assert.equal(h.main.active, undefined); + assert.equal(h.main.drafts.has('A'), false); + assert.equal(child.active, 'A'); + assert.equal(child.drafts.get('A-child'), 'child draft'); + assert.equal(child.revealed, true); + assert.equal(child.frozen, false); + for (const id of group.sessionIds) { + assert.equal(h.coordinator.canUseSession(child.contents as never, id), true); + assert.equal(h.coordinator.canUseSession(h.main.contents as never, id), false); + } + h.coordinator.dispose(); +}); + +test('an inactive family leaves the main selection and unrelated drafts untouched', async () => { + const h = harness(); h.main.active = 'B'; h.main.drafts.set('B', 'keep B'); h.main.drafts.set('A', 'move A'); + await h.open(); + assert.equal(h.main.active, 'B'); assert.equal(h.main.drafts.get('B'), 'keep B'); + const child = h.windows[1]!; child.active = 'A-child'; child.drafts.set('A-child', 'edited child'); + await h.returnToMain(); + assert.equal(h.main.active, 'A-child'); assert.equal(h.main.drafts.get('B'), 'keep B'); + assert.equal(h.main.drafts.get('A-child'), 'edited child'); assert.equal(child.destroyed, true); + h.coordinator.dispose(); +}); + +test('repeated and simultaneous opens focus the existing owner instead of creating duplicates', async () => { + const h = harness(); await Promise.all([h.open(), h.open('A-child'), h.open('A-version')]); + assert.equal(h.windows.length, 2); assert.equal(h.windows[1]!.active, 'A-version'); + const child = h.windows[1]!; + child.focused = false; child.revealed = false; + await h.open(); + assert.equal(h.windows.length, 2); assert.equal(child.focused, true); assert.equal(child.revealed, true); + child.focused = false; child.revealed = false; + await h.coordinator.routeSession(h.main.contents as never, { sessionId: 'A-child' }); + assert.equal(child.active, 'A-child'); assert.equal(h.main.active, undefined); + assert.equal(child.focused, true); assert.equal(child.revealed, true); + h.coordinator.dispose(); +}); + +for (const failure of ['creation', 'prepare', 'receive', 'resources', 'prepare-timeout', 'receive-timeout'] as const) { + test(`${failure} failure retains the source draft and releases the failed child`, async () => { + const h = harness(); h.main.drafts.set('A', 'keep me'); + if (failure === 'creation') h.failCreation(); + if (failure === 'prepare') h.main.unsupported = true; + if (failure === 'receive') h.failReceive('session'); + if (failure === 'resources') h.failResources(); + if (failure === 'prepare-timeout') h.dropReply('prepare'); + if (failure === 'receive-timeout') h.dropReply('receive'); + await assert.rejects(h.open()); + assert.equal(h.main.active, 'A'); assert.equal(h.main.drafts.get('A'), 'keep me'); assert.equal(h.main.frozen, false); + assert.equal(h.windows.filter((window) => !window.destroyed).length, 1); + assert.equal(h.coordinator.canUseSession(h.main.contents as never, 'A'), true); + h.coordinator.dispose(); + }); +} + +for (const failure of ['receive', 'resources', 'receive-timeout'] as const) { + test(`return ${failure} preserves the source child and the main window's unrelated B`, async () => { + const h = harness(); await h.open(); const child = h.windows[1]!; + h.main.active = 'B'; h.main.drafts.set('B', 'keep B'); child.drafts.set('A', 'keep A'); + if (failure === 'receive') h.failReceive('main'); + if (failure === 'resources') h.failResources(); + if (failure === 'receive-timeout') h.dropReply('receive'); + await assert.rejects(h.returnToMain()); + assert.equal(h.main.active, 'B'); assert.equal(h.main.drafts.get('B'), 'keep B'); assert.equal(h.main.destroyed, false); + assert.equal(child.active, 'A'); assert.equal(child.drafts.get('A'), 'keep A'); assert.equal(child.frozen, false); + assert.equal(h.coordinator.canUseSession(child.contents as never, 'A'), true); + h.coordinator.dispose(); + }); +} + +test('resource handoff holds both windows inactive and source drafts until the receiver commits', async () => { + const h = harness(); h.main.drafts.set('A', 'still owned'); + const entered = deferred(); const release = deferred(); h.gate({ entered: () => entered.resolve(), wait: release.promise }); + const opening = h.open(); await entered.promise; + assert.equal(h.main.active, undefined); assert.equal(h.main.drafts.get('A'), 'still owned'); + assert.equal(h.windows[1]!.active, undefined); assert.equal(h.windows[1]!.revealed, false); + assert.equal(h.coordinator.canUseSession(h.main.contents as never, 'A'), false); + release.resolve(); await opening; + assert.equal(h.main.drafts.has('A'), false); assert.equal(h.windows[1]!.active, 'A'); + h.coordinator.dispose(); +}); + +test('new descendants inherit the family owner before they can use session resources', async () => { + const h = harness(); await h.open(); + assert.equal(h.coordinator.canUseSession(h.main.contents as never, 'new-child'), false); + h.known[0] = { ...group, sessionIds: [...group.sessionIds, 'new-child'] }; + h.coordinator.refreshOwnership(); + assert.equal(h.coordinator.canUseSession(h.windows[1]!.contents as never, 'new-child'), true); + assert.equal(h.coordinator.canUseSession(h.main.contents as never, 'new-child'), false); + h.coordinator.dispose(); +}); + +test('cancel close keeps every member draft, while confirmed close releases its owner and browser', async () => { + const h = harness(); await h.open(); const child = h.windows[1]!; + child.drafts.set('A-child', 'hidden unsent text'); child.close(); await settle(); await settle(); + assert.equal(h.confirmations(), 1); assert.equal(child.destroyed, false); assert.equal(child.frozen, false); + assert.equal(child.drafts.get('A-child'), 'hidden unsent text'); + h.confirm(true); child.close(); await settle(); await settle(); + assert.equal(child.destroyed, true); assert.ok(h.resources.includes(`release:${child.id}`)); + assert.equal(h.coordinator.canUseSession(h.main.contents as never, 'A'), true); + h.coordinator.dispose(); +}); + +test('an unreadable composer still allows an explicitly confirmed discard close', async () => { + const h = harness(); await h.open(); h.dropReply('check-close'); h.confirm(true); + const child = h.windows[1]!; child.close(); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.equal(h.confirmations(), 1); assert.equal(child.destroyed, true); + h.coordinator.dispose(); +}); + +test('a failed confirmation cancels close and unfreezes the child', async () => { + const h = harness(); await h.open(); const child = h.windows[1]!; child.unsupported = true; + h.confirm(new Error('dialog failed')); child.close(); await settle(); await settle(); + assert.equal(child.destroyed, false); assert.equal(child.frozen, false); assert.equal(h.errors.length, 1); + h.coordinator.dispose(); +}); + +test('closing Main leaves children alive and returning can recreate Main', async () => { + const h = harness(); await h.open(); const child = h.windows[1]!; + h.main.close(); await settle(); await settle(); + assert.equal(h.main.destroyed, true); assert.equal(child.destroyed, false); + await h.returnToMain(child); assert.equal(h.windows[2]!.active, 'A'); assert.equal(child.destroyed, true); + h.coordinator.dispose(); +}); + +test('IPC rejects auxiliary senders and subframes and validates routing input', async () => { + const h = harness(); + const handlers = new Map any>(); + registerSessionWindowIpc(h.coordinator, { handle: (key, handler) => { handlers.set(key, handler); } }); + const frame = {}; Object.assign(h.main.contents, { mainFrame: frame }); + const event = { sender: h.main.contents, senderFrame: frame }; + assert.throws(() => handlers.get('session-window:context')!({ ...event, senderFrame: {} }), /main frame/); + assert.throws(() => handlers.get('session-window:context')!({ sender: { mainFrame: frame, isDestroyed: () => false }, senderFrame: frame }), /workspace window/); + assert.throws(() => handlers.get('session-window:route')!(event, { sessionId: 'A', sequence: -1 }), /sequence/); + assert.throws(() => handlers.get('session-window:open')!(event, ''), /identity/); + h.coordinator.dispose(); +}); + +for (const change of ['crash', 'reload', 'reload-ready', 'destroy'] as const) { + test(`receiver ${change} after receipt rejects transfer and preserves the source`, async () => { + const h = harness(); h.main.drafts.set('A', 'surviving draft'); + const entered = deferred(); const release = deferred(); + h.gate({ entered: () => entered.resolve(), wait: release.promise }); + const moving = h.open(); const rejected = assert.rejects(moving); + await entered.promise; + const target = h.windows[1]!; + if (change === 'destroy') target.destroy(); + else { + if (change === 'crash') target.contents.emit('render-process-gone'); + else target.contents.emit('did-start-navigation', {}, 'reload', false, true); + target.resetRenderer(); + if (change === 'reload-ready') h.coordinator.sessionWindowReady(target.contents as never); + } + release.resolve(); await rejected; + assert.equal(h.main.drafts.get('A'), 'surviving draft'); + assert.equal(h.main.active, 'A'); assert.equal(h.main.frozen, false); + assert.equal(h.coordinator.canUseSession(h.main.contents as never, 'A'), true); + assert.equal(target.destroyed, true); + h.coordinator.dispose(); + }); +} 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-renderer.test.ts b/apps/desktop/src/main/__tests__/session-window-renderer.test.ts new file mode 100644 index 00000000000..5751376d75d --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-renderer.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { deferred } from '@maka/core/test-only/async-primitives'; +import { createSessionWindowCommandHandler, createSessionWindowNavigator, bootstrapSessionWindow } from '../../renderer/features/session-windows/testing.js'; +import { markNewTaskReloadIntent, clearNewTaskReloadIntent } from '../../renderer/application/contracts/new-task-reload-intent.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { createConversationWorkspace, stubConversationServices } from '../../renderer/features/conversation/testing.js'; + +const group = { hostId: 'host', groupId: 'A', sessionIds: ['A', 'child'] }; +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('local ownership navigates synchronously; unknown or foreign sessions never mount locally', () => { + const routed: string[] = []; const selected: string[] = []; + let blocked = false; + const open = createSessionWindowNavigator({ owns: (id) => id === 'A', route: ({ sessionId }) => { routed.push(sessionId); }, blocked: () => blocked }); + assert.equal(open({ sessionId: 'A' }, () => selected.push('A')), true); + assert.deepEqual(selected, ['A']); + assert.equal(open({ sessionId: 'child' }, () => selected.push('child')), false); + assert.deepEqual(routed, ['child']); assert.deepEqual(selected, ['A']); + blocked = true; assert.equal(open({ sessionId: 'A' }, () => selected.push('A')), false); + assert.deepEqual(selected, ['A']); +}); + +test('suspending the family cancels a pending source publication without removing unrelated B', () => { + const catalog = createSessionCatalogController(); catalog.commitSessions(readableSessions()); + const workspace = createConversationWorkspace(catalog, stubConversationServices().observation, false); + workspace.commands.setActiveId('B'); + const messages = [{ id: 'b', type: 'user' as const, text: 'B', ts: 1, turnId: 'b' }]; + workspace.commitTranscript('B', messages); + workspace.commands.setActiveId('A'); const ownsA = workspace.commands.captureSelection(); + const handler = createSessionWindowCommandHandler({ + freeze() {}, activeSessionId: () => workspace.activeIdRef.current, + capture: () => [], hasUnsent: () => false, clear() {}, restore() {}, navigate() {}, + suspend: (ids) => { for (const id of ids) workspace.commands.clearOwnedSessionState(id); }, + }); + handler.execute({ kind: 'prepare', requestId: '1', transferId: 'transfer', group }); + assert.equal(ownsA(), false); assert.equal(catalog.getState().activeSessionId, 'B'); + assert.equal(workspace.commitTranscript('A', []), false); assert.deepEqual(workspace.messages.getSnapshot(), messages); +}); + +test('stale aborts cannot cancel a later transfer, and deletion cannot resurrect a parked source', () => { + let active: string | undefined = 'A'; const cleared: string[] = []; + const handler = createSessionWindowCommandHandler({ freeze() {}, activeSessionId: () => active, + capture: () => [], hasUnsent: () => false, clear: (ids) => { cleared.push(...ids); }, restore() {}, + navigate: ({ sessionId }) => { active = sessionId; }, suspend: () => { active = undefined; }, + }); + handler.execute({ kind: 'prepare', requestId: '1', transferId: 'current', group }); + handler.execute({ kind: 'abort', requestId: '2', transferId: 'old' }); assert.equal(active, undefined); + handler.deleted('A'); handler.execute({ kind: 'abort', requestId: '3', transferId: 'current' }); + assert.equal(active, undefined); assert.deepEqual(cleared, ['A']); +}); + +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__/window-reveal-mode.test.ts b/apps/desktop/src/main/__tests__/window-reveal-mode.test.ts index 24eb06cd448..4cd8c3e5837 100644 --- a/apps/desktop/src/main/__tests__/window-reveal-mode.test.ts +++ b/apps/desktop/src/main/__tests__/window-reveal-mode.test.ts @@ -162,8 +162,21 @@ describe('createWindowRevealGate', () => { gate.markReady(win); assert.deepEqual(win.calls, ['showInactive']); // A focus request after readiness must not raise the app either. + win.minimized = true; gate.requestFocus(win); assert.deepEqual(win.calls, ['showInactive']); + assert.equal(win.minimized, true); + }); + + it('restores an already-ready minimized product window before showing and focusing it', () => { + const gate = createWindowRevealGate('active'); + const win = fakeWindow(); + gate.markReady(win); + win.minimized = true; + win.calls.length = 0; + gate.requestFocus(win); + assert.deepEqual(win.calls, ['restore', 'show', 'focus']); + assert.equal(win.minimized, false); }); it('reveals inactively before maximizing, so the maximize cannot raise the app', () => { diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 6656247033c..96d61e0931d 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -421,6 +421,24 @@ describe('useWorkbarController', () => { assert.equal(controller().host.rightCollapsed, false); }); + it('keeps topology and dimensions window-wide during ordinary Session switching', async () => { + const { root } = installReactRenderer(); + const services = createFakeWorkbarServices(); + const show = (id: string) => renderController(root, services, input(session(id))); + await act(async () => show('a')); + await act(async () => controller().commands.openTool('files')); + await act(async () => show('b')); + await act(async () => controller().commands.openTool('inspector', 'bottom')); + await act(async () => controller().host.rightResizable._onResizeStart()); + await act(async () => controller().host.rightResizable._onResizeMove(50)); + const panels = controller().host.panelsState; + const width = controller().host.rightWidth; + await act(async () => show('a')); + assert.deepEqual(controller().host.panelsState, panels); + assert.equal(controller().host.rightWidth, width); + assert.equal(controller().host.bottomOpen, true); + }); + function fakeRail(width = 260) { const listeners = new Set<() => void>(); return { diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts index 518cac6238f..e02e552ee71 100644 --- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -625,11 +625,12 @@ test('rejects unowned/subframe IPC and buffers navigation until main subscribes' h.controller.dispose(); }); -test('application broadcasts include registered auxiliary renderers', async () => { +test('application broadcasts reach each live workspace and auxiliary renderer once', async () => { const entry = fileURLToPath(new URL('../../../src/main/main-window.ts', import.meta.url)); const output = await build({ entryPoints: [entry], bundle: false, write: false, format: 'cjs', platform: 'node', define: { 'import.meta.dirname': JSON.stringify('/app/dist/main') } }); const module = { exports: {} as { createMainWindowController: typeof createMainWindowController } }; const auxiliaries = new Set(); + const workspaces = new Set(); runInNewContext(output.outputFiles[0]!.text, { module, exports: module.exports, process, require: (specifier: string) => specifier.endsWith('/auxiliary-window-registry.js') @@ -638,7 +639,13 @@ test('application broadcasts include registered auxiliary renderers', async () = renderers: () => [...auxiliaries], }, } - : { createWindowRevealGate: () => ({}) }, + : specifier.endsWith('/workspace-window-registry.js') + ? { + workspaceWindowRegistry: { + entries: () => [...workspaces].map((webContents) => ({ window: { webContents } })), + }, + } + : { createWindowRevealGate: () => ({}) }, }); const controller = module.exports.createMainWindowController({ workspaceRoot: '/workspace', e2eFixture: null, revealMode: 'hidden', @@ -650,12 +657,37 @@ test('application broadcasts include registered auxiliary renderers', async () = isDestroyed: () => false, send: (channel: string) => { messages.push(channel); }, }) as unknown as Electron.WebContents; + const workspaceMessages: string[] = []; + const workspaceRenderer = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + send: (channel: string) => { workspaceMessages.push(channel); }, + }) as unknown as Electron.WebContents; + const auxiliaryMessages: string[] = []; + const auxiliaryRenderer = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + send: (channel: string) => { auxiliaryMessages.push(channel); }, + }) as unknown as Electron.WebContents; + const destroyedRenderer = Object.assign(new EventEmitter(), { + isDestroyed: () => true, + send: () => { assert.fail('A destroyed renderer cannot receive broadcasts'); }, + }) as unknown as Electron.WebContents; auxiliaries.add(renderer); - controller.send('settings:changed'); + auxiliaries.add(auxiliaryRenderer); + workspaces.add(renderer); + workspaces.add(workspaceRenderer); + workspaces.add(destroyedRenderer); + assert.equal(controller.send('settings:changed'), true); assert.deepEqual(messages, ['settings:changed']); + assert.deepEqual(workspaceMessages, ['settings:changed']); + assert.deepEqual(auxiliaryMessages, ['settings:changed']); auxiliaries.delete(renderer); - controller.send('settings:changed'); + auxiliaries.delete(auxiliaryRenderer); + workspaces.delete(renderer); + workspaces.delete(workspaceRenderer); + assert.equal(controller.send('settings:changed'), false); assert.deepEqual(messages, ['settings:changed']); + assert.deepEqual(workspaceMessages, ['settings:changed']); + assert.deepEqual(auxiliaryMessages, ['settings:changed']); }); test('control shows a passive card only after it is painted and preserves manual conversation geometry', async () => { 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..7ba042894b0 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts @@ -0,0 +1,366 @@ +/* + * 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 { SAFE_MIN_HEIGHT, SAFE_MIN_WIDTH } from '../window-state.js'; +import { createSessionWindowCoordinator } from '../session-window-coordinator.js'; +import type { SessionWindowCommand } from '../../shared/session-window-contract.js'; +import type { MainWindowController } from '../main-window.js'; + +type TestDisplay = { workArea: Electron.Rectangle; scaleFactor: number }; + +async function harness() { + const registry = createWorkspaceWindowRegistry(); + const windows: FakeWindow[] = []; + let settingsReads = 0; + let releaseSettings!: () => void; + let settings = new Promise((resolve) => { releaseSettings = resolve; }); + const closedCounts: number[] = []; + const savedBounds: Electron.Rectangle[] = []; + const primary: TestDisplay = { workArea: { x: 0, y: 0, width: 1500, height: 1000 }, scaleFactor: 1 }; + const displays = { primary, matching: primary }; + const matchingCalls: Electron.Rectangle[] = []; + const dialogCalls: { kind: 'open' | 'save'; parent: unknown }[] = []; + const auxiliaryRenderers = new Set(); + const nativeParents = new Map(); + class Contents extends EventEmitter { + session = {}; + destroyed = false; + url = ''; + isDestroyed() { return this.destroyed; } + setWindowOpenHandler() {} + getURL() { return this.url; } + executeJavaScript() { return Promise.resolve(); } + send(_channel: string, command: SessionWindowCommand) { this.emit('command', command); } + } + class FakeWindow extends EventEmitter { + static fromWebContents(contents: Contents) { + return nativeParents.get(contents) ?? windows.find((window) => + !window.isDestroyed() && window.webContents === contents) ?? null; + } + readonly id = windows.length + 1; + private readonly contents = new Contents(); + destroyed = false; + visible = false; + private bounds: Electron.Rectangle; + readonly contentView = {}; + get webContents() { + if (this.destroyed) throw new Error('Object has been destroyed'); + return this.contents; + } + constructor(readonly options: Electron.BrowserWindowConstructorOptions) { + super(); + this.bounds = { x: options.x ?? 0, y: options.y ?? 0, width: options.width ?? 1240, height: options.height ?? 820 }; + windows.push(this); + } + isDestroyed() { return this.destroyed; } + isVisible() { return this.visible; } + isFocused() { return false; } + isMinimized() { return false; } + isMaximized() { return false; } + getBounds() { return { ...this.bounds }; } + setBounds(bounds: Electron.Rectangle) { this.bounds = { ...bounds }; } + getNormalBounds() { return this.getBounds(); } + async loadURL(url: string) { this.contents.url = url; this.contents.emit('did-finish-load'); this.emit('ready-to-show'); } + close() { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + this.emit('close', event); + if (!event.defaultPrevented) this.destroy(); + } + 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 }, + dialog: { + showOpenDialog: async (parent: unknown) => { dialogCalls.push({ kind: 'open', parent }); return { canceled: false, filePaths: ['/selected.txt'] }; }, + showSaveDialog: async (parent: unknown) => { dialogCalls.push({ kind: 'save', parent }); return { canceled: false, filePath: '/saved.txt' }; }, + }, + screen: { + getAllDisplays: () => [displays.primary, displays.matching], + getPrimaryDisplay: () => displays.primary, + getDisplayMatching: (bounds: Electron.Rectangle) => { matchingCalls.push(bounds); return displays.matching; }, + } }, + '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 (_root: string, bounds: Electron.Rectangle) => { savedBounds.push(bounds); }, SAFE_MIN_WIDTH, SAFE_MIN_HEIGHT }, + './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: () => auxiliaryRenderers.values(), + rendererParent: (contents: Contents) => !contents.isDestroyed() && auxiliaryRenderers.has(contents) ? {} : undefined, + } }, + './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, displays, matchingCalls, closedCounts, savedBounds, + dialogCalls, auxiliaryRenderers, nativeParents, + createRenderer: () => new Contents(), + createAuxiliaryWindow: () => new FakeWindow({ show: false }), + releaseSettings: () => releaseSettings(), + pauseSettings: () => { settings = new Promise((resolve) => { releaseSettings = resolve; }); }, + settingsReads: () => settingsReads, + }; +} + +test('file dialogs stay with the requesting workspace instead of the active main window', 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); + const child = h.windows[1]!; + await h.controller.showOpenDialog({}, child.webContents as never); + await h.controller.showSaveDialog({}, child.webContents as never); + assert.deepEqual(h.dialogCalls, [{ kind: 'open', parent: child }, { kind: 'save', parent: child }]); +}); + +test('WorkHub file dialogs follow its registered renderer from docked to floating', async () => { + const h = await harness(); + h.releaseSettings(); + await h.controller.createWindow(new AbortController().signal); + const main = h.windows[0]!; + const workHub = h.createRenderer(); + h.auxiliaryRenderers.add(workHub); + h.nativeParents.set(workHub, main); + assert.equal((await h.controller.showOpenDialog({}, workHub as never)).canceled, false); + assert.equal((await h.controller.showSaveDialog({}, workHub as never)).canceled, false); + const floating = h.createAuxiliaryWindow(); + h.nativeParents.set(workHub, floating); + main.destroy(); + assert.equal((await h.controller.showOpenDialog({}, workHub as never)).canceled, false); + assert.equal((await h.controller.showSaveDialog({}, workHub as never)).canceled, false); + assert.deepEqual(h.dialogCalls, [ + { kind: 'open', parent: main }, { kind: 'save', parent: main }, + { kind: 'open', parent: floating }, { kind: 'save', parent: floating }, + ]); +}); + +test('file dialogs reject unknown or destroyed auxiliary senders and missing or destroyed parents', async () => { + const h = await harness(); + const sender = h.createRenderer(); + const parent = h.createAuxiliaryWindow(); + h.nativeParents.set(sender, parent); + const assertCancelled = async () => { + assert.equal((await h.controller.showOpenDialog({}, sender as never)).canceled, true); + assert.equal((await h.controller.showSaveDialog({}, sender as never)).canceled, true); + assert.equal(h.dialogCalls.length, 0); + }; + await assertCancelled(); + h.auxiliaryRenderers.add(sender); + h.nativeParents.delete(sender); + await assertCancelled(); + h.nativeParents.set(sender, parent); + parent.destroy(); + await assertCancelled(); + h.nativeParents.set(sender, h.createAuxiliaryWindow()); + sender.destroyed = true; + await assertCancelled(); +}); + +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); +}); + +test('session windows follow the main window on a negative-coordinate HiDPI display in DIP', async () => { + const h = await harness(); + h.releaseSettings(); + const signal = new AbortController().signal; + await h.controller.createWindow(signal); + h.displays.matching = { workArea: { x: -2560, y: -1440, width: 2560, height: 1400 }, scaleFactor: 2 }; + const mainBounds = { x: -2400, y: -1300, width: 1240, height: 820 }; + h.windows[0]!.setBounds(mainBounds); + + await h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'A', group: { hostId: 'local', groupId: 'A', sessionIds: ['A'] } }, signal); + + assert.deepEqual(h.matchingCalls, [mainBounds]); + assert.deepEqual(h.windows[1]!.getBounds(), { x: -2368, y: -1268, width: 1240, height: 820 }); +}); + +test('session window placement clamps every edge to the main display work area', async () => { + const h = await harness(); + h.releaseSettings(); + const signal = new AbortController().signal; + await h.controller.createWindow(signal); + h.displays.matching = { workArea: { x: 1500, y: 40, width: 1600, height: 900 }, scaleFactor: 1 }; + h.windows[0]!.setBounds({ x: 2600, y: 700, width: 1240, height: 820 }); + + await h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'A', group: { hostId: 'local', groupId: 'A', sessionIds: ['A'] } }, signal); + assert.deepEqual(h.windows[1]!.getBounds(), { x: 1860, y: 120, width: 1240, height: 820 }); + + h.windows[0]!.setBounds({ x: 1400, y: -40, width: 1240, height: 820 }); + await h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'B', group: { hostId: 'local', groupId: 'B', sessionIds: ['B'] } }, signal); + assert.deepEqual(h.windows[2]!.getBounds(), { x: 1500, y: 40, width: 1240, height: 820 }); +}); + +test('session windows shrink only dimensions larger than the available work area', async () => { + const h = await harness(); + h.releaseSettings(); + const signal = new AbortController().signal; + await h.controller.createWindow(signal); + h.displays.matching = { workArea: { x: -1000, y: 40, width: 1000, height: 1000 }, scaleFactor: 1 }; + h.windows[0]!.setBounds({ x: -900, y: 100, width: 1240, height: 820 }); + + await h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'A', group: { hostId: 'local', groupId: 'A', sessionIds: ['A'] } }, signal); + + assert.deepEqual(h.windows[1]!.getBounds(), { x: -1000, y: 132, width: 1000, height: 820 }); + assert.equal(h.windows[1]!.options.minWidth, SAFE_MIN_WIDTH); + assert.equal(h.windows[1]!.options.minHeight, SAFE_MIN_HEIGHT); +}); + +test('session window minimum sizes cannot expand it beyond a small display work area', async () => { + const h = await harness(); + h.releaseSettings(); + const signal = new AbortController().signal; + await h.controller.createWindow(signal); + h.displays.matching = { workArea: { x: 1500, y: 40, width: 480, height: 240 }, scaleFactor: 1 }; + h.windows[0]!.setBounds({ x: 1500, y: 40, width: 1240, height: 820 }); + + await h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'A', group: { hostId: 'local', groupId: 'A', sessionIds: ['A'] } }, signal); + + assert.deepEqual(h.windows[1]!.getBounds(), { x: 1500, y: 40, width: 480, height: 240 }); + assert.equal(h.windows[1]!.options.minWidth, 480); + assert.equal(h.windows[1]!.options.minHeight, 240); +}); + +test('session placement uses the current main window position after asynchronous preparation', async () => { + const h = await harness(); + h.releaseSettings(); + const signal = new AbortController().signal; + await h.controller.createWindow(signal); + h.windows[0]!.setBounds({ x: 50, y: 50, width: 1240, height: 820 }); + h.pauseSettings(); + const child = h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'A', group: { hostId: 'local', groupId: 'A', sessionIds: ['A'] } }, signal); + assert.equal(h.settingsReads(), 2); + assert.equal(h.windows.length, 1); + h.displays.matching = { workArea: { x: 1500, y: 40, width: 2000, height: 1200 }, scaleFactor: 1 }; + const movedBounds = { x: 1700, y: 140, width: 1240, height: 820 }; + h.windows[0]!.setBounds(movedBounds); + h.releaseSettings(); + await child; + + assert.deepEqual(h.matchingCalls, [movedBounds]); + assert.deepEqual(h.windows[1]!.getBounds(), { x: 1732, y: 172, width: 1240, height: 820 }); +}); + +test('session windows center within the primary display work area when no main window exists', async () => { + const h = await harness(); + h.releaseSettings(); + h.displays.primary = { workArea: { x: 0, y: 40, width: 1600, height: 1000 }, scaleFactor: 2 }; + + await h.controller.createSessionWindow({ kind: 'session', initialSessionId: 'A', group: { hostId: 'local', groupId: 'A', sessionIds: ['A'] } }, new AbortController().signal); + + assert.equal(h.matchingCalls.length, 0); + assert.deepEqual(h.windows[0]!.getBounds(), { x: 180, y: 130, width: 1240, height: 820 }); +}); + + +test('coordinated immediate close saves final Main bounds through native close finalization', async () => { + const h = await harness(); + const coordinator = createSessionWindowCoordinator({ + windows: h.controller, signal: new AbortController().signal, ensureMainWindow: async () => {}, + resolveGroup: async (id) => ({ hostId: 'host', groupId: id, sessionIds: [id] }), + knownGroups: () => [], confirmClose: async () => false, + transferResources: async () => {}, releaseResources: () => {}, + isQuitting: () => false, onError: (error) => { throw error; }, + }); + h.releaseSettings(); + await h.controller.createWindow(new AbortController().signal); + const main = h.windows[0]!; + const sender = main.webContents; + sender.on('command', (command: SessionWindowCommand) => queueMicrotask(() => coordinator.reply(sender as never, + { requestId: command.requestId, ok: true, result: {} }))); + coordinator.sessionWindowReady(sender as never); + const bounds = { x: 230, y: 140, width: 1100, height: 760 }; + main.setBounds(bounds); + main.emit('move'); + main.close(); + await new Promise((resolve) => setTimeout(resolve, 450)); + assert.equal(main.destroyed, true); + assert.deepEqual(h.savedBounds.map(({ x, y, width, height }) => ({ x, y, width, height })), [bounds]); + coordinator.dispose(); +}); 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/browser-ipc-main.ts b/apps/desktop/src/main/browser-ipc-main.ts index 999e2beb772..6cf630ff84f 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,13 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { const selections = new Map(); const observedRenderers = new WeakSet(); + const rendererParents = new WeakMap(); 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; } @@ -70,6 +76,11 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { const isCoordination = (sessionId: string): boolean => isWorkHubCoordinationSessionId(parseDesktopSessionResourceKey(sessionId).sessionId); + const canPresentSession = (contents: Electron.WebContents, sessionId: string): boolean => + !deps.mainWindowController.isWorkspaceRenderer(contents) || (isCoordination(sessionId) + ? deps.mainWindowController.isMainRenderer(contents) + : deps.canUseSession(contents, desktopSessionKey(parseDesktopSessionResourceKey(sessionId)))); + // Coordination has one persistent conversation owner and may also be // presented by Main. Only its native page moves; automation keeps its owner. const ownerForSession = (sessionId: string): Electron.WebContents | undefined => { @@ -96,9 +107,9 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { window.isVisible() && !window.isMinimized(); }; const canRunInBackground = (sessionId: string): boolean => { + const ref = parseDesktopSessionResourceKey(sessionId); const owner = ownerForSession(sessionId); if (!owner || !browserParentForRenderer(owner)) return false; - const ref = parseDesktopSessionResourceKey(sessionId); return deps.isHostActive(ref); }; const revokeHiddenActions = (): void => { @@ -153,10 +164,24 @@ 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(); + } + 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 +202,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 +273,7 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { selection: RendererSelection, sessionId: string | null, ): void => { + if (sessionId && !canPresentSession(contents, 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 +287,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 +302,7 @@ 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 && canPresentSession(contents, sessionId) && browserParentForRenderer(contents) && selections.get(contents)?.sessionId === sessionId ? sessionId : undefined; @@ -366,6 +393,25 @@ 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(); + } + 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; 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..9ac41574ee2 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,156 @@ 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(); + } + + function resolveDialogParent(sender?: Electron.WebContents): BrowserWindow | undefined { + if (!sender) return workspaceWindowRegistry.active()?.window; + const workspace = workspaceWindowRegistry.forSender(sender); + if (workspace) return workspace.window; + if (!auxiliaryWindowRegistry.rendererParent(sender)) return undefined; + const parent = BrowserWindow.fromWebContents(sender); + return parent && !parent.isDestroyed() ? parent : undefined; + } + + 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 = resolveDialogParent(sender); + if (sender && !parent) return Promise.resolve({ canceled: true, filePaths: [] }); + return parent ? dialog.showOpenDialog(parent, options) : dialog.showOpenDialog(options); + }, + showSaveDialog(options, sender) { + const parent = resolveDialogParent(sender); + 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 +345,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 +363,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; + 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 disposeBrowserViews(): Promise { - await browserViews?.disposeAll(); - } - - 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,10 +394,14 @@ 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); + // Sample the main window after async preparation so a move to another + // display while settings load is reflected in the new session window. + const bounds = context.kind === 'session' + ? sessionWindowBounds(defaults) + : clampBoundsToVisibleDisplay(savedBounds); const persistedTheme = persistedAppearance?.theme ?? 'auto'; // Quit cleanup permanently closes process-scoped stores. Re-check after // asynchronous preparation so an in-flight request cannot attach a new @@ -389,8 +493,10 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main // window below the restore minimum. Shares SAFE_MIN_WIDTH and // SAFE_MIN_HEIGHT with sanitizeBounds so the resize floor and the restore // floor can't drift apart (locked by window-state.test.ts). - minWidth: SAFE_MIN_WIDTH, - minHeight: SAFE_MIN_HEIGHT, + // A session window must still fit when the display's work area is + // smaller than the usual resize floor. + minWidth: context.kind === 'session' ? Math.min(SAFE_MIN_WIDTH, bounds.width) : SAFE_MIN_WIDTH, + minHeight: context.kind === 'session' ? Math.min(SAFE_MIN_HEIGHT, bounds.height) : SAFE_MIN_HEIGHT, backgroundColor: initialBg, // The window stays hidden until `ready-to-show`, so the first visible // frame is already the launch surface — showing it at construction @@ -425,6 +531,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 +540,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 +600,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 +619,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 +648,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 +670,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 +722,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 +738,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 +761,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 +806,7 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main return !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused(); }, }; + return controller; } function rendererFrameIdentity(frame: Electron.WebFrameMain): MainRendererFrameIdentity { @@ -749,6 +850,22 @@ function clampBoundsToVisibleDisplay(bounds: SavedBounds): SavedBounds { return { width: bounds.width, height: bounds.height, isMaximized: bounds.isMaximized }; } +/** Place new session windows near main, using Electron's DIP coordinates. */ +function sessionWindowBounds(defaults: SavedBounds): SavedBounds { + const anchor = workspaceWindowRegistry.main()?.controller.windowBounds(); + const area = (anchor ? screen.getDisplayMatching(anchor) : screen.getPrimaryDisplay()).workArea; + const width = Math.min(defaults.width, area.width); + const height = Math.min(defaults.height, area.height); + const x = anchor ? anchor.x + 32 : area.x + Math.round((area.width - width) / 2); + const y = anchor ? anchor.y + 32 : area.y + Math.round((area.height - height) / 2); + return { + width, + height, + x: Math.max(area.x, Math.min(x, area.x + area.width - width)), + y: Math.max(area.y, Math.min(y, area.y + area.height - height)), + }; +} + function e2eFixtureWindowBounds( e2eFixture: E2eFixture | null, defaults: SavedBounds, @@ -767,8 +884,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/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 1a34e2ea100..b7cb8aa31ae 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -168,7 +168,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 c3b69ddeb31..1d8b163acbb 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -17,6 +17,8 @@ * under the License. */ +import { linkedSubagentParentSessionId } from '@maka/core/session'; +import { isSideConversationSession } from '@maka/core/side-conversation'; import { createAssistantWebBackend } from './assistant-web-backend.js'; import { startAssistantWebServer } from './assistant-web-server.js'; import { resolveDesktopWslHostHandoff } from './runtime-host-wsl-handoff.js'; @@ -24,6 +26,9 @@ import { app, autoUpdater as nativeAutoUpdater, type BrowserWindow, + type WebContents, + View, + dialog, clipboard, ipcMain, Menu, @@ -58,6 +63,7 @@ import { loadOrCreateRuntimeHostClientInstanceId, listRuntimeHostWslDistributions, runtimeHostProfileAccess, + projectSessionCatalogSummary, RuntimeHostProfileConnectionError, } from "@maka/runtime-host/client"; import { @@ -276,11 +282,16 @@ 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 { createSessionWindowCoordinator, registerSessionWindowIpc } from './session-window-coordinator.js'; + const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; bootContext.prepareToQuit = prepareRuntimeHostDesktopQuit; bootContext.cleanup = closeRuntimeHostDesktop; @@ -449,10 +460,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 — @@ -488,12 +495,18 @@ const sessionLocal = new DesktopSessionLocalService(sessionLocalStore, { const target = localSessionTarget(state); return target ? [target] : []; }), - changed: localSessionChanged, + changed: (scope, sessionId) => { + sessionWindows.refreshOwnership(); + localSessionChanged(scope, sessionId); + }, onError: (error) => console.error('[session-local] background synchronization failed:', error), }); +const sessionWindowGroups = new SessionWindowGroups(); +const sessionWindowLifetime = new AbortController(); + registerDesktopSessionLocalIpc({ ipcMain, service: sessionLocal, approvals: attachmentApprovals, resizeImage: resizeImageForAttachment, - changed: localSessionChanged, + changed: (scope, sessionId) => { sessionWindows.refreshOwnership(); localSessionChanged(scope, sessionId); }, resolveWorkspace: async (target, input) => { const context = runtimePolicyTargetsByEpoch.get(target.scope.targetEpoch); if (!context?.isActive()) throw new Error('Select a cached project before creating an offline task'); @@ -817,7 +830,72 @@ 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 ?? []; + sessionWindowGroups.update(hostId, cached); + // The existing local catalog supplies known ancestry. Only a newly created + // member missing from that catalog needs a targeted Host read. + let missing: string | undefined = id; + const seen = new Set(); + while (missing && !sessionWindowGroups.has(missing) && !seen.has(missing)) { + seen.add(missing); + if (state.readiness !== 'ready') throw new Error('Session is unavailable'); + const session = await state.candidate.client.getSession(parseDesktopSessionKey(missing).sessionId); + if (!session) throw new Error('Session is unavailable'); + sessionWindowGroups.update(hostId, [projectSessionCatalogSummary(session)]); + const parent = linkedSubagentParentSessionId(session) + ?? session.revisionRootSessionId ?? (isSideConversationSession(session.labels) ? session.parentSessionId : undefined); + missing = parent ? desktopSessionKey({ hostId, sessionId: parent }) : undefined; + } + return sessionWindowGroups.resolve(id, []); + }, + knownGroups: () => { + for (const entry of sessionLocal.catalog()) sessionWindowGroups.update(entry.scope.hostId, entry.sessions); + return sessionWindowGroups.all(); + }, + confirmClose: async (sender) => { + const zh = (await desktopLocale.resolve()).startsWith('zh'); + const options = { + type: 'warning' as const, title: 'Maka', + message: zh ? '关闭窗口并丢弃未发送的内容?' : 'Close this window and discard unsent content?', + buttons: zh ? ['取消', '丢弃并关闭'] : ['Cancel', 'Discard and close'], + defaultId: 0, cancelId: 0, + }; + const window = mainWindowController.browserWindowFor(sender); + const result = await (window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)); + return result.response === 1; + }, + transferResources: async ({ group, source, target }) => { + browserIpc.transferSessions(group.sessionIds, source, target); + const sessionId = native.computerUsePip.currentSessionId(); + if (sessionId && group.sessionIds.includes(desktopSessionKey(parseDesktopSessionResourceKey(sessionId)))) native.computerUsePip.clearForSession(sessionId); + }, + releaseResources: (sender) => { + 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, @@ -852,10 +930,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, @@ -1024,6 +1102,7 @@ const notifyRun = createRunNotifier({ locale: desktopLocale, mainWindowController, e2e: isE2e, + }); const sessionCopyOwnerProcessId = randomUUID(); @@ -1217,6 +1296,13 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( transcriptInitialHistoryBytes: PARTIAL_HISTORY_TRANSCRIPT_BYTES, } : {}), + canUseSession: async (senderId, scope, sessionId) => { + const sender = mainWindowController.registry.entries().find((entry) => entry.window.webContents.id === senderId)?.window.webContents; + if (!sender) return false; + const key = desktopSessionKey({ ...scope, sessionId }); + await sessionWindows.refreshGroup(key); + return sessionWindows.canUseSession(sender, key); + }, retireCancelledMessages: (scope, sessionId, messageIds) => sessionLocal.retireCancelledMessages(scope, sessionId, messageIds), failNotAdmittedMessages: (scope, sessionId, messageIds) => @@ -1254,7 +1340,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); sessionWindowGroups.forgetHost(state.hostId); } } sessionLocal.wake(); const event = runtimeHostTargetEvent(state); @@ -1298,7 +1384,8 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( }, onTargetRemoved: (state) => { const localTarget = localSessionTarget(state); - if (localTarget) sessionLocal.purge(localTarget); + if (localTarget) { sessionLocal.purge(localTarget); } + sessionWindowGroups.forgetHost(state.hostId); mainWindowController.send("runtime-host-profiles:changed", { ...runtimeHostTargetEvent(state), readiness: "unavailable", @@ -2059,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', ); @@ -2068,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] } }; }); @@ -2076,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"); @@ -2148,7 +2235,10 @@ function emitSessionsChanged( ): void { sessionLocal.changed(scope); if (reason === 'deleted' && sessionId) { - try { sessionLocal.removeSession(sessionLocal.target(scope), sessionId); } catch { /* A retired target cannot repopulate its cache. */ } + try { + const target = sessionLocal.target(scope); + sessionLocal.removeSession(target, sessionId); + } catch { /* A retired target cannot repopulate its cache. */ } } const event: SessionChangedEvent = { reason, @@ -2203,7 +2293,9 @@ async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> { return dialog.decisions[response] === 'quit'; }, }); - if (preparation === 'ready') mainWindowController.browserWindow()?.destroy(); + if (preparation === 'ready') { + for (const entry of mainWindowController.registry.entries()) entry.window.destroy(); + } return preparation; } @@ -2212,6 +2304,8 @@ function closeRuntimeHostDesktop(): Promise { } async function disposeRuntimeHostDesktop(): Promise { + sessionWindowLifetime.abort(); + sessionWindows.dispose(); await assistantWebStart; await assistantWebServer?.close().catch((error: unknown) => console.error('[assistant-web] shutdown failed:', error)); sessionLocal.close(); 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 42b65e93033..fee8bc35b15 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 660bade7488..7e04578ba89 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -132,6 +132,7 @@ import { type CandidateIpcMain = ReconnectableReadIpcMain & Pick; export interface DesktopRuntimeHostCandidateDeps { + readonly canUseSession?: (senderId: number, scope: DesktopTargetScope, sessionId: string) => boolean | Promise; readonly resolveWslOperator?: ReturnType; readonly terminalCloses?: import('./terminal-close-intents.js').TerminalCloseIntents; readonly retireCancelledMessages?: (scope: DesktopTargetScope, sessionId: string, messageIds: readonly string[]) => void; @@ -795,6 +796,7 @@ export async function createDesktopRuntimeHostCandidate( { client, terminalCloses, + canUseSession: deps.canUseSession ? (sender, id) => deps.canUseSession!(sender.id, scope, id) : undefined, sessionObserver, emitModeChanged, ...(deps.renderer ? { sendToRenderer } : {}), 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..f579687b0a8 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 | Promise; 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-shell-runs-ipc-main.ts b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts index d3dfe318baf..8bfc8f636af 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 | Promise; 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'); + await 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', async (event, value: unknown) => { const input = runtimeResourceIdentity(value, 'stop'); - return closes.stop(input, () => controllers.stop(input)); + await 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 | Promise, ) { this.#client = client; this.#newId = newId; @@ -213,20 +219,32 @@ class RuntimeResourceControllers { target: RuntimeHostSessionObserverTarget, ): Promise { return this.#run(input, async () => { + await 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) { + async assertOwner(target: RuntimeHostSessionObserverTarget, sessionId: string): Promise { + if (this.canUseSession && !await this.canUseSession(target, sessionId)) { + throw new Error('Session is displayed in another window'); + } + } + + control(input: RuntimeResourceControl, target: RuntimeHostSessionObserverTarget) { return this.#run(input, async () => { + await 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 () => { + await 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-window-coordinator.ts b/apps/desktop/src/main/session-window-coordinator.ts new file mode 100644 index 00000000000..30fa12719e1 --- /dev/null +++ b/apps/desktop/src/main/session-window-coordinator.ts @@ -0,0 +1,376 @@ +/* + * 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, + SessionWindowDraft, SessionWindowGroup, SessionWindowNavigation, SessionWindowOwnership, +} from '../shared/session-window-contract.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 knownGroups: () => readonly SessionWindowGroup[]; + readonly confirmClose: (sender: WebContents) => Promise; + readonly transferResources: (input: { group: SessionWindowGroup; source: WebContents | undefined; target: WebContents }) => void | Promise; + readonly releaseResources: (sender: WebContents) => void | Promise; + readonly isQuitting: () => boolean; + readonly onError: (error: unknown, sender?: WebContents) => void; + readonly commandTimeoutMs?: number; +} + +/** Main owns session families. Renderers receive only their current navigation membership. */ +export function createSessionWindowCoordinator(deps: SessionWindowCoordinatorDeps) { + const registry = deps.windows.registry; + const groups = new Map(); + const memberGroups = new Map(); + const owners = new Map(); + const transferring = new Set(); + const ready = new Map(); + const readyWaiters = new Map void>>(); + const pending = new Map(); + const closing = new Set(); + const finalized = new Set(); + let operations: Promise = Promise.resolve(); + let version = 0; + const timeoutMs = deps.commandTimeoutMs ?? 20_000; + const keyOf = (group: SessionWindowGroup) => JSON.stringify([group.hostId, group.groupId]); + const report = (error: unknown, sender?: WebContents) => 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 assertOpen(entry: WorkspaceWindowEntry): void { + if (entry.window.isDestroyed() || closing.has(entry.context.windowId)) throw new Error('The workspace window is closing'); + } + function serial(operation: () => Promise): Promise { + const next = operations.catch(() => undefined).then(operation); + operations = next; + return next; + } + function membership(entry: WorkspaceWindowEntry): SessionWindowOwnership { + const sessionIds = new Set(); + for (const [key, group] of groups) { + const owner = owners.get(key); + if (owner === entry.context.windowId || (!owner && entry.context.kind === 'main')) { + for (const id of group.sessionIds) sessionIds.add(id); + } + } + return { version, sessionIds: [...sessionIds] }; + } + function publish(): void { + version += 1; + for (const entry of registry.entries()) entry.window.webContents.send('session-window:ownership', membership(entry)); + } + function remember(resolved: SessionWindowGroup): SessionWindowGroup { + const keys = resolved.sessionIds.flatMap((id) => memberGroups.has(id) ? [memberGroups.get(id)!] : []); + const key = keys.find((id) => owners.has(id)) ?? keyOf(resolved); + if (keys.some((id) => id !== key && owners.has(id))) throw new Error('Related sessions belong to different windows'); + const previous = groups.get(key); + const group = { ...resolved, groupId: previous?.groupId ?? resolved.groupId, + sessionIds: [...new Set([...(previous?.sessionIds ?? []), ...resolved.sessionIds])] }; + for (const old of new Set(keys)) if (old !== key) groups.delete(old); + groups.set(key, group); + for (const id of group.sessionIds) memberGroups.set(id, key); + return group; + } + function refreshOwnership(): void { + for (const group of deps.knownGroups()) remember(group); + publish(); + } + async function resolveGroup(id: string): Promise { + const group = remember(await deps.resolveGroup(id)); + publish(); + return group; + } + function owner(group: SessionWindowGroup): WorkspaceWindowEntry | undefined { + const id = owners.get(keyOf(group)); + return id ? registry.get(id) : undefined; + } + function rejectRequests(windowId: string): void { + ready.delete(windowId); + for (const complete of readyWaiters.get(windowId) ?? []) complete(new Error('The workspace window closed')); + readyWaiters.delete(windowId); + for (const operation of pending.values()) if (operation.windowId === windowId) operation.reject(new Error('The workspace window closed')); + } + async function waitReady(entry: WorkspaceWindowEntry): Promise { + if (entry.window.isDestroyed()) throw new Error('The workspace window is unavailable'); + const id = entry.context.windowId; + if (ready.has(id)) return; + await new Promise((resolve, reject) => { + const waiters = readyWaiters.get(id) ?? new Set<(error?: Error) => void>(); + const complete = (error?: Error) => { clearTimeout(timer); waiters.delete(complete); if (error) reject(error); else resolve(); }; + const timer = setTimeout(() => complete(new Error('The workspace window did not become ready')), timeoutMs); + readyWaiters.set(id, waiters); + waiters.add(complete); + }); + } + async function request(entry: WorkspaceWindowEntry, input: CommandInput): Promise { + await waitReady(entry); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const complete = (error?: Error, result?: SessionWindowCommandResult) => { + clearTimeout(timer); pending.delete(requestId); + if (error) reject(error); else resolve(result ?? {}); + }; + const timer = setTimeout(() => complete(new Error(`Workspace ${input.kind} was not confirmed`)), timeoutMs); + pending.set(requestId, { windowId: entry.context.windowId, resolve: (result) => complete(undefined, result), reject: complete }); + try { entry.window.webContents.send('session-window:command', { ...input, requestId }); } + catch (error) { complete(error instanceof Error ? error : new Error(String(error))); } + }); + } + async function ensureMain(): Promise { + await deps.ensureMainWindow(); + const entry = registry.main(); + if (!entry) throw new Error('The main window is unavailable'); + assertOpen(entry); + await waitReady(entry); + return entry; + } + async function navigate(entry: WorkspaceWindowEntry, navigation: SessionWindowNavigation): Promise { + assertOpen(entry); + await request(entry, { kind: 'navigate', navigation }); + entry.controller.reveal(); + entry.controller.focus(); + } + async function transfer(group: SessionWindowGroup, initial: string, target: WorkspaceWindowEntry, source?: WorkspaceWindowEntry): Promise { + assertOpen(target); + if (source) assertOpen(source); + const key = keyOf(group); + const transferId = randomUUID(); + const targetSender = target.window.webContents; + const sourceSender = source?.window.webContents; + transferring.add(key); + let movedResources = false; + try { + await waitReady(target); + const receivingDocument = ready.get(target.context.windowId); + if (!receivingDocument) throw new Error('The receiving renderer is unavailable'); + const captured = source ? await request(source, { kind: 'prepare', transferId, group }) : { drafts: [] }; + const drafts = requireDrafts(captured.drafts, group); + const sessionId = source?.context.kind === 'session' && captured.activeSessionId && group.sessionIds.includes(captured.activeSessionId) + ? captured.activeSessionId : initial; + // Receiving only stages the drafts. Ownership publication activates the + // target after receipt; until then the source keeps its original drafts. + await request(target, { kind: 'receive', transferId, group, navigation: { sessionId }, drafts }); + movedResources = true; + await deps.transferResources({ group, source: sourceSender, target: targetSender }); + assertOpen(target); + if (ready.get(target.context.windowId) !== receivingDocument) throw new Error('The receiving renderer changed during handoff'); + if (target.context.kind === 'session') owners.set(key, target.context.windowId); + else owners.delete(key); + } catch (error) { + // A staged target is never active before ownership commits. Do not destroy + // Main or snapshot its unrelated conversation when returning a child fails. + await request(target, { kind: 'abort', transferId }).catch((failure) => report(failure, targetSender)); + if (source && !source.window.isDestroyed()) { + if (movedResources) await Promise.resolve().then(() => deps.transferResources({ group, source: targetSender, target: sourceSender! })).catch((failure) => report(failure, sourceSender)); + await request(source, { kind: 'abort', transferId }).catch((failure) => report(failure, sourceSender)); + } + throw error; + } finally { transferring.delete(key); } + publish(); + target.controller.reveal(); + target.controller.focus(); + if (source?.context.kind === 'session' && !source.window.isDestroyed()) source.window.destroy(); + } + function attach(entry: WorkspaceWindowEntry): void { + const id = entry.context.windowId; + const sender = entry.window.webContents; + sender.on('did-start-navigation', (_event, _url, inPlace, mainFrame) => { if (mainFrame && !inPlace) rejectRequests(id); }); + sender.on('render-process-gone', () => { + rejectRequests(id); + void Promise.resolve().then(() => deps.releaseResources(sender)).catch((error) => report(error, sender)); + }); + entry.window.once('closed', () => { + rejectRequests(id); + void Promise.resolve().then(() => deps.releaseResources(sender)).catch((error) => report(error, sender)); + for (const [key, ownerId] of owners) if (ownerId === id) owners.delete(key); + finalized.delete(id); + publish(); + }); + entry.window.on('close', (event) => { + if (deps.isQuitting() || finalized.has(id)) return; + event.preventDefault(); + if (closing.has(id)) return; + closing.add(id); + void serial(async () => { + if (entry.window.isDestroyed()) return; + if (entry.context.kind === 'session') { + let unsent = true; + try { + const group = await resolveGroup(entry.context.initialSessionId); + unsent = (await request(entry, { kind: 'check-close', group })).hasUnsent !== false; + } catch (error) { report(error, sender); } + if (unsent && !await deps.confirmClose(sender)) { + await request(entry, { kind: 'cancel-close' }); + return; + } + } + finalized.add(id); + entry.window.close(); + }).catch(async (error) => { + await request(entry, { kind: 'cancel-close' }).catch(() => undefined); + report(error, sender); + }).finally(() => closing.delete(id)); + }); + } + for (const entry of registry.entries()) attach(entry); + const unsubscribe = registry.onRegistered(attach); + + function ownerForSession(sessionId: string): WebContents | undefined { + const key = memberGroups.get(sessionId); + const id = key && owners.get(key); + return id ? registry.get(id)?.window.webContents : undefined; + } + return { + context: (sender: WebContents) => requireWindow(sender).context, + ownership(sender: WebContents) { refreshOwnership(); return membership(requireWindow(sender)); }, + refreshOwnership, + refreshGroup: resolveGroup, + ownerForSession, + canUseSession(sender: WebContents, sessionId: string): boolean { + const entry = registry.forSender(sender); + const key = memberGroups.get(sessionId); + if (!entry || !key || transferring.has(key)) return false; + const owner = ownerForSession(sessionId); + return owner ? owner === sender : entry.context.kind === 'main'; + }, + routeSession(sender: WebContents, navigation: SessionWindowNavigation): Promise { + const origin = requireWindow(sender); + return serial(async () => { + assertOpen(origin); + const group = await resolveGroup(navigation.sessionId); + await navigate(owner(group) ?? await ensureMain(), navigation); + }); + }, + openSessionWindow(sender: WebContents, sessionId: string): Promise { + const origin = requireWindow(sender); + return serial(async () => { + assertOpen(origin); + const group = await resolveGroup(sessionId); + const existing = owner(group); + if (existing) { await navigate(existing, { 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'); + try { await transfer(group, sessionId, requireWindow(window.webContents), source); } + catch (error) { if (!window.isDestroyed()) window.destroy(); throw error; } + }); + }, + openInMainWindow(sender: WebContents): Promise { + const origin = requireWindow(sender); + return serial(async () => { + assertOpen(origin); + if (origin.context.kind !== 'session') return; + const group = await resolveGroup(origin.context.initialSessionId); + if (owner(group) !== origin) throw new Error('This session belongs to another window'); + await transfer(group, origin.context.initialSessionId, await ensureMain(), origin); + }); + }, + sessionWindowReady(sender: WebContents): void { + const entry = requireWindow(sender); + const id = entry.context.windowId; + if (ready.has(id)) return; + ready.set(id, {}); + for (const complete of readyWaiters.get(id) ?? []) complete(); + refreshOwnership(); + // A renderer reload keeps the native window's existing ownership, but + // does not persist or reconstruct unsent drafts from another store. + if (entry.context.kind === 'session' && ownerForSession(entry.context.initialSessionId) === sender) { + void serial(() => navigate(entry, { sessionId: (entry.context as Extract).initialSessionId })).catch((error) => report(error, sender)); + } + }, + reply(sender: WebContents, reply: SessionWindowCommandReply): void { + const operation = pending.get(reply.requestId); + if (operation?.windowId !== requireWindow(sender).context.windowId) return; + if (reply.ok) operation.resolve(reply.result ?? {}); else operation.reject(new Error(reply.error)); + }, + commandMain(sender: WebContents, command: 'newTask' | 'openSettings' | 'openHelp'): Promise { + const origin = requireWindow(sender); + return serial(async () => { + assertOpen(origin); + const entry = await ensureMain(); + entry.window.webContents.send('window:command', { id: command }); + entry.controller.focus(); + }); + }, + dispose(): void { + unsubscribe(); + for (const entry of registry.entries()) rejectRequests(entry.context.windowId); + }, + }; +} + +function requireDrafts(value: readonly SessionWindowDraft[] | undefined, group: SessionWindowGroup): readonly SessionWindowDraft[] { + if (!Array.isArray(value)) throw new Error('The source window did not confirm its drafts'); + const ids = new Set(); + for (const draft of value) { + if (!draft || typeof draft.text !== 'string' || !Array.isArray(draft.references) || !group.sessionIds.includes(draft.sessionId) || ids.has(draft.sessionId)) throw new Error('Invalid session draft'); + ids.add(draft.sessionId); + } + return value; +} +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('ownership', (sender) => coordinator.ownership(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('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..4a7b88a1cc1 --- /dev/null +++ b/apps/desktop/src/main/session-window-group.ts @@ -0,0 +1,109 @@ +/* + * 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>(); + readonly #groups = new Map>(); + + has(sessionKey: string): boolean { + const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); + return this.#catalogs.get(hostId)?.has(sessionId) ?? false; + } + + update(hostId: string, sessions: readonly WindowGroupSession[]): void { + let catalog = this.#catalogs.get(hostId); + if (!catalog) this.#catalogs.set(hostId, catalog = new Map()); + for (const session of sessions) { + const previous = catalog.get(session.id); + if (!previous || sessionRevisionFamilyId(previous) !== sessionRevisionFamilyId(session) + || linkedSubagentParentSessionId(previous) !== linkedSubagentParentSessionId(session) + || previous.parentSessionId !== session.parentSessionId + || isSideConversationSession(previous.labels) !== isSideConversationSession(session.labels)) this.#groups.delete(hostId); + catalog.set(session.id, session); + } + } + + all(): SessionWindowGroup[] { + return [...this.#catalogs.keys()].flatMap((hostId) => [...new Set(this.#forHost(hostId).values())]); + } + + resolve(sessionKey: string, sessions: readonly WindowGroupSession[]): SessionWindowGroup { + const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); + this.update(hostId, sessions); + const group = this.#forHost(hostId).get(sessionId); + if (!group) throw new Error('Session is unavailable'); + return group; + } + + #forHost(hostId: string): Map { + const cached = this.#groups.get(hostId); + if (cached) return cached; + const catalog = this.#catalogs.get(hostId) ?? new Map(); + 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 members = new Map(); + for (const id of catalog.keys()) { + const root = rootOf(id); + const group = members.get(root) ?? []; + group.push(id); + members.set(root, group); + } + const result = new Map(); + for (const [root, ids] of members) { + // A deleted parent retains its identity while the family window is open. + if (!ids.includes(root)) ids.push(root); + const group = { hostId, groupId: desktopSessionKey({ hostId, sessionId: root }), + sessionIds: ids.sort().map((id) => desktopSessionKey({ hostId, sessionId: id })) }; + for (const id of ids) result.set(id, group); + } + this.#groups.set(hostId, result); + return result; + } + + forgetHost(hostId: string): void { this.#catalogs.delete(hostId); this.#groups.delete(hostId); } +} 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 578dde8aa02..ec98ec6ef7a 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -345,6 +345,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 = @@ -1463,7 +1465,7 @@ export interface MakaBridge { sessionIds: readonly string[], options?: { measureBytes?: boolean; requireArchived?: boolean }, ): Promise; - cleanupSessionCopy(sessionId: string): Promise; + cleanupSessionCopy(sessionId: string, recovery?: { sourceSessionId: string; attachmentIds: readonly string[] }): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; }; transcripts: { @@ -1916,7 +1918,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 a46dbc0a50f..8440603c7a2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2779,8 +2779,12 @@ const makaBridge = { ): Promise { return previewDesktopSessionRemoval(sessionIds, options); }, - cleanupSessionCopy(sessionId: string): Promise { - return invokeSessionRuntimeHost('sessions:cleanupSessionCopy', sessionId); + async cleanupSessionCopy(sessionId, recovery): Promise { + const session = await runtimeHostSessionRef(sessionId); + const source = recovery && parseDesktopSessionKey(recovery.sourceSessionId); + if (source && source.hostId !== session.scope.hostId) throw new Error('Side conversation belongs to another Host'); + return invokeWhenReady('sessions:cleanupSessionCopy', session.scope, session.sessionId, + recovery && { sourceSessionId: source!.sessionId, attachmentIds: recovery.attachmentIds }); }, async abandonSessionCopy(sourceSessionId: string, copyId: string): Promise { const source = await runtimeHostSessionRef(sourceSessionId); @@ -3886,6 +3890,24 @@ const makaBridge = { }, }, appWindow: { + context: () => invokeWhenReady('session-window:context'), + ownership: () => invokeWhenReady('session-window:ownership'), + subscribeSessionWindowOwnership(handler) { + const listener = (_event: Electron.IpcRendererEvent, snapshot: import('../shared/session-window-contract.js').SessionWindowOwnership) => handler(snapshot); + ipcRenderer.on('session-window:ownership', listener); + return () => ipcRenderer.off('session-window:ownership', listener); + }, + 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), + 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/README.md b/apps/desktop/src/renderer/README.md index ee37545a5de..2258edba25f 100644 --- a/apps/desktop/src/renderer/README.md +++ b/apps/desktop/src/renderer/README.md @@ -285,7 +285,6 @@ or reason, which stays with review. | `AppShellContent` | `useAppShellPersistenceEffects` | theme and navigation persistence | `` theme class and palette; stored navigation | legacy `app-shell-effects.ts` | apply the theme preference and palette; persist the navigation state | layout | — | | `AppShellContent` | `useAppShellProjectContext` | project context | titlebar project name, path and open-folder gate; Workbar project id and aliases; Module Hub and command-palette client-path access; composer mentions project path; the default-Host refresh in the lifecycle handlers | legacy `use-project-context.ts` | read the owner Session's project (path, Git state, current project, capabilities) and, with no Session, the default Host's, and re-read it on change; no writes: project mutations and the open-folder commands belong to Task Entry | application lifecycle | — | | `AppShellContent` | `useAppShellSessionWorkspace` | Session workspace | every region's requested, published and owner Session; transcript flags; the queue surface and named Composer edits; the Session UI reads store | legacy `use-app-shell-session-workspace.ts` over the Session catalog and Conversation | Session selection, the catalog controller, list refresh and row patches, message reads and refresh | navigation | — | -| `AppShellContent` | `useLayoutEffect` | `openSessionInChatRef` publication | every caller of `openSessionInChat`: turn footer, transcript lineage, linked-Session and revision links, Composer submission, Workbar, Module Hub, command palette, Settings overlays, turn-request approval, agent graph, titlebar parent link | AppShell | publish the current open-Session command into a ref | cross-region command | — | | `AppShellContent` | `useSessionNavigationReads` | rail reads | transcript revision navigation, titlebar parent and `sidebarCollapsed`, frame `data-sidebar-state` and `--maka-sidenav-width` | Session Navigation | revision navigation, the active parent Session and the rail layout; writes the compact flag into `sessionRailLayoutStore` | navigation | — | | `AppShellContent` | `useSessionSettingIntent` | selected-Session setting overlay | Composer model, thinking and mode controls; the palette's permission-mode command; new-task settings for creation; Session teardown | Session Settings | an equality-selected overlay read, the new-task settings and setting commands | cross-region command | — | | `AppShellContent` | `useShellAppearance` | appearance settings | theme, palette, user label, Workbar toggle position, locale update gate; `appearanceHydrated` for the previous-shutdown notice | legacy `use-shell-appearance.ts` | read client and Host appearance settings and E2E state, hydrate the locale, apply theme, palette and font sizes, and expose local setters | layout | — | @@ -319,7 +318,7 @@ projection one feature makes into another's region is composed here. | module-hub | `ModuleHubSkillCatalogRevisionBoundary` | Composer mentions provider | render-prop skill catalog revision | stays: cross-region projection into the Composer | | module-hub | `ModuleHubScheduledTasksBoundary` | Session rail (`SessionNavigationProvider`) | render-prop scheduled tasks | stays: cross-region projection into navigation | | module-hub | `createModuleHubCommandPort` | command palette; project selection | command port | stays: cross-region command | -| session-navigation | `createSessionOpenCommand` | open-Session command | command factory | stays: cross-region command | +| session-navigation | `createSessionNavigationCommands` | window-aware Session and new-task commands | command factory | stays: cross-region command | | session-navigation | `sessionRailLayoutStore` | rail collapse handle; E2E fixture | layout store | stays: layout | | session-navigation | `useSessionNavigationReads` | see the retained-root table | read hook | stays: navigation | | session-settings | `useSessionSettingIntent` | see the retained-root table | read hook and commands | stays: cross-region command | diff --git a/apps/desktop/src/renderer/app-shell-chrome-actions.tsx b/apps/desktop/src/renderer/app-shell-chrome-actions.tsx index 7b0d96d798f..a8eb9721cdb 100644 --- a/apps/desktop/src/renderer/app-shell-chrome-actions.tsx +++ b/apps/desktop/src/renderer/app-shell-chrome-actions.tsx @@ -24,13 +24,10 @@ import { WindowTitlebar } from './shell/window-titlebar'; import { WorkbarTitlebarActions } from './features/workbar'; /** Localize the shell frame without giving its presentation layer state. */ -export function AppShellTitlebar({ workbar, children, ...props }: Omit, 'copy'> & { +export function AppShellTitlebar({ workbar, workbarActions = workbar && , ...props }: Omit, 'copy'> & { workbar?: ComponentProps; }) { return ( - - {children} - {workbar && } - + ); } diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index a88d537d4eb..3da67a29902 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, @@ -50,6 +50,7 @@ type ComposerImportOwner = { type RefBox = { current: T }; export interface AppShellCommandListOptions { + standalone?: boolean; uiLocale: UiLocale; activeId: string | undefined; activePermissionMode: PermissionMode | undefined; @@ -128,6 +129,7 @@ export function buildAppShellCommandList( }; return buildCommandList({ + allowGlobalNavigation: !options.standalone, locale, activeSessionId: options.activeId, themePref: options.themePref, @@ -389,13 +391,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 8950e2ff64c..b0024d0a7d8 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -24,7 +24,6 @@ import { RuntimeHostHandoffOverlay } from './features/runtime-host-management/in import { ArchiveRetentionNotices } from './features/storage-usage/index.js'; import { useCallback, - useLayoutEffect, useMemo, useRef, useState, @@ -34,18 +33,16 @@ import { 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 { type ComposerHandle, + type NavSelection, MakaUriContext, AstryxLocaleProvider, LocaleProvider, type ToastDiagnosticTarget, - type NavSelection, - type ProjectRowActions, SessionListPanel, TitlebarSessionIdentity, type TurnFooterActionMeta, @@ -59,12 +56,14 @@ 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, - createSessionOpenCommand, + SessionNavigationCommands, + createSessionNavigationCommands, sessionRailLayoutStore, useSessionNavigationReads, type SessionNavigationPorts, @@ -158,8 +157,10 @@ export function AppShell() { - - + + {(sessionWindow) => <> + {!sessionWindow.standalone && } + {(taskEntry) => ( {(overlays) => ( @@ -167,13 +168,13 @@ export function AppShell() { {(sharedSessionDialog) => ( {(workbar) => ( - + {(onboarding) => ( {(copyManualDiagnosticReport) => ( )} @@ -187,7 +188,8 @@ export function AppShell() { )} )} - + } + @@ -208,6 +210,7 @@ export function AppShell() { const SESSION_RAIL = ; function AppShellContent({ + sessionWindow, taskEntry, overlays, sharedSessionDialog, @@ -218,6 +221,7 @@ function AppShellContent({ setUiLocaleOverride, setUiLocalePreference, }: { + sessionWindow: SessionWindowProjection; taskEntry: TaskEntryShellProjection; overlays: OverlaysShellProjection; sharedSessionDialog: SessionCollaborationDialogProjection; @@ -240,7 +244,7 @@ function AppShellContent({ refreshChangedSession, activeId, activeIdRef, - bootstrapSelectionLease, + readSelectionRevision, setActiveId, startNewSession, clearOwnedSessionState, @@ -263,19 +267,16 @@ function AppShellContent({ const sessionCount = authoritativeSessionIds?.size ?? 0; const { - openHelp, closePalette, openSearch, setSearchScrollTarget, - openSettings, - openSettingsSection, openProjectSettings, openProviderCatalog, openConnectionDetail, - openExternalAgentSettings, openProviderCreate, setSettingsProfileId, } = overlays.commands; + const { openHelp, openSettings, openSettingsSection, openExternalAgentSettings } = sessionWindow.mainActions(overlays.commands); const { searchScrollTarget } = overlays.selectors; const settingsOpen = overlays.selectors.settings.open; @@ -297,16 +298,17 @@ 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, )); - }, []); - const navSelectionRef = useRef(navSelection); + }, [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). const [workHubActive, setWorkHubActive] = useState(false); @@ -554,16 +556,6 @@ function AppShellContent({ return setOrchestrationMode('default'); } - const openSessionInChatRef = useRef< - (sessionId: string, turnId?: string, sequence?: number) => void - >(() => undefined); - const openSessionInChat = useCallback( - (sessionId: string, turnId?: string, sequence?: number): void => { - openSessionInChatRef.current(sessionId, turnId, sequence); - }, - [], - ); - /** 技能页 使用: jump to the chat view and seed the composer with a skill * invocation. Same human-in-the-loop rule as maka://compose — we never * auto-send; the user finishes the sentence and presses Enter. @@ -582,12 +574,6 @@ function AppShellContent({ }, [shellCopy], ); - const openWorkHub = useCallback(() => { - overlays.commands.closeSettings(); - setNavSelection({ section: 'sessions' }); - setWorkHubActive(true); - }, [overlays.commands, setNavSelection]); - // Transient placeholder while the real SessionSummary loads, so the composer // does not flash a value the session never had. const activeSessionForView = activeSession ?? (activeId @@ -726,39 +712,6 @@ function AppShellContent({ }; const taskSubmissionHardBlocked = !activeId && !taskEntry.selectors.target; - const openNewTaskSurface = useCallback(() => { - composerStaging.resetImageNotice(NEW_TASK_PENDING_KEY); - const ownerToken = startNewSession(); - // Only Plan resets: a new task starts out of Plan, in whatever - // orchestration the last one was set to. - sessionSettingIntent.commands.setNewTaskPlanMode(false); - setNavSelection({ section: 'sessions' }); - setSearchScrollTarget(null); - // New-task affordances reset to the empty-state composer; move focus - // there so the user can start typing immediately. - window.requestAnimationFrame(() => composerEditing.focus()); - return ownerToken; - }, [composerEditing, composerStaging, sessionSettingIntent.commands, setNavSelection, setSearchScrollTarget, startNewSession]); - - const createSession = useCallback(async () => { - openNewTaskSurface(); - }, [openNewTaskSurface]); - - // Stable, because the rail's Project rows carry it: a fresh identity here - // rebuilt the whole list on every AppShell commit (#4109). - const projectRowActions = useMemo( - () => taskEntry.selectors.projectScopes.length === 0 ? undefined : { - onNew: (key) => { - if (taskEntry.commands.selectProject(key)) openNewTaskSurface(); - }, - onRename: taskEntry.commands.renameProject, - onArchive: taskEntry.commands.archiveProject, - onRestore: taskEntry.commands.restoreProject, - onRelink: taskEntry.commands.relinkProject, - }, - [openNewTaskSurface, taskEntry.commands, taskEntry.selectors.projectScopes.length], - ); - // Composer mention popups: `/` uses Runtime's session/project-aware, // host-compatible projection; `@` uses workspace file search. Keep the // resolved project path as a refresh key for new-chat project changes. Only @@ -781,25 +734,27 @@ function AppShellContent({ const hasModalOpen = overlays.selectors.anyModalOpen || sharedSessionDialog.isOpen; const shellObscured = hasModalOpen || settingsOpen; - const exitWorkHub = useCallback(() => setWorkHubActive(false), []); - const openSession = useMemo( - () => - createSessionOpenCommand({ - activateSession: setActiveId, - exitWorkHub, - selectSessionSurface: () => setNavSelection({ section: 'sessions' }), - setSearchTarget: setSearchScrollTarget, - }), - [exitWorkHub, setNavSelection, setActiveId, setSearchScrollTarget], + const { openSession, openLocal, clearLocal, openNewTaskSurface, createSession, projectRowActions, bootstrapSessions, exitWorkHub, enableWorkHub, openWorkHub } = useMemo( + () => createSessionNavigationCommands({ + window: sessionWindow, + activateSession: setActiveId, setWorkHubActive, closeSettings: overlays.commands.closeSettings, + selectSessionSurface: () => setNavSelection({ section: 'sessions' }), setSearchTarget: setSearchScrollTarget, + newTask: { prepare: () => composerStaging.resetImageNotice(NEW_TASK_PENDING_KEY), start: startNewSession, + resetPlanMode: () => sessionSettingIntent.commands.setNewTaskPlanMode(false), focus: composerEditing.focus }, + projects: taskEntry.commands, hasProjects: taskEntry.selectors.projectScopes.length > 0, + refreshSessions, readSelectionRevision, + }), + [sessionWindow.openSession, sessionWindow.requestNewTask, sessionWindow.bootstrap, setActiveId, overlays.commands.closeSettings, setNavSelection, + setSearchScrollTarget, composerStaging, startNewSession, sessionSettingIntent.commands, composerEditing.focus, + taskEntry.commands, taskEntry.selectors.projectScopes.length, refreshSessions, readSelectionRevision], ); - useLayoutEffect(() => { - openSessionInChatRef.current = openSession; - }, [openSession]); + const openSessionInChat = openSession; const sessionNavigationCommandsRef = useRef(null); // Built inline: the rail reads these through a ref published on commit, so // 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, @@ -819,9 +774,9 @@ function AppShellContent({ if (!activeParentSession) return undefined; return { name: activeParentSession.name, - onOpen: () => openSessionInChatRef.current(activeParentSession.id), + onOpen: () => openSessionInChat(activeParentSession.id), }; - }, [activeParentSession]); + }, [activeParentSession, openSessionInChat]); const archivedTasksBridge = useMemo( () => ({ catalog: sessionCatalogController, @@ -879,7 +834,7 @@ function AppShellContent({ toastApi, }); useAppShellPersistenceEffects({ - navigationState, + navigationState: sessionWindow.standalone ? undefined : navigationState, themePalette, themePref, }); @@ -922,12 +877,6 @@ function AppShellContent({ return owner.sessionId === undefined && isComposerImportOwnerActive(owner); } - async function bootstrapSessions() { - const next = await refreshSessions(); - bootstrapSelectionLease.reconcile(collapseSessionRevisions(next)); - bootstrapSelectionLease.release(); - } - const dispatchMakaUri = createInternalUriDispatcher(ownerActiveId, openSettingsSection, composerEditing); function closeSettings() { @@ -993,6 +942,7 @@ function AppShellContent({ // `ConversationHomeSurface` reads for the displayed Session. const homeSurfaceEligible = sessionsSelected && transcriptEmpty; const commandOptions: AppShellCommandListOptions = { + standalone: sessionWindow.standalone, uiLocale, activeId, activePermissionMode, @@ -1141,9 +1091,10 @@ function AppShellContent({ - + {(conversation) => + } + {sessionWindow.standalone && } + {!sessionWindow.standalone && } void defaultHostConnections.refreshConnections()} /> - { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} /> + {!sessionWindow.standalone && } sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} onOpenSearchModal={openSearch} workbar={{ togglePosition: workbarTogglePosition }} @@ -1244,7 +1202,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 && }