diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index db038c77ed3..851a34345e2 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -84,6 +84,10 @@ "zh-tw-locale.spec.ts": { "tests": 1, "electron": "the locale is a persisted Host setting and only takes effect through a renderer reload" + }, + "session-windows.spec.ts": { + "tests": 2, + "electron": "native BrowserWindow focus/minimize/close and main recreation, real WebContentsView identity across renderer/main transfer, and restart with 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..fada6a97037 --- /dev/null +++ b/apps/desktop/e2e/session-windows.spec.ts @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createServer } from 'node:http'; +import type { ElectronApplication, Page } from '@playwright/test'; +import { COMPOSER_INPUT, awaitSendReady, ensureSidebarExpanded, expect, test } from './fixtures'; + +async function createTask(page: Page, text: string): Promise { + await page.locator(COMPOSER_INPUT).fill(text); + await awaitSendReady(page); + await page.locator(COMPOSER_INPUT).press('Enter'); + await expect(page.getByText(`Fake backend received: ${text}`)).toBeVisible(); + await ensureSidebarExpanded(page); + const id = await page.locator('[data-session-id]:has([aria-current="page"])').getAttribute('data-session-id'); + if (!id) throw new Error('No active session'); + return id; +} +async function detach(app: ElectronApplication, page: Page, id: string): Promise { + const next = app.waitForEvent('window'); + await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id); + const child = await next; + await expect(child.locator(COMPOSER_INPUT)).toBeVisible(); + return child; +} + +// Electron boundary: real WebContentsView identity and renderer/main handoff; +// a mock window cannot expose Chromium destruction during native reparenting. +test('detaching and returning keeps the same native browser and restores a minimized owner', async ({ sessionLocalWindow }) => { + const { page, app } = sessionLocalWindow; + const id = await createTask(page, 'multi-window browser continuity'); + await page.locator(COMPOSER_INPUT).fill('draft travelling with the session'); + const server = createServer((_request, response) => response.end('Window handoff')); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No server address'); + const url = `http://127.0.0.1:${address.port}/`; + await page.evaluate(async ({ id, url }) => { window.maka.browser.setActiveSession(id); await window.maka.browser.navigate(id, url); }, { id, url }); + const nativePageId = await app.evaluate(async ({ webContents }, url) => { + const native = webContents.getAllWebContents().find((contents) => contents.getURL() === url); + if (!native) throw new Error('Native browser page not found'); + await native.executeJavaScript('document.querySelector("input").value = "preserved native state"'); + return native.id; + }, url); + const child = await detach(app, page, id); + await expect(child.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session'); + await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); + const childNative = await app.browserWindow(child); + await childNative.evaluate((window) => window.minimize()); + await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id); + await expect.poll(() => childNative.evaluate((window) => window.isMinimized())).toBe(false); + // Isolated E2E fixtures intentionally reveal without taking OS focus. + await expect.poll(() => childNative.evaluate((window) => window.isVisible())).toBe(true); + await child.getByRole('button', { name: '在主窗口打开', exact: true }).click(); + await expect.poll(() => child.isClosed()).toBe(true); + await expect(page.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session'); + expect(await app.evaluate(async ({ webContents }, nativeId) => { + const native = webContents.fromId(nativeId); + return native?.executeJavaScript('document.querySelector("input").value'); + }, nativePageId)).toBe('preserved native state'); + } finally { await new Promise((resolve) => server.close(() => resolve())); } +}); + +// Electron boundary: closing/recreating real BrowserWindows and app restart, +// 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('saved after closing a session window'); + const mainNative = await app.browserWindow(page); + await mainNative.evaluate((window) => window.close()); + await expect.poll(() => page.isClosed()).toBe(true); + expect(child.isClosed()).toBe(false); + const reopened = app.waitForEvent('window'); + await child.getByRole('button', { name: '在主窗口打开', exact: true }).click(); + const main = await reopened; + await expect(main.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window'); + const secondChild = await detach(app, main, id); + await expect(secondChild.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window'); + const childNative = await app.browserWindow(secondChild); + await childNative.evaluate((window) => window.close()); + await expect.poll(() => secondChild.isClosed()).toBe(true); + await ensureSidebarExpanded(main); + await main.locator(`[data-session-id=${JSON.stringify(id)}]`).click(); + await expect(main.locator(COMPOSER_INPUT)).toHaveText('saved after closing a session window'); + await detach(app, main, id); + let restartedApp: ElectronApplication | undefined; + const restored = await restart(async (next) => { restartedApp = next; }); + expect((await restored.evaluate(() => window.maka.appWindow.context())).kind).toBe('main'); + await expect.poll(async () => { + const pages = restartedApp!.windows().filter((window) => !window.isClosed()); + const contexts = await Promise.all(pages.map((window) => window.evaluate(() => window.maka?.appWindow?.context()).catch(() => undefined))); + return contexts.filter((context) => context?.kind === 'session').length; + }).toBe(0); + await ensureSidebarExpanded(restored); + await restored.locator(`[data-session-id=${JSON.stringify(id)}]`).click(); + await expect(restored.locator(COMPOSER_INPUT)).toHaveText(''); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f3a9c66c71b..9841159de32 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -348,12 +348,14 @@ "ConversationMessageConsumer", "ConversationProvider", "ConversationTranscriptRegion", + "ConversationWindowState", "NEW_TASK_PENDING_KEY", "PlanExecutionSurface", "PlanProvider", "TaskReadinessProvider", "createComposerStagingCommands", "createComposerSubmissionCommands", + "createComposerSurfaceOwner", "createExecutorSessionActivator", "resolveTaskReadinessModelTarget", "useActiveExecutionBoundary", @@ -455,6 +457,7 @@ }, "src/renderer/features/session-navigation/index.ts": { "appShell": [ + "SessionNavigationCommands", "SessionNavigationProvider", "createSessionOpenCommand", "sessionRailLayoutStore", @@ -467,12 +470,24 @@ "src/renderer/features/session-settings/index.ts": { "appShell": [ "SessionSettingsProvider", + "createSessionModeCommands", + "deriveSessionSettingsPresentation", "useSessionSettingIntent" ], "composition": [ "SessionSettingsServicesProvider" ] }, + "src/renderer/features/session-windows/index.ts": { + "appShell": [ + "SessionWindowBinding", + "SessionWindowEmptyState", + "SessionWindowRoot" + ], + "composition": [ + "SessionWindowServicesProvider" + ] + }, "src/renderer/features/storage-usage/index.ts": { "appShell": [ "ArchiveRetentionNotices" @@ -534,7 +549,7 @@ "@maka/ui": 1 }, "importSpecifiers": 0, - "nonTriviaTokens": 122 + "nonTriviaTokens": 117 }, "src/renderer/app-shell-command-actions.ts": { "importDeclarations": 4, @@ -558,7 +573,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2143 + "nonTriviaTokens": 2117 }, "src/renderer/app-shell-copy.ts": { "importDeclarations": 1, @@ -620,7 +635,7 @@ "react": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 843 + "nonTriviaTokens": 841 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 4, @@ -653,7 +668,7 @@ "nonTriviaTokens": 858 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 34, + "importDeclarations": 33, "bridgePaths": {}, "environmentCapabilities": { "window.requestAnimationFrame": 3 @@ -711,6 +726,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, @@ -737,13 +753,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": 7280 + "importSpecifiers": 42, + "nonTriviaTokens": 7279 }, "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__/attachment-preview.test.ts b/apps/desktop/src/main/__tests__/attachment-preview.test.ts index 221f5a9a557..b16ae2839af 100644 --- a/apps/desktop/src/main/__tests__/attachment-preview.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-preview.test.ts @@ -20,8 +20,10 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; import { loadApprovalPreview, + loadRecoveryApprovalPreview, registerAttachmentPreviewIpc, type AttachmentPreviewResult, } from '../attachment-preview.js'; @@ -200,6 +202,8 @@ describe('staged attachment preview (approval source)', () => { (event: { sender: { id: number } }, approvalId: unknown) => Promise >(); registerAttachmentPreviewIpc({ + recovery: new SessionLocalAttachmentRecovery(), + authorizeRecovery: () => true, ipcMain: { handle: (channel, listener) => handlers.set(channel, listener) }, approvals, readFile: async () => PNG, @@ -259,3 +263,39 @@ describe('staged attachment preview (approval source)', () => { ); }); }); + + +describe('workspace recovery image preview', () => { + const owner = { senderId: 1, partition: 'authorized', scope: { hostId: 'host', targetEpoch: 'epoch' }, sessionId: 'session' }; + it('rebuilds a thumbnail for the new window without exposing original bytes or consuming its send approval', async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]); + const thumbnail = Uint8Array.from([...PNG, 1, 2, 3]); + const result = await loadRecoveryApprovalPreview({ senderId: owner.senderId, approvalId: approval.approvalId, recovery, + authorizeRecovery: (candidate) => candidate.partition === owner.partition && candidate.scope.targetEpoch === owner.scope.targetEpoch, + renderPreview: async () => ({ mimeType: 'image/png', bytes: thumbnail }), + }); + assert.deepEqual(result, { ok: true, mimeType: 'image/png', base64: Buffer.from(thumbnail).toString('base64') }); + assert.notEqual((result as { base64: string }).base64, Buffer.from(PNG).toString('base64')); + assert.equal(recovery.prepare(owner, [approval]).commit(() => true), true); + }); + it('rejects another window or revoked Host and never falls back to original recovery bytes', async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]); + const input = { senderId: owner.senderId, approvalId: approval.approvalId, recovery, authorizeRecovery: () => true, renderPreview: async () => null }; + assert.deepEqual(await loadRecoveryApprovalPreview({ ...input, senderId: 2 }), { ok: false, reason: 'not_found' }); + assert.deepEqual(await loadRecoveryApprovalPreview({ ...input, authorizeRecovery: () => false }), { ok: false, reason: 'not_found' }); + assert.deepEqual(await loadRecoveryApprovalPreview(input), { ok: false, reason: 'unreadable' }); + }); + it('discards a thumbnail if source authority expires during decoding', async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [approval] = recovery.issue(owner, [{ name: 'image.png', mimeType: 'image/png', content: PNG }]); + const result = await loadRecoveryApprovalPreview({ senderId: owner.senderId, approvalId: approval.approvalId, recovery, + authorizeRecovery: () => true, renderPreview: async () => { + recovery.clear(owner.partition); + return { mimeType: 'image/png', bytes: PNG }; + }, + }); + assert.deepEqual(result, { ok: false, reason: 'not_found' }); + }); +}); diff --git a/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts b/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts index f949323ae03..d51f923f5be 100644 --- a/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/browser-ipc-main.test.ts @@ -157,14 +157,20 @@ test('browser IPC isolates owned renderer documents and their native parents', a await controller?.dispose(); }, }; + const workspaceRenderers = new Set([main]); + let sessionOwner: unknown = main; const mainWindowController = { + isWorkspaceRenderer: (contents: unknown) => workspaceRenderers.has(contents), getBrowserViews: (resolve: typeof parentResolver) => { parentResolver = resolve; return manager; }, isMainRenderer: (contents: Electron.WebContents) => contents === main as unknown as Electron.WebContents, }; + const parkingParent = { getVisible: () => false } as Electron.View; const browserIpc = registerBrowserIpc({ + parkingParent, + canUseSession: (contents, id) => !id.includes('main-session') || contents === sessionOwner, mainWindowController: mainWindowController as never, auxiliaryWindowRegistry: { rendererParent: (contents: Electron.WebContents) => @@ -360,10 +366,33 @@ test('browser IPC isolates owned renderer documents and their native parents', a // Electron rejects fromWebContents on that destroyed native object. emit('browser:active-session', main, scope, 'main-session', 'main-document', 5); emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 5); + const child = new FakeRenderer('child-frame'); + const childWindow = new FakeWindow(); + const childParent = { getVisible: () => true } as Electron.View; + childWindow.contentView = childParent; + windows.set(child, childWindow); + workspaceRenderers.add(child); + emit('browser:document-ready', child, 'child-document'); + sessionOwner = child; + browserIpc.transferSessions([JSON.stringify([scope.hostId, 'main-session'])], main as never, child as never); + emit('browser:active-session', child, scope, 'main-session', 'child-document', 1); + emit('browser:setViewport', child, scope, { sessionId: 'main-session', rect: mainRect }, 'child-document', 1); + emit('browser:active-session', main, scope, 'main-session', 'main-document', 6); + emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 6); + await invoke('browser:navigate', main, scope, 'main-session', 'https://late-source.example/'); + assert.equal(mainController.parent, childParent, 'late source selection cannot steal the destination page'); + assert.deepEqual(mainController.navigations, ['https://main.example/']); assert.doesNotThrow(() => main.destroy()); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(mainController.disposed, true, 'closing Main still releases its owned browser page'); + assert.equal(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 2d954deb3f0..0d0c8333c82 100644 --- a/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-staging-owner.test.ts @@ -41,11 +41,16 @@ import { createChatActions } from '../../renderer/features/conversation/testing. import { createRevisionActions, type TurnRevisionDraft } from '../../renderer/features/conversation/testing.js'; import { createDesktopComposerStagingServices } from '../../renderer/platform/desktop/create-composer-staging-services.js'; import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION, windowSubmissionServices } from './app-shell-chat-actions-fixture.js'; +import { captureSessionWorkspace, clearSessionWorkspace, restoreSessionWorkspace } from '../../renderer/application/contracts/session-workspace-handoff.js'; import { ComposerStagingFixture } from './composer-staging-fixture.js'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import type { DesktopLocalMessageDraft } from '../../shared/session-local-contract.js'; +import { createFakeWorkbarServices, createSessionWorkbarPanelsState, openSessionWorkbarPanelTab, useSideConversationWorkspace, WorkbarServicesProvider, WorkbarSurface, type WorkbarServices } from '../../renderer/features/workbar/testing.js'; +import { captureSideConversationWorkspace, clearSideConversationWorkspace, restoreSideConversationWorkspace } from '../../renderer/application/contracts/session-side-conversation-handoff.js'; +import type { SideConversationWorkspaceState } from '../../shared/session-workspace-state.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; const saved = Object.fromEntries([ 'window', 'document', 'Element', 'HTMLBRElement', 'sessionStorage', 'HTMLElement', 'HTMLIFrameElement', 'Event', 'Node', 'CSS', @@ -213,6 +218,13 @@ test('staged follow-up holds recovered attachments through removal and preserves assert.deepEqual(released, [['local-recovery:follow-up']], 'the removed attachment is released after the refused send'); }); +test('idle workspace capture does not start Session reference resolution', async () => { + let referenceRequests = 0; + const view = await mount({ waitForSessionReference: async () => { referenceRequests++; return false; } }); + await view.composer.current!.waitForDraftReady(); + assert.equal(referenceRequests, 0); +}); + test('StagedComposer keeps the submitted recovery attachment while waiting for Session references', async () => { const released: string[][] = []; let finishReference!: (ready: boolean) => void; @@ -259,7 +271,10 @@ test('StagedComposer keeps the submitted recovery attachment while waiting for S assert.ok(form); form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); }); + let ready = false; + const flushed = view.composer.current!.waitForDraftReady().then(() => { ready = true; }); await act(() => view.removeAttachment()); + assert.equal(ready, false, 'workspace capture waits for a send still resolving Session references'); assert.deepEqual(released, [], 'early Composer ownership protects the approval before onSend starts'); assert.deepEqual(submissions, []); await act(async () => { finishReference(true); await Promise.resolve(); }); @@ -267,7 +282,9 @@ test('StagedComposer keeps the submitted recovery attachment while waiting for S sources: [{ type: 'approval', approvalId: 'local-recovery:waiting-reference', name: 'recovered.txt' }], replaces: 'paused-original', }], 'the later factory must submit the content captured by the original send'); - await act(async () => { finishAdmission(false); await sent; }); + assert.equal(ready, false, 'workspace capture also waits for admission and Composer cleanup'); + await act(async () => { finishAdmission(false); await sent; await flushed; }); + assert.equal(ready, true); assert.deepEqual(released, [['local-recovery:waiting-reference']]); }); @@ -868,3 +885,244 @@ test('Desktop backs the attachment port with the bridge reader; AppShell no long const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8'); assert.deepEqual(shell.split('\n').filter((line) => /\battachments\s*\.\s*readBytes\b|\bonReadAttachmentBytes\b/.test(line)), []); }); + + +test('window handoff carries inactive member drafts and staging while preserving an unrelated session', async () => { + const view = await mount(); + await act(() => { + view.composer.current!.appendDraft('draft-a', '@file.ts first', [{ kind: 'workspace_file', value: '@file.ts', label: 'file.ts', start: 0 }], 'replacement'); + view.commands.addQuote({ text: 'quoted A', turnId: 'turn-a' }); + view.stage({ attachments: [attachment], directoryReferences: [{ hostId: 'host-a', path: '/project-a' }] }); + }); + await view.render('draft-child'); + await act(() => { + view.composer.current!.setText('child draft'); + view.commands.addQuote({ text: 'quoted child' }); + view.restoreRecovery('local-recovery:child'); + }); + await view.render('unrelated'); + await act(() => { + view.composer.current!.setText('B stays here'); + view.commands.addQuote({ text: 'B quote' }); + }); + const input = { composer: view.composer.current, staging: view.commands, sessionIds: ['draft-a', 'draft-child'] }; + const saved = await captureSessionWorkspace(input); + assert.deepEqual(saved.drafts.map((draft) => draft.text), ['@file.ts first', 'child draft']); + assert.equal(saved.drafts[0]!.replacesMessageId, 'replacement'); + assert.deepEqual(saved.drafts[0]!.directoryReferences, [{ hostId: 'host-a', path: '/project-a' }]); + assert.equal(saved.drafts[0]!.attachments[0]!.type, 'retained'); + assert.equal(saved.drafts[1]!.attachments[0]!.type, 'approval'); + assert.ok(!JSON.stringify(saved).includes('previewUrl')); + await act(() => clearSessionWorkspace(input)); + assert.equal(view.composer.current!.getText(), 'B stays here'); + assert.deepEqual(view.commands.captureSubmission().quotesForSend(), [{ text: 'B quote' }]); + assert.equal(view.composer.current!.getDraft('draft-a'), ''); + assert.equal(view.commands.captureDrafts(['draft-a'])[0]!.attachments.length, 0); + // Main reissues these approvals; restore accepts only the resulting handles. + const restored = { ...saved, drafts: saved.drafts.map((draft) => ({ ...draft, attachments: draft.attachments.map((item) => item.type === 'approval' ? { ...item, approvalId: 'local-recovery:destination' } : item) })) }; + await act(() => restoreSessionWorkspace({ ...input, snapshot: restored })); + await view.render('draft-a'); + assert.equal(view.composer.current!.getText(), '@file.ts first'); + assert.deepEqual(view.composer.current!.captureDrafts(['draft-a'])[0]!.references, [{ kind: 'workspace_file', value: '@file.ts', label: 'file.ts', start: 0 }]); + assert.deepEqual(view.commands.captureSubmission().quotesForSend(), [{ text: 'quoted A', sourceTurnId: 'turn-a' }]); + assert.equal(view.commands.captureSubmission().submittableAttachments?.[0]?.source.type, 'retained'); + await view.render('draft-child'); + assert.equal(view.composer.current!.getText(), 'child draft'); + assert.deepEqual(view.commands.captureSubmission().submittableAttachments?.[0]?.source, { type: 'approval', approvalId: 'local-recovery:destination', name: 'recovered.txt' }); +}); + +test('restoring a retained workspace keeps shared recovery approvals sendable and releases only replaced context', async () => { + const released: string[] = []; + const sent: string[] = []; + const view = await mount({ + releaseRecoveryAttachments: async (ids) => { released.push(...ids); }, + onSend: async (_text, _metadata, staging) => { + for (const item of staging?.submittableAttachments ?? []) { + assert.equal(item.source.type, 'approval'); + if (item.source.type !== 'approval') continue; + assert.ok(!released.includes(item.source.approvalId), 'restored sends must not use an expired approval'); + sent.push(item.source.approvalId); + } + return false; + }, + }); + await act(() => { + view.restoreRecovery('local-recovery:retained-main-B'); + view.composer.current!.setText('B draft'); + view.commands.addQuote({ text: 'B quote' }); + view.stage({ directoryReferences: [{ hostId: 'host-a', path: '/B' }] }); + }); + const input = { composer: view.composer.current, staging: view.commands, sessionIds: ['draft-a'] }; + const snapshot = await captureSessionWorkspace(input); + await act(() => restoreSessionWorkspace({ ...input, snapshot })); + assert.equal(released.length, 0, 'rollback to the current snapshot retains the current approval'); + await act(() => { + view.restoreRecovery('local-recovery:replaced'); + view.commands.addQuote({ text: 'discarded quote' }); + view.stage({ directoryReferences: [{ hostId: 'host-a', path: '/discarded' }] }); + }); + await act(() => restoreSessionWorkspace({ ...input, snapshot })); + assert.deepEqual(released, ['local-recovery:replaced']); + const restored = view.commands.captureSubmission(); + assert.deepEqual(restored.quotesForSend(), [{ text: 'B quote' }]); + assert.deepEqual(restored.directoryOptions, { directoryReferences: [{ hostId: 'host-a', path: '/B' }] }); + await act(async () => { + view.container.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + await view.composer.current!.waitForDraftReady(); + }); + assert.deepEqual(sent, ['local-recovery:retained-main-B']); +}); + +test('workspace replacement moves recovery approvals between member drafts without releasing unrelated owners', async () => { + const released: string[] = []; + const view = await mount({ releaseRecoveryAttachments: async (ids) => { released.push(...ids); } }); + await act(() => view.restoreRecovery('local-recovery:moving')); + await view.render('draft-child'); + await act(() => view.restoreRecovery('local-recovery:obsolete-child')); + await view.render('unrelated'); + await act(() => view.restoreRecovery('local-recovery:unrelated')); + const input = { composer: view.composer.current, staging: view.commands, sessionIds: ['draft-a', 'draft-child'] }; + const captured = await captureSessionWorkspace(input); + const moving = captured.drafts[0]!.attachments; + const snapshot = { ...captured, drafts: captured.drafts.map((draft) => ({ ...draft, + attachments: draft.sessionId === 'draft-child' ? moving : [], + })) }; + await act(() => restoreSessionWorkspace({ ...input, snapshot })); + assert.deepEqual(released, ['local-recovery:obsolete-child']); + const drafts = view.commands.captureDrafts(['draft-a', 'draft-child', 'unrelated']); + assert.deepEqual(drafts.map((draft) => draft.attachments.map((item) => item.source.type === 'approval' ? item.source.approvalId : 'unexpected')), + [[], ['local-recovery:moving'], ['local-recovery:unrelated']]); + await act(() => clearSessionWorkspace(input)); + assert.deepEqual(released, ['local-recovery:obsolete-child', 'local-recovery:moving']); + assert.equal(view.commands.captureSubmission().submittableAttachments?.length, 1, 'unrelated draft retains its approval'); +}); + +async function mountSideWorkspace(options: { + send?: WorkbarServices['sideChat']['send']; + initialSessionId?: string; + companion?: boolean; +} = {}) { + const view = await mount(); + const source = { ...recoverySession('B'), llmConnectionId: 'connection' }; + const fork = { ...source, id: 'B-side' }; + const registry = new SessionLocalAttachmentRecovery(); + const owner = { senderId: 11, partition: 'local', scope: { hostId: 'host-a', targetEpoch: 'epoch-1' }, sessionId: options.companion ? fork.id : source.id }; + const approved = registry.issue(owner, [{ name: 'side-draft.txt', mimeType: 'text/plain', content: new Uint8Array([1, 2, 3]) }])[0]!; + const released: string[] = []; + const dismissed: string[] = []; + const base = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + attachments: { ...base.attachments, releaseRecoveryAttachments: async (ids) => { released.push(...ids); registry.release(owner.senderId, ids); } }, + sideChat: { ...base.sideChat, + listSessions: async () => [source, fork], listTurns: async () => [{ turnId: 'source-turn', status: 'completed' }], + branchFromTurn: async () => ({ ok: true, session: fork }), + cleanupSessionCopy: async (id) => { dismissed.push(id); }, + abandonSessionCopy: async (id) => { dismissed.push(id); }, + ...(options.send ? { send: options.send } : {}), + }, + }); + const emptyPanels = createSessionWorkbarPanelsState(); + const panels = openSessionWorkbarPanelTab(emptyPanels, 'right', { id: 'side-chat:panel-B', kind: 'side-chat' }); + const record: SideConversationWorkspaceState = { + panel: { id: 'panel-B', sourceSessionId: source.id, quotes: [] }, hasContent: false, active: false, + ...(options.companion ? { companion: { sessionId: fork.id, ownTurnIds: [], queueOwnedMessageIds: [] } } : {}), + draft: { sessionId: owner.sessionId, text: 'B side draft', references: [], quotes: [], directoryReferences: [], + attachments: [{ type: 'approval', ...approved }] }, + }; + function Frame({ sessionId }: { sessionId?: string }) { + const workspace = useSideConversationWorkspace(); + return createElement(WorkbarSurface, { + sessionId, sourceSession: sessionId ? { ...source, id: sessionId } : undefined, + modelChoices: [{ connectionId: 'connection', connectionSlug: 'test', providerType: 'openai', providerLabel: 'Test', model: 'test', label: 'Test', isDefault: true, thinkingLevels: [] }], + panelsState: sessionId === source.id ? panels : emptyPanels, + quotes: workspace.panels, hidden: false, rightCollapsed: false, bottomOpen: false, + onDismissPanel() {}, onToggleRightPanel() {}, onActivateTab() {}, onCloseTab() {}, onOpenLauncher() {}, onRequestOpenTab() {}, + onContentStateChange: workspace.setContent, onActivityStateChange: workspace.setActive, confirmBypass: async () => true, + }); + } + const render = async (sessionId?: string) => { + await act(async () => root!.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(ToastProvider, { children: createElement(WorkbarServicesProvider, { services, children: createElement(Frame, { sessionId }) }) }), + }))); + }; + await render(options.initialSessionId ?? source.id); + await act(() => restoreSideConversationWorkspace([record])); + const capture = async () => { + let captured!: SideConversationWorkspaceState[]; + await act(async () => { captured = await captureSideConversationWorkspace(['B']); }); + return captured; + }; + return { ...view, render, capture, registry, owner, approvalId: approved.approvalId, released, dismissed, record }; +} + +test('Workbar layout changes and rollback keep another task side draft mounted; group clear releases it', async () => { + const view = await mountSideWorkspace({ companion: true }); + const form = view.container.querySelector('form'); + assert.ok(form); + for (const next of ['A', undefined, 'B']) { + await view.render(next); + // Failed transfer rollback restores the retained snapshot while B is still owned here. + await act(() => restoreSideConversationWorkspace([view.record])); + assert.equal(view.container.querySelector('form'), form, 'the existing side composer stays mounted'); + const captured = await view.capture(); + assert.equal(captured[0]!.draft.text, 'B side draft'); + assert.equal(captured[0]!.draft.sessionId, 'B-side'); + assert.ok(view.registry.ownerForPreview(view.owner.senderId, view.approvalId)); + assert.equal(view.released.length, 0); + assert.equal(view.dismissed.length, 0); + } + await act(() => clearSideConversationWorkspace(['B'])); + assert.equal(view.container.querySelector('form'), null, 'a transferred group no longer leaves a source panel behind'); + assert.deepEqual(view.released, [view.approvalId]); + assert.equal(view.registry.ownerForPreview(view.owner.senderId, view.approvalId), undefined); + assert.equal(view.dismissed.length, 0, 'handoff preserves the backend fork'); +}); + +for (const companion of [false, true]) { + test(`restored inactive member side draft captures its owner before the member is visited (fork=${companion})`, async () => { + const view = await mountSideWorkspace({ initialSessionId: 'A', companion }); + const captured = await view.capture(); + assert.equal(captured[0]!.draft.sessionId, companion ? 'B-side' : 'B'); + assert.equal(captured[0]!.draft.text, 'B side draft'); + assert.deepEqual(captured[0]!.draft.attachments, view.record.draft.attachments); + await act(() => clearSideConversationWorkspace(['B'])); + assert.deepEqual(view.released, [view.approvalId]); + }); +} + +test('side composer holds recovery attachments during pending admission and releases them after unmount settles', async () => { + let complete!: (value: Awaited>) => void; + const pending = new Promise>>((resolve) => { complete = resolve; }); + let sending = false; + const view = await mountSideWorkspace({ send: async () => { sending = true; return pending; } }); + await act(() => view.container.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true }))); + for (let attempt = 0; !sending && attempt < 20; attempt++) await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + assert.equal(sending, true); + await act(() => clearSideConversationWorkspace(['B'])); + assert.equal(view.released.length, 0, 'unmount cannot revoke an in-flight attachment before admission returns'); + assert.ok(view.registry.ownerForPreview(view.owner.senderId, view.approvalId)); + await act(async () => { complete({ ok: false, reason: 'rejected' }); await pending; }); + assert.deepEqual(view.released, [view.approvalId]); + assert.equal(view.registry.ownerForPreview(view.owner.senderId, view.approvalId), undefined); +}); + +for (const action of ['remove', 'send'] as const) { + test(`side composer releases its recovered attachment after explicit ${action}`, async () => { + const view = await mountSideWorkspace({ send: async () => ({ ok: true, turnId: 'accepted-turn' }) }); + await act(async () => { + if (action === 'remove') { + const remove = view.container.querySelector('.maka-composer-attachment-token button'); + assert.ok(remove); + remove.dispatchEvent(new window.Event('click', { bubbles: true })); + } else { + view.container.querySelector('form')!.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })); + } + }); + for (let attempt = 0; !view.released.length && attempt < 20; attempt++) await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + assert.deepEqual(view.released, [view.approvalId]); + assert.equal(view.registry.ownerForPreview(view.owner.senderId, view.approvalId), undefined); + const captured = await view.capture(); + assert.equal(captured[0]!.draft.attachments.length, 0); + await act(() => clearSideConversationWorkspace(['B'])); + }); +} diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index c97624df3b6..8c1a3712339 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -216,9 +216,11 @@ 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 reloadEnd = mainWindowSource.indexOf(' isRenderer:', reloadStart); + assert.ok(reloadStart >= 0 && reloadEnd > reloadStart); const reloadHandler = mainWindowSource.slice( reloadStart, - mainWindowSource.indexOf(' send: safeSendToRenderer', reloadStart), + reloadEnd, ); assert.match( @@ -227,7 +229,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..ccc413240cc 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -997,6 +997,7 @@ test('editing a queued message retracts it and restores its content under the ow }, }), children: createElement(Probe) }) })}))); surface.composer.current = { + waitForDraftReady: async () => {}, captureDrafts: () => [], restoreDrafts() {}, setText() {}, appendText() {}, getText: () => '', clearDraft() {}, setDraft: (key, text) => { restoredDrafts.push([key, text]); }, getDraft: () => '', diff --git a/apps/desktop/src/main/__tests__/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__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index be14c0b6ef1..4c6371c48b5 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -49,6 +49,9 @@ import { type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; import { renderTranscriptMarkup } from './transcript-test-dom.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; +import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; +import { resolveAttachmentRefs, resolveIngestItems } from '../attachment-ingest.js'; const originalGlobals = { document: globalThis.document, @@ -145,6 +148,7 @@ function installDom() { async function renderProbe( sideChat: Partial, options: { + restoredWorkspace?: Parameters[0]['restoredWorkspace']; ownership?: boolean; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; @@ -183,6 +187,7 @@ async function renderProbe( ? createElement(QuoteCompanionOwnershipProbe, { onSend: options.onSend ?? (() => undefined), onProjection: options.onProjection, + restoredWorkspace: options.restoredWorkspace, onQueue: options.onQueue, onSteer: options.onSteer, onStop: options.onStop, @@ -3570,6 +3575,7 @@ function QuoteCompanionProbe(props: { } function QuoteCompanionOwnershipProbe(props: { + restoredWorkspace?: Parameters[0]['restoredWorkspace']; onSend: (send: (text: string) => Promise) => void; onProjection?: (companion: ReturnType) => void; onQueue?: (queue: (text: string) => Promise) => void; @@ -3587,6 +3593,8 @@ function QuoteCompanionOwnershipProbe(props: { const sourceSession = props.sourceSession ?? SOURCE_SESSION; const companion = useQuoteCompanion({ panelId: 'ownership-panel', + restoredWorkspace: props.restoredWorkspace, + restoredHasContent: Boolean(props.restoredWorkspace), active: props.active ?? true, pendingQuotes: props.pendingQuotes ?? [], sourceSession, @@ -3864,3 +3872,89 @@ test('an unknown steer outcome that later retracts keeps the staged attachments' }); assert.deepEqual(consumed, []); }); + + +test('a transferred side conversation observes the existing fork and detaches without deleting it', async () => { + const subscriptions: string[] = []; + const cleanups: string[] = []; + let projection!: ReturnType; + let forks = 0; + const restoredWorkspace = { sessionId: 'existing-fork', ownTurnIds: ['side-turn'], queueOwnedMessageIds: ['queued'] }; + const view = await renderProbe({ + listSessions: async () => [session('existing-fork')], + branchFromTurn: async () => { forks += 1; return { ok: true as const, session: session('unexpected') }; }, + subscribeEvents: (id, _handler, onSeeded) => { subscriptions.push(id); onSeeded?.(); return () => {}; }, + cleanupSessionCopy: async (id) => { cleanups.push(id); }, + }, { ownership: true, restoredWorkspace, onProjection: (value) => { projection = value; }, + ready: (container) => container.firstElementChild?.getAttribute('data-companion-id') === 'existing-fork' }); + assert.equal(forks, 0); + assert.deepEqual(subscriptions, ['existing-fork'], 'one observer is attached to the existing fork'); + assert.deepEqual(await projection.captureWorkspace(), restoredWorkspace); + projection.detachWorkspace(); + await act(async () => { view.root.unmount(); await Promise.resolve(); }); + mountedRoot = undefined; + assert.deepEqual(cleanups, [], 'closing the source window cannot delete the transferred fork'); +}); + +test('a cancelled window transfer restores normal side-panel dismissal', async () => { + const cleanups: string[] = []; + let projection!: ReturnType; + const view = await renderProbe({ + listSessions: async () => [session('existing-fork')], + subscribeEvents: (_id, _handler, onSeeded) => { onSeeded?.(); return () => {}; }, + cleanupSessionCopy: async (id) => { cleanups.push(id); }, + }, { ownership: true, restoredWorkspace: { sessionId: 'existing-fork', ownTurnIds: [], queueOwnedMessageIds: [] }, + onProjection: (value) => { projection = value; }, + ready: (container) => container.firstElementChild?.getAttribute('data-companion-id') === 'existing-fork' }); + projection.detachWorkspace(); + projection.resumeWorkspace(); + await act(async () => { view.root.unmount(); await Promise.resolve(); }); + mountedRoot = undefined; + assert.deepEqual(cleanups, ['existing-fork']); +}); + +for (const existing of [false, true]) { + test(`a recovered side attachment sends through ${existing ? 'its existing' : 'a newly created'} fork with exact Session authority`, async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const owner = { senderId: 11, partition: 'authority', scope: { hostId: 'host', targetEpoch: 'epoch' }, + sessionId: existing ? 'side-conversation' : SOURCE_SESSION.id }; + const [approval] = recovery.issue(owner, [{ name: 'question.txt', mimeType: 'text/plain', content: Buffer.from('saved side bytes') }]); + const recovered = approvalAttachment(approval.approvalId, approval.name); + let send!: SendFn; + let branches = 0; + const ingested: string[] = []; + await renderProbe({ + listSessions: async () => [session('side-conversation')], + branchFromTurn: async (sourceId, input) => { + branches++; + assert.equal(sourceId, SOURCE_SESSION.id); + assert.deepEqual(input.recoveryAttachmentIds, [approval.approvalId]); + recovery.transfer(owner, 'side-conversation', input.recoveryAttachmentIds); + return { ok: true, session: session('side-conversation') }; + }, + subscribeEvents: (_id, _handler, seeded) => { seeded?.(); return () => {}; }, + send: async (sessionId, command) => { + assert.equal(sessionId, 'side-conversation'); + assert.throws(() => recovery.prepare({ ...owner, sessionId: 'another-session' }, command.attachmentItems)); + const prepared = recovery.prepare({ ...owner, sessionId }, command.attachmentItems); + try { + prepared.validate(); + const files = await resolveIngestItems({ senderId: owner.senderId, items: prepared.items, + approvals: createAttachmentApprovalRegistry(), stat: async () => { throw new Error('Recovery cannot open a client path'); } }); + await resolveAttachmentRefs({ files, snapshot: async ({ name, mimeType, content }) => { + ingested.push(Buffer.from(content).toString()); + return { name, mimeType, content }; + } }); + prepared.validate(); + return { ok: true, turnId: 'side-turn' }; + } finally { prepared.dispose(); } + }, + }, { ownership: true, onSend: (next) => { send = next; }, + ...(existing ? { restoredWorkspace: { sessionId: 'side-conversation', ownTurnIds: [], queueOwnedMessageIds: [] }, + ready: (container: Element) => container.firstElementChild?.getAttribute('data-companion-id') === 'side-conversation' } : {}), + }); + await act(async () => { assert.equal(await send('follow up', [recovered]), true); }); + assert.equal(branches, existing ? 0 : 1); + assert.deepEqual(ingested, ['saved side bytes']); + }); +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 65cfa27f732..8f92bbbf958 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -306,6 +306,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn completeDesktopInteractionTurn() {}, notifyRun: async () => {}, createSessionCopyCleanup: () => ({ + adopt: async () => {}, ownCreation: (_creation, operation) => operation(), rejectCreation: async () => undefined, cleanup: async () => undefined, @@ -634,6 +635,7 @@ function ipcHarness() { function unusedSessionCopyCleanup() { return { + adopt: async () => {}, ownCreation: async (_creation: unknown, operation: () => Promise) => operation(), async rejectCreation() {}, async cleanup() {}, 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 bd420f862fa..1af8bec731e 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 @@ -1265,6 +1265,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, @@ -1644,6 +1645,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-catalog-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts index 154e170d7a5..d03e6d8e4ed 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts @@ -30,6 +30,7 @@ import { toDesktopHostSessionSummary, type RuntimeHostSessionCatalogIpcDeps, } from '../runtime-host-session-catalog-ipc-main.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; import { DesktopRuntimeHostClientError } from '../runtime-host-client.js'; import { createManagedTaskDirectoryAuthority } from '../managed-task-directory.js'; import { resolveDesktopSessionWorkspace } from '../new-session-project.js'; @@ -311,7 +312,7 @@ function ipcHarness() { 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: { id: 9 } } as never, ...args); }, }; } @@ -363,3 +364,29 @@ function projection(overrides: Partial = {}): SessionC ...overrides, }; } + + +test('replacing an empty side conversation returns recovery handles to its source before cleanup', async () => { + let now = 0; + const recovery = new SessionLocalAttachmentRecovery(() => now); + const owner = { senderId: 9, partition: 'p', scope: { hostId: 'host', targetEpoch: 'epoch' }, sessionId: 'old-fork' }; + const [approval] = recovery.issue(owner, [{ name: 'draft.txt', mimeType: 'text/plain', content: Buffer.from('draft') }]); + const deps = createDeps([]); + const removed: string[] = []; + deps.client.getSession = async () => projection({ id: 'old-fork', parentSessionId: 'source', labels: ['mode:side_conversation'] }); + deps.sessionCopyCleanup.cleanup = async (id) => { removed.push(id); }; + deps.transferRecoveryAttachments = (senderId, source, target, ids) => recovery.transfer({ ...owner, senderId, sessionId: source }, target, ids); + const ipc = ipcHarness(); + registerRuntimeHostSessionCatalogIpc(deps, ipc); + await assert.rejects(ipc.invoke('sessions:cleanupSessionCopy', 'old-fork', { sourceSessionId: 'unrelated', attachmentIds: [approval!.approvalId] }), /another session/); + assert.deepEqual(removed, []); + now = 25 * 60 * 1000; + await ipc.invoke('sessions:cleanupSessionCopy', 'old-fork', { sourceSessionId: 'source', attachmentIds: [approval!.approvalId] }); + assert.deepEqual(removed, ['old-fork']); + recovery.transfer({ ...owner, sessionId: 'source' }, 'new-fork', [approval!.approvalId]); + assert.equal(Buffer.from(recovery.snapshot({ ...owner, sessionId: 'new-fork' }, approval!.approvalId).content).toString(), 'draft'); + assert.throws(() => recovery.snapshot(owner, approval!.approvalId), { code: 'source_expired' }); + assert.throws(() => recovery.snapshot({ ...owner, senderId: 10, sessionId: 'new-fork' }, approval!.approvalId), { code: 'source_expired' }); + now = 31 * 60 * 1000; + assert.throws(() => recovery.snapshot({ ...owner, sessionId: 'new-fork' }, approval!.approvalId), { code: 'source_expired' }, 'replacement must not extend the original expiry'); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index 79de0255b90..44c7041f5e5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -1220,16 +1220,18 @@ function shellRunUpdate(overrides: Partial = {}): ShellRunUpdate type IpcHandler = Parameters['handle']>[1]; function ipcHarness() { + let sender = { id: 1 }; const handlers = new Map(); return { handle(channel: string, handler: IpcHandler) { assert.equal(handlers.has(channel), false, `duplicate handler: ${channel}`); handlers.set(channel, handler); }, + setSender(id: number) { sender = { id }; }, async invoke(channel: string, ...args: unknown[]): Promise { const handler = handlers.get(channel); assert.ok(handler, `missing handler: ${channel}`); - return handler({} as never, ...args); + return handler({ sender } as never, ...args); }, }; } @@ -1406,3 +1408,43 @@ test('plan control channels return the Host error code across the IPC boundary', assert.deepEqual(changed, [], `${scenario.channel} must not report a mode change`); } }); + + +test('moving a terminal transfers its observer and rejects late source input and detach without stopping the PTY', async () => { + const ipc = ipcHarness(); + const events: string[] = []; + let owner = 1; + const pty = { sessionId: 'session-1', ref: 'pty-1', sequence: 5, buffer: 'still running', size: { cols: 80, rows: 24 } }; + const handle = registerDomainsIpc({ + client: domainClient({ + acquireRuntimeResourceController: async (input) => { events.push('acquire:' + input.controllerId); return { controllerId: input.controllerId, nextSequence: 8, pty }; }, + controlRuntimeResource: async (input) => { events.push('input:' + input.sequence); return { controllerId: input.controllerId, sequence: input.sequence }; }, + releaseRuntimeResourceController: async (input) => { events.push('release'); return { controllerId: input.controllerId, released: true }; }, + stopRuntimeResource: async () => { events.push('stop'); return {}; }, + }), + canUseSession: (target) => target.id === owner, + sessionObserver: { + observe: async (_id, _observer, target) => { events.push('observe:' + target.id); }, + unobserve: async () => { events.push('unobserve'); }, + }, + emitModeChanged() {}, newId: () => 'transfer', + }, ipc); + const identity = { sessionId: 'session-1', ref: pty.ref }; + await ipc.invoke('shell-runs:attach', identity); + owner = 2; + ipc.setSender(2); + assert.deepEqual(await ipc.invoke('shell-runs:attach', identity), pty); + ipc.setSender(1); + await ipc.invoke('shell-runs:detach', identity); + await assert.rejects(ipc.invoke('shell-runs:write', { ...identity, input: 'late' }), /another window/); + await assert.rejects(ipc.invoke('shell-runs:attach', identity), /another window/); + ipc.setSender(2); + // A real renderer retains its WebContents identity across invokes. + await ipc.invoke('shell-runs:attach', identity); + await ipc.invoke('shell-runs:write', { ...identity, size: { cols: 100, rows: 30 } }); + assert.equal(events.includes('release'), false); + assert.equal(events.includes('stop'), false); + assert.equal(events.at(-1), 'input:8'); + assert.ok(events.indexOf('unobserve') < events.indexOf('observe:2')); + await handle.close(); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index c4115a07c32..bd11bd28dac 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -37,6 +37,7 @@ import { RuntimeHostOperationError, RuntimeHostRequestInterruptedError, } from '@maka/runtime-host/client'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; import { createAttachmentApprovalRegistry } from "../attachment-approval.js"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; import { @@ -646,6 +647,7 @@ test("sends Side Conversation intent and metadata atomically to Runtime Host", a const backgroundErrors: unknown[] = []; const ipc = ipcHarness(); const sessionCopyCleanup = { + adopt: async () => {}, ownCreation: (_creation: unknown, operation: () => Promise) => operation(), async rejectCreation() {}, async cleanup() {}, @@ -2444,6 +2446,7 @@ function registerExecutionIpc( function unusedSessionCopyCleanup(): RuntimeHostSessionExecutionIpcDeps['sessionCopyCleanup'] { return { + adopt: async () => {}, ownCreation: async (_creation, operation) => operation(), async rejectCreation() {}, async cleanup() {}, @@ -2567,3 +2570,39 @@ test('prompt suggestion IPC forwards only a validated Session and does not recon assert.deepEqual(calls, ['session-1']); assert.equal(ipc.reconnectableChannels.has('sessions:generatePromptSuggestion'), false); }); + + +for (const channel of ['sessions:send', 'sessions:submitMessage'] as const) { + test(`${channel} admits recovered side-chat attachments only for their assigned session`, async () => { + const recovery = new SessionLocalAttachmentRecovery(); + const owner = { senderId: 9, partition: 'partition', scope: { hostId: 'host', targetEpoch: 'epoch' }, sessionId: 'source' }; + const [approval] = recovery.issue(owner, [{ name: 'recovered.txt', mimeType: 'text/plain', content: Buffer.from('hello') }]); + const uploads: string[] = []; + const ipc = ipcHarness(); + registerExecutionIpc({ + recoverAttachmentItems: (senderId, sessionId, items) => recovery.prepare({ ...owner, senderId, sessionId }, items), + transferRecoveryAttachments: (senderId, sourceSessionId, targetSessionId, ids) => recovery.transfer({ ...owner, senderId, sessionId: sourceSessionId }, targetSessionId, ids), + client: executionClient({ + getSession: async () => ({ ...session('/workspace', 'fork'), labels: [SIDE_CONVERSATION_SESSION_LABEL] }), + copySession: async () => session('/workspace', 'fork'), + ingestAttachment: async ({ sessionId, name, mimeType, content }) => { + uploads.push(Buffer.from(content).toString()); + return { kind: 'other', name, mimeType, bytes: content.byteLength, ref: { kind: 'session_file', sessionId, relativePath: 'attachment-1' } }; + }, + submitMessage: async () => ({ disposition: 'turn_started', turnId: 'turn-1', skillInvocation: { loaded: [], failed: [], receipts: [] } }), + }), + }, ipc); + const input = { type: 'send', text: 'read recovered attachment', messageId: 'message-1', attachmentItems: [{ type: 'approval', approvalId: approval!.approvalId, name: approval!.name }] }; + const send = () => channel === 'sessions:send' + ? ipc.invoke(channel, 'fork', input) + : ipc.invoke(channel, 'fork', 'next_turn', input); + assert.deepEqual(await send(), { ok: false, reason: 'attachment_blocked', code: 'source_expired' }); + await ipc.invoke('sessions:branchFromTurn', 'source', { copyId: 'fork', sideConversation: true, recoveryAttachmentIds: [approval!.approvalId] }); + assert.equal((await send() as { ok: boolean }).ok, true); + assert.deepEqual(uploads, ['hello']); + // A failed remote admission can retain the original draft; renderer cleanup + // owns successful-send disposal instead of prematurely consuming its token. + assert.equal(recovery.snapshot({ ...owner, sessionId: 'fork' }, approval!.approvalId).content.byteLength, 5); + await assert.rejects(ipc.invoke('sessions:branchFromTurn', 'source', { copyId: 'ordinary', recoveryAttachmentIds: [approval!.approvalId] }), /Invalid side conversation recovery attachments/); + }); +} diff --git a/apps/desktop/src/main/__tests__/session-history-navigation.test.ts b/apps/desktop/src/main/__tests__/session-history-navigation.test.ts index ee059ac51ef..42a9fe948d5 100644 --- a/apps/desktop/src/main/__tests__/session-history-navigation.test.ts +++ b/apps/desktop/src/main/__tests__/session-history-navigation.test.ts @@ -24,6 +24,7 @@ import { parseHTML } from 'linkedom'; import { installReactRenderer, cleanupFakeDom, FakeElement } from './fake-dom.js'; import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; import { SessionHistoryNavigation, createSessionOpenCommand } from '../../renderer/features/session-navigation/testing.js'; +import { createSessionWindowNavigator } from '../../renderer/features/session-windows/testing.js'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; const cleanups: Array<() => void> = []; @@ -65,16 +66,21 @@ function setup() { profileKind: 'local', runtimeHostId: 'local-host', revision: 0, activityAt: 0, }))); const targets: unknown[] = []; - const openSession = createSessionOpenCommand({ + const openLocal = createSessionOpenCommand({ activateSession: catalog.setActiveSessionId, exitWorkHub() {}, selectSessionSurface() {}, setSearchTarget: (target) => targets.push(target), }); + const navigator = createSessionWindowNavigator({ + route: async () => 'local', blocked: () => false, + openWorkspace: async (_id, isCurrent, activate) => { await Promise.resolve(); if (isCurrent()) activate(); }, + }); + const openSession = (id: string) => navigator.open({ sessionId: id }, () => openLocal(id)); const render = (visible = true, blocked = false) => act(() => root.render(createElement( SessionHistoryNavigation, { catalog, visible, blocked, openSession }, ))); render(); - const wheel = (timeStamp: number, overrides: Partial = {}, selector = 'p') => { + const wheel = async (timeStamp: number, overrides: Partial = {}, selector = 'p') => { const target = dom.document.querySelector(selector)!; const path: EventTarget[] = []; for (let node: Element | null = target; node; node = node.parentElement) path.push(node); @@ -84,7 +90,7 @@ function setup() { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, composedPath: () => path, preventDefault: () => { prevented = true; }, ...overrides, } as WheelEvent; - act(() => { for (const listener of listeners) listener(event); }); + await act(async () => { for (const listener of listeners) listener(event); }); return prevented; }; const feedback = () => body.childNodes[0] as FakeElement | undefined; @@ -92,54 +98,54 @@ function setup() { return { catalog, root, render, wheel, targets, dom, listeners, feedback, blur, blurListeners }; } -it('shows pull progress before switching and acknowledges the threshold only once', () => { +it('shows pull progress before switching and acknowledges the threshold only once', async () => { const { catalog, wheel, feedback, targets } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); - wheel(0, { deltaX: -24 }); + await wheel(0, { deltaX: -24 }); assert.equal(catalog.getState().activeSessionId, 'C'); assert.equal(feedback()?.getAttribute('data-phase'), 'pulling'); assert.equal(String(feedback()?.getAttribute('data-progress')), '0.3'); - wheel(16, { deltaX: -56 }); + await wheel(16, { deltaX: -56 }); assert.equal(catalog.getState().activeSessionId, 'B'); assert.equal(feedback()?.getAttribute('data-phase'), 'committed'); - wheel(32); + await wheel(32); assert.deepEqual(targets, [null]); }); -it('leaves continuous vertical scrolling uncancelled immediately after a history swipe', () => { +it('leaves continuous vertical scrolling uncancelled immediately after a history swipe', async () => { const { catalog, wheel, targets } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); - assert.equal(wheel(0), true); + assert.equal(await wheel(0), true); for (let time = 100; time <= 1000; time += 100) { - assert.equal(wheel(time, { deltaX: 0, deltaY: 50 }), false); + assert.equal(await wheel(time, { deltaX: 0, deltaY: 50 }), false); } - assert.equal(wheel(1100, { deltaX: 3, deltaY: 50 }), false); + assert.equal(await wheel(1100, { deltaX: 3, deltaY: 50 }), false); assert.equal(catalog.getState().activeSessionId, 'B'); assert.deepEqual(targets, [null]); }); -it('recognizes the captured Windows sequence when only its first wheel frame is cancelable', () => { +it('recognizes the captured Windows sequence when only its first wheel frame is cancelable', async () => { const { catalog, wheel, feedback, targets } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); // Physical touchpad capture: the tiny opening event is cancelable; Chromium // delivers the remaining horizontal displacement as non-cancelable events. - wheel(0, { deltaX: -1.6667, cancelable: true }); - assert.equal(wheel(3.2, { deltaX: -6.6667, cancelable: false }), false); - assert.equal(wheel(25.5, { deltaX: -21.6667, cancelable: false }), false); + await wheel(0, { deltaX: -1.6667, cancelable: true }); + assert.equal(await wheel(3.2, { deltaX: -6.6667, cancelable: false }), false); + assert.equal(await wheel(25.5, { deltaX: -21.6667, cancelable: false }), false); assert.equal(feedback()?.getAttribute('data-phase'), 'pulling'); - assert.equal(wheel(146.6, { deltaX: -201.6667, cancelable: false }), false); + assert.equal(await wheel(146.6, { deltaX: -201.6667, cancelable: false }), false); assert.equal(catalog.getState().activeSessionId, 'B'); assert.equal(feedback()?.getAttribute('data-phase'), 'committed'); - wheel(151.5, { deltaX: -31.6667, cancelable: false }); + await wheel(151.5, { deltaX: -31.6667, cancelable: false }); assert.deepEqual(targets, [null]); }); -it('retracts an unfinished pull after idle and removes the feedback without navigating', (t) => { +it('retracts an unfinished pull after idle and removes the feedback without navigating', async (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); t.mock.method(performance, 'now', () => Date.now()); const { catalog, wheel, feedback } = setup(); for (const id of ['A', 'B']) catalog.setActiveSessionId(id); - wheel(0, { deltaX: -32 }); + await wheel(0, { deltaX: -32 }); act(() => t.mock.timers.tick(300)); assert.equal(feedback()?.getAttribute('data-phase'), 'pulling'); act(() => t.mock.timers.tick(200)); @@ -149,40 +155,40 @@ it('retracts an unfinished pull after idle and removes the feedback without navi assert.equal(catalog.getState().activeSessionId, 'B'); }); -it('bounds completion feedback even when a continuous wheel tail never stops', (t) => { +it('bounds completion feedback even when a continuous wheel tail never stops', async (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); t.mock.method(performance, 'now', () => Date.now()); const { catalog, wheel, feedback, targets } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); - wheel(0, { deltaX: -20 }); - wheel(180, { deltaX: -60 }); + await wheel(0, { deltaX: -20 }); + await wheel(180, { deltaX: -60 }); for (let time = 50; time <= 150; time += 50) { act(() => t.mock.timers.tick(50)); - wheel(time + 180, { deltaX: -2 }); + await wheel(time + 180, { deltaX: -2 }); } assert.equal(feedback()?.getAttribute('data-phase'), 'committed'); act(() => t.mock.timers.tick(50)); assert.equal(feedback()?.getAttribute('data-phase'), 'returning'); for (let time = 250; time <= 650; time += 100) { act(() => t.mock.timers.tick(100)); - wheel(time + 180, { deltaX: -2 }); + await wheel(time + 180, { deltaX: -2 }); } assert.equal(feedback() === undefined, true); assert.equal(catalog.getState().activeSessionId, 'B'); assert.deepEqual(targets, [null]); }); -it('uses elapsed time for animation even when the sample preview freezes the wall clock', (t) => { +it('uses elapsed time for animation even when the sample preview freezes the wall clock', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.method(Date, 'now', () => 0); let elapsed = 0; t.mock.method(performance, 'now', () => elapsed); const { catalog, wheel, feedback } = setup(); for (const id of ['A', 'B']) catalog.setActiveSessionId(id); - wheel(0); + await wheel(0); elapsed = 100; act(() => t.mock.timers.tick(100)); - wheel(100, { deltaX: -2 }); + await wheel(100, { deltaX: -2 }); elapsed = 200; act(() => t.mock.timers.tick(100)); assert.equal(feedback()?.getAttribute('data-phase'), 'committed'); @@ -194,15 +200,15 @@ it('uses elapsed time for animation even when the sample preview freezes the wal assert.equal(feedback() === undefined, true); }); -it('retains the completion when a Session swap detaches the original wheel target', (t) => { +it('retains the completion when a Session swap detaches the original wheel target', async (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); t.mock.method(performance, 'now', () => Date.now()); const { catalog, wheel, feedback, dom, targets } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); - wheel(0); + await wheel(0); const oldTarget = dom.document.querySelector('p')!; oldTarget.remove(); - wheel(48, { composedPath: () => [oldTarget, document] }); + await wheel(48, { composedPath: () => [oldTarget, document] }); assert.equal(feedback()?.getAttribute('data-phase'), 'committed'); act(() => t.mock.timers.tick(150)); assert.equal(feedback()?.getAttribute('data-phase'), 'committed'); @@ -210,175 +216,175 @@ it('retains the completion when a Session swap detaches the original wheel targe assert.deepEqual(targets, [null]); }); -it('accepts a reverse on the new conversation after a completed gesture loses its old target', () => { +it('accepts a reverse on the new conversation after a completed gesture loses its old target', async () => { const { catalog, wheel, feedback, targets } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); - wheel(0); + await wheel(0); // Session replacement sends the remaining wheel tail outside the marked // surface. That completed tail must not lock the next deliberate return. - wheel(100, { deltaX: -30 }, 'aside'); - wheel(200, { deltaX: 30, cancelable: false }); + await wheel(100, { deltaX: -30 }, 'aside'); + await wheel(200, { deltaX: 30, cancelable: false }); assert.equal(feedback()?.getAttribute('data-phase'), 'pulling'); - wheel(220, { deltaX: 60, cancelable: false }); + await wheel(220, { deltaX: 60, cancelable: false }); assert.equal(catalog.getState().activeSessionId, 'C'); assert.deepEqual(targets, [null, null]); }); -it('recovers an ancestor-latched wheel target after the conversation leaves inert loading', () => { +it('recovers an ancestor-latched wheel target after the conversation leaves inert loading', async () => { const { catalog, wheel, dom } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); const surface = dom.document.querySelector('main')!; Object.defineProperty(document, 'querySelector', { configurable: true, value: dom.document.querySelector.bind(dom.document) }); Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => surface.hasAttribute('inert') ? dom.document.body : dom.document.querySelector('p') }); surface.setAttribute('inert', ''); - wheel(0, { deltaX: -30, clientX: 200, clientY: 100 }, 'body'); + await wheel(0, { deltaX: -30, clientX: 200, clientY: 100 }, 'body'); assert.equal(catalog.getState().activeSessionId, 'C'); surface.removeAttribute('inert'); // Chromium keeps the non-inert ancestor as the target of the wheel stream. - wheel(30, { deltaX: -90, clientX: 200, clientY: 100 }, 'body'); + await wheel(30, { deltaX: -90, clientX: 200, clientY: 100 }, 'body'); assert.equal(catalog.getState().activeSessionId, 'B'); }); -it('shows unavailable feedback at the history boundary instead of acknowledging a move', () => { +it('shows unavailable feedback at the history boundary instead of acknowledging a move', async () => { const { catalog, wheel, feedback, targets } = setup(); catalog.setActiveSessionId('A'); - wheel(0, { deltaX: -32 }); + await wheel(0, { deltaX: -32 }); assert.equal(feedback()?.getAttribute('data-phase'), 'unavailable'); - wheel(16); + await wheel(16); assert.equal(feedback()?.getAttribute('data-phase'), 'unavailable'); assert.deepEqual(targets, []); }); -it('cancels feedback and the gesture when a modal interrupts a pull', () => { +it('cancels feedback and the gesture when a modal interrupts a pull', async () => { const { catalog, wheel, feedback, render } = setup(); for (const id of ['A', 'B']) catalog.setActiveSessionId(id); - wheel(0, { deltaX: -32 }); + await wheel(0, { deltaX: -32 }); render(true, true); assert.equal(feedback(), undefined); render(); assert.equal(feedback() === undefined, true); - wheel(16, { deltaX: -60 }); + await wheel(16, { deltaX: -60 }); assert.equal(catalog.getState().activeSessionId, 'B'); - wheel(400); + await wheel(400); assert.equal(catalog.getState().activeSessionId, 'A'); }); -it('removes an in-progress arrow when the pointer enters an excluded control', () => { +it('removes an in-progress arrow when the pointer enters an excluded control', async () => { const { catalog, wheel, feedback } = setup(); for (const id of ['A', 'B']) catalog.setActiveSessionId(id); - wheel(0, { deltaX: -32 }); - wheel(16, { deltaX: -20 }, 'textarea'); + await wheel(0, { deltaX: -32 }); + await wheel(16, { deltaX: -20 }, 'textarea'); assert.equal(feedback() === undefined, true); - wheel(32); + await wheel(32); assert.equal(catalog.getState().activeSessionId, 'B'); }); -it('cancels a pull on window blur and releases listeners and timers on unmount', (t) => { +it('cancels a pull on window blur and releases listeners and timers on unmount', async (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); t.mock.method(performance, 'now', () => Date.now()); const { catalog, wheel, feedback, blur, root, blurListeners } = setup(); for (const id of ['A', 'B']) catalog.setActiveSessionId(id); - wheel(0, { deltaX: -32 }); + await wheel(0, { deltaX: -32 }); blur(); assert.equal(feedback() === undefined, true); - wheel(16); + await wheel(16); assert.equal(catalog.getState().activeSessionId, 'B'); - wheel(400, { deltaX: -32 }); + await wheel(400, { deltaX: -32 }); act(() => root.render(null)); act(() => t.mock.timers.tick(1000)); assert.equal(feedback() === undefined, true); assert.equal(blurListeners.size, 0); }); -it('records rapid requested selections and traverses them through the existing open command', () => { +it('records rapid requested selections and traverses them through the existing open command', async () => { const { catalog, render, wheel, targets } = setup(); act(() => { for (const id of ['A', 'C', 'B']) catalog.setActiveSessionId(id); }); - assert.equal(wheel(0), true); + assert.equal(await wheel(0), true); assert.equal(catalog.getState().activeSessionId, 'C'); render(); // A React commit acknowledging the traversal must not branch history. - wheel(16); + await wheel(16); assert.equal(catalog.getState().activeSessionId, 'C'); - wheel(400); + await wheel(400); assert.equal(catalog.getState().activeSessionId, 'A'); - wheel(800, { deltaX: 100 }); + await wheel(800, { deltaX: 100 }); assert.equal(catalog.getState().activeSessionId, 'C'); assert.deepEqual(targets, [null, null, null]); }); -it('ignores settings, modal, non-conversation, edited-text, zoom and non-pixel input', () => { +it('ignores settings, modal, non-conversation, edited-text, zoom and non-pixel input', async () => { const { catalog, render, wheel } = setup(); catalog.setActiveSessionId('A'); catalog.setActiveSessionId('B'); render(false); - assert.equal(wheel(0), false); + assert.equal(await wheel(0), false); render(true, true); - assert.equal(wheel(400), false); + assert.equal(await wheel(400), false); render(); - assert.equal(wheel(800, {}, 'aside'), false); - assert.equal(wheel(1200, {}, 'textarea'), false); - assert.equal(wheel(1600, { ctrlKey: true }), false); - assert.equal(wheel(2000, { deltaMode: 1 }), false); - assert.equal(wheel(2400, { shiftKey: true }), false); - assert.equal(wheel(2800, { defaultPrevented: true }), false); + assert.equal(await wheel(800, {}, 'aside'), false); + assert.equal(await wheel(1200, {}, 'textarea'), false); + assert.equal(await wheel(1600, { ctrlKey: true }), false); + assert.equal(await wheel(2000, { deltaMode: 1 }), false); + assert.equal(await wheel(2400, { shiftKey: true }), false); + assert.equal(await wheel(2800, { defaultPrevented: true }), false); assert.equal(catalog.getState().activeSessionId, 'B'); - wheel(3200); + await wheel(3200); assert.equal(catalog.getState().activeSessionId, 'A'); }); -it('keeps horizontal overflow gestures with their content even at the scroll edge', () => { +it('keeps horizontal overflow gestures with their content even at the scroll edge', async () => { const { catalog, dom, wheel } = setup(); catalog.setActiveSessionId('A'); catalog.setActiveSessionId('B'); const scroller = dom.document.querySelector('.scroller')!; Object.defineProperties(scroller, { clientWidth: { value: 200 }, scrollWidth: { value: 600 }, scrollLeft: { value: 0 } }); globalThis.getComputedStyle = ((element: Element) => ({ overflowX: element === scroller ? 'auto' : 'visible' })) as typeof getComputedStyle; - assert.equal(wheel(0, {}, 'pre'), false); - assert.equal(wheel(16), false); // Moving the pointer outside does not steal the tail. + assert.equal(await wheel(0, {}, 'pre'), false); + assert.equal(await wheel(16), false); // Moving the pointer outside does not steal the tail. assert.equal(catalog.getState().activeSessionId, 'B'); - wheel(400); + await wheel(400); assert.equal(catalog.getState().activeSessionId, 'A'); }); -it('skips a missing catalog row but keeps its visit when the row returns', () => { +it('skips a missing catalog row but keeps its visit when the row returns', async () => { const { catalog, wheel } = setup(); const sessions = catalog.getState().sessions; for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); catalog.commitSessions(sessions.filter((session) => session.id !== 'B')); - wheel(0); + await wheel(0); assert.equal(catalog.getState().activeSessionId, 'A'); catalog.commitSessions(sessions); - wheel(400, { deltaX: 100 }); + await wheel(400, { deltaX: 100 }); assert.equal(catalog.getState().activeSessionId, 'B'); }); -it('does not retain event listeners after unmount', () => { +it('does not retain event listeners after unmount', async () => { const { root, listeners } = setup(); assert.equal(listeners.size, 1); act(() => root.render(null)); assert.equal(listeners.size, 0); }); -it('does not resurrect a confirmed removed visit during an unrelated React commit', () => { +it('does not resurrect a confirmed removed visit during an unrelated React commit', async () => { const { catalog, render, wheel } = setup(); const removed = catalog.getState().sessions.find((session) => session.id === 'C')!; for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); catalog.commitPatch('C', null); render(); - wheel(0); + await wheel(0); assert.equal(catalog.getState().activeSessionId, 'B'); catalog.commitPatch('C', removed); - wheel(400, { deltaX: 100 }); + await wheel(400, { deltaX: 100 }); assert.equal(catalog.getState().activeSessionId, 'B'); }); -it('preserves forward history when the removed current selection survives another render', () => { +it('preserves forward history when the removed current selection survives another render', async () => { const { catalog, render, wheel } = setup(); for (const id of ['A', 'B', 'C']) catalog.setActiveSessionId(id); - wheel(0); + await wheel(0); assert.equal(catalog.getState().activeSessionId, 'B'); catalog.commitPatch('B', null); assert.equal(catalog.getState().activeSessionId, 'B'); render(); - wheel(400, { deltaX: 100 }); + await wheel(400, { deltaX: 100 }); assert.equal(catalog.getState().activeSessionId, 'C'); - wheel(800); + await wheel(800); assert.equal(catalog.getState().activeSessionId, 'A'); }); diff --git a/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts b/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts index e83c7b5ee38..b638a7b5e6b 100644 --- a/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts +++ b/apps/desktop/src/main/__tests__/session-local-attachment-recovery.test.ts @@ -130,6 +130,49 @@ test('two prepared submissions cannot consume the same recovery approval twice', concurrent.dispose(); }); +test('closing a sender releases its unleased drafts but preserves another window and acquired send leases', () => { + const recovery = new SessionLocalAttachmentRecovery(); + const [draft, sending, cancelled] = recovery.issue(owner, [attachment(), attachment(), attachment()]); + const other = { ...owner, senderId: owner.senderId + 1 }; + const [otherDraft] = recovery.issue(other, [attachment()]); + const send = recovery.prepare(owner, [sending]); + const cancel = recovery.prepare(owner, [cancelled]); + recovery.releaseSender(owner.senderId); + assert.throws(() => recovery.prepare(owner, [draft]), blocked('source_expired')); + assert.throws(() => recovery.prepare(owner, [sending]), blocked('source_expired')); + assert.equal(send.commit(() => 'admitted'), 'admitted'); + send.dispose(); + cancel.dispose(); + assert.throws(() => recovery.prepare(owner, [cancelled]), blocked('source_expired')); + assert.equal(recovery.prepare(other, [otherDraft]).commit(() => 'other window'), 'other window'); +}); + +test('a side draft transfers only its exact source authority to the newly created fork, without duplicating bytes or extending expiry', () => { + let now = 0; + const recovery = new SessionLocalAttachmentRecovery(() => now); + const [approval] = recovery.issue(owner, [attachment()]); + const fork = { ...owner, sessionId: 'side-fork' }; + for (const foreign of [ + { ...owner, senderId: 999 }, { ...owner, sessionId: 'unrelated' }, + { ...owner, partition: 'other' }, { ...owner, scope: { ...owner.scope, targetEpoch: 'other' } }, + ]) assert.throws(() => recovery.transfer(foreign, fork.sessionId, [approval.approvalId]), blocked('source_expired')); + const pendingSend = recovery.prepare(owner, [approval]); + assert.throws(() => recovery.transfer(owner, fork.sessionId, [approval.approvalId]), blocked('source_expired')); + pendingSend.dispose(); + assert.throws(() => recovery.transfer(owner, fork.sessionId, [approval.approvalId, 'local-recovery:forged']), blocked('source_expired')); + assert.equal(recovery.snapshot(owner, approval.approvalId).content.byteLength, 4, 'a partially invalid batch never moves the first attachment'); + now = ATTACHMENT_APPROVAL_TTL_MS - 1; + recovery.transfer(owner, fork.sessionId, [approval.approvalId]); + recovery.transfer(owner, fork.sessionId, [approval.approvalId]); + assert.throws(() => recovery.prepare(owner, [approval]), blocked('source_expired')); + assert.deepEqual(recovery.snapshot(fork, approval.approvalId).content, attachment().content); + const send = recovery.prepare(fork, [approval]); + send.validate(); + now += 2; + assert.throws(() => send.validate(), blocked('source_expired')); + send.dispose(); +}); + test('a synchronous admission failure preserves every recovery approval for a retry', () => { const recovery = new SessionLocalAttachmentRecovery(); const approvals = recovery.issue(owner, [attachment(), attachment()]); diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index ba3b36f3746..9935f1decaf 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -537,12 +537,14 @@ describe('useSessionNavigationReads', () => { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); catalog.commitSessions(linkedCatalog); + const opened: string[] = []; + const openSession = (id: string) => { opened.push(id); }; await act(async () => root.render( navigationTree( catalog, { activeSessionId: 'child', workHubActive: false }, - createElement(ReadsProbe, { catalog, activeSessionId: 'child' }), + createElement(ReadsProbe, { catalog, activeSessionId: 'child', openSession }), createElement(RailProbe), ), ), @@ -555,19 +557,21 @@ describe('useSessionNavigationReads', () => { ['root', 'remote', 'environment'], ); assert.equal(latestRail.activeId, 'root'); - assert.equal(latestReads.activeParentSession?.id, 'root'); + latestReads.titlebarParentSession?.onOpen(); + assert.deepEqual(opened, ['root']); await act(async () => root.render( navigationTree( catalog, { activeSessionId: 'side-conversation', workHubActive: false }, - createElement(ReadsProbe, { catalog, activeSessionId: 'side-conversation' }), + createElement(ReadsProbe, { catalog, activeSessionId: 'side-conversation', openSession }), null, ), ), ); - assert.equal(latestReads.activeParentSession?.id, 'root'); + latestReads.titlebarParentSession?.onOpen(); + assert.deepEqual(opened, ['root', 'root']); }); }); diff --git a/apps/desktop/src/main/__tests__/session-visit-history.test.ts b/apps/desktop/src/main/__tests__/session-visit-history.test.ts index fde88e1bc1f..a4c57370ddd 100644 --- a/apps/desktop/src/main/__tests__/session-visit-history.test.ts +++ b/apps/desktop/src/main/__tests__/session-visit-history.test.ts @@ -21,82 +21,82 @@ import { strict as assert } from 'node:assert'; import { it } from 'node:test'; import { createSessionVisitHistory } from '../../renderer/features/session-navigation/testing.js'; -it('previews the next available visit without moving the cursor', () => { +it('previews the next available visit without moving the cursor', async () => { const history = createSessionVisitHistory(); for (const id of ['A', 'B', 'C']) history.visit(id); assert.equal(history.peek(-1, (id) => id !== 'B'), 'A'); assert.equal(history.peek(-1, () => true), 'B'); assert.equal(history.peek(1, () => true), undefined); let opened: string | undefined; - history.move(-1, () => true, (id) => { opened = id; return true; }); + await history.move(-1, () => true, (id) => { opened = id; return true; }); assert.equal(opened, 'B'); }); -it('traverses visits rather than sidebar order without recording traversal as a new visit', () => { +it('traverses visits rather than sidebar order without recording traversal as a new visit', async () => { const history = createSessionVisitHistory(); const opened: string[] = []; const open = (id: string) => { opened.push(id); history.visit(id); return true; }; for (const id of ['A', 'C', 'B']) history.visit(id); - assert.equal(history.move(-1, () => true, open), true); - assert.equal(history.move(-1, () => true, open), true); - assert.equal(history.move(-1, () => true, open), false); - assert.equal(history.move(1, () => true, open), true); - assert.equal(history.move(1, () => true, open), true); - assert.equal(history.move(1, () => true, open), false); + assert.equal(await history.move(-1, () => true, open), true); + assert.equal(await history.move(-1, () => true, open), true); + assert.equal(await history.move(-1, () => true, open), false); + assert.equal(await history.move(1, () => true, open), true); + assert.equal(await history.move(1, () => true, open), true); + assert.equal(await history.move(1, () => true, open), false); assert.deepEqual(opened, ['C', 'A', 'C', 'B']); }); -it('deduplicates the current visit and discards forward history on a new branch', () => { +it('deduplicates the current visit and discards forward history on a new branch', async () => { const history = createSessionVisitHistory(); const opened: string[] = []; const open = (id: string) => { opened.push(id); history.visit(id); return true; }; for (const id of ['A', 'B', 'B', 'C']) history.visit(id); - history.move(-1, () => true, open); + await history.move(-1, () => true, open); history.visit('D'); - assert.equal(history.move(1, () => true, open), false); - history.move(-1, () => true, open); - history.move(-1, () => true, open); + assert.equal(await history.move(1, () => true, open), false); + await history.move(-1, () => true, open); + await history.move(-1, () => true, open); assert.deepEqual(opened, ['B', 'B', 'A']); }); -it('does not advance the cursor on a rejected or throwing open', () => { +it('does not advance the cursor on a rejected or throwing open', async () => { const history = createSessionVisitHistory(); for (const id of ['A', 'B', 'C']) history.visit(id); - assert.equal(history.move(-1, () => true, () => false), false); - assert.throws(() => history.move(-1, () => true, () => { throw new Error('rejected'); })); + assert.equal(await history.move(-1, () => true, () => false), false); + await assert.rejects(async () => await history.move(-1, () => true, async () => { throw new Error('rejected'); })); let opened: string | undefined; - history.move(-1, () => true, (id) => { opened = id; return true; }); + await history.move(-1, () => true, (id) => { opened = id; return true; }); assert.equal(opened, 'B'); }); -it('can go back to the immediately preceding visit when the current session is removed', () => { +it('can go back to the immediately preceding visit when the current session is removed', async () => { const history = createSessionVisitHistory(); for (const id of ['A', 'B', 'C']) history.visit(id); history.forget(new Set(['C'])); let opened: string | undefined; - history.move(-1, () => true, (id) => { opened = id; return true; }); + await history.move(-1, () => true, (id) => { opened = id; return true; }); assert.equal(opened, 'B'); }); -it('retains only the latest 100 visits', () => { +it('retains only the latest 100 visits', async () => { const history = createSessionVisitHistory(); for (let i = 0; i < 120; i += 1) history.visit(String(i)); const opened: string[] = []; - while (history.move(-1, () => true, (id) => { opened.push(id); return true; })) {} + while (await history.move(-1, () => true, (id) => { opened.push(id); return true; })) {} assert.equal(opened.length, 99); assert.equal(opened.at(-1), '20'); }); -it('skips unavailable targets without erasing visits and prunes confirmed removals', () => { +it('skips unavailable targets without erasing visits and prunes confirmed removals', async () => { const history = createSessionVisitHistory(); const opened: string[] = []; const open = (id: string) => { opened.push(id); history.visit(id); return true; }; for (const id of ['A', 'C', 'B', 'D']) history.visit(id); history.forget(new Set(['B'])); - assert.equal(history.move(-1, (id) => id !== 'C', open), true); + assert.equal(await history.move(-1, (id) => id !== 'C', open), true); assert.deepEqual(opened, ['A']); - assert.equal(history.move(1, () => true, open), true); + assert.equal(await history.move(1, () => true, open), true); assert.deepEqual(opened, ['A', 'C']); - assert.equal(history.move(1, () => true, open), true); + assert.equal(await history.move(1, () => true, open), true); assert.deepEqual(opened, ['A', 'C', 'D']); }); 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..0bbb0b3d80e --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-binding.test.tsx @@ -0,0 +1,242 @@ +/* + * 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 { strict as assert } from 'node:assert'; +import { afterEach, test } from 'node:test'; +import { act, useState, type ComponentProps } from 'react'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { ComposerHandle } from '@maka/ui'; +import type { SessionWorkspaceSnapshot } from '../../shared/session-workspace-state.js'; +import { SessionWindowBinding, SessionWindowServicesProvider, type SessionWindowServices } from '../../renderer/features/session-windows/index.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +type BindingProps = ComponentProps; + +const saved: SessionWorkspaceSnapshot = { + capturedAt: 1, + drafts: [{ + sessionId: 'a', text: 'important unsent draft', references: [], + attachments: [{ type: 'approval', approvalId: 'local-recovery:handoff', name: 'draft.txt', size: 5 }], + directoryReferences: [], quotes: [], + }], + sideConversations: [], +}; + +async function mountBinding(options: { + load?(): Promise; + refresh?(): Promise; + adopt?(): Promise; + snapshot?: SessionWorkspaceSnapshot; + initialSessionId?: string; +} = {}) { + const { root } = installReactRenderer(); + globalThis.requestAnimationFrame = (callback) => { queueMicrotask(() => callback(0)); return 1; }; + const lifecycle: BindingProps['window']['lifecycle'] = {}; + const state = { + active: (options.initialSessionId ?? 'b') as string | undefined, + restoring: false, + loads: 0, + clearSelections: 0, + restored: [] as Parameters[0][number][], + staged: [] as Parameters[0][number][], + released: [] as SessionWorkspaceSnapshot[], + adopted: [] as SessionWorkspaceSnapshot[], + }; + let select: (id: string | undefined) => void = () => { throw new Error('Binding is not mounted'); }; + const services: SessionWindowServices = { + context: async () => ({ kind: 'main', windowId: 'main' }), + routeSession: async () => 'local', + openSessionWindow: async () => {}, + openInMainWindow: async () => {}, + sessionWindowReady: async () => {}, + subscribeSessionWindowCommand: () => () => {}, + ackSessionWindowCommand: async () => {}, + loadWorkspaceSnapshot: async (ids) => { + assert.deepEqual(ids, ['a']); + state.loads += 1; + await options.load?.(); + return options.snapshot ?? saved; + }, + adoptWorkspaceSnapshot: async (snapshot) => { + await options.adopt?.(); + state.adopted.push(snapshot); + }, + releaseSnapshot: async (snapshot) => { state.released.push(snapshot); }, + commandMain: async () => {}, + subscribeDeleted: () => () => {}, + }; + const composer: ComposerHandle = { + waitForDraftReady: async () => {}, + captureDrafts: () => [], + restoreDrafts: (drafts) => { + assert.equal(state.active, 'a', 'restore happens after selecting the destination'); + state.restored.push(...drafts); + }, + setText() {}, appendText() {}, getText: () => '', clearDraft() {}, setDraft() {}, + getDraft: () => '', captureEmptyDraft: () => undefined, appendDraft() {}, focus() {}, openModelPicker() {}, + }; + const staging: BindingProps['staging'] = { + flushImports: async () => {}, captureDrafts: () => [], clearDrafts() {}, + replaceDrafts: (drafts) => { state.staged.push(...drafts); }, + }; + const submission: BindingProps['submission'] = { + waitForPendingSends: async () => {}, captureWorkspace: () => ({ retractedReferences: {} }), + restoreWorkspace() {}, clearWorkspace() {}, + }; + const conversation: BindingProps['conversation'] = { + composer: { current: composer }, + readActiveSessionId: () => state.active, + clearSelection: () => { state.clearSelections += 1; select(undefined); }, + clearOwnedSessionState() {}, captureReadingAnchors: () => ({}), restoreReadingAnchors() {}, + }; + function Harness() { + const [active, setActive] = useState(options.initialSessionId ?? 'b'); + const [restoring, setRestoring] = useState(false); + select = setActive; + state.active = active; + state.restoring = restoring; + const window: BindingProps['window'] = { + context: { kind: 'main', windowId: 'main' }, lifecycle, + frozen: restoring, returning: false, standalone: false, + setFrozen() {}, setRestoring, + bootstrap: async () => {}, openSession: async () => true, openInNewWindow: async () => {}, + openInMainWindow() {}, commandMain() {}, + }; + return + ({}), restoreWorkspace() {} }} + conversation={conversation} activeSessionId={active} + openLocal={({ sessionId }) => select(sessionId)} clearLocal={() => select(undefined)} + refreshSessions={async () => {}} + refreshMessages={async () => { await options.refresh?.(); return true; }} /> + ; + } + await act(async () => root.render()); + assert.ok(lifecycle.current); + return { + state, select: (id: string) => select(id), + open: (isCurrent = () => true, activate = () => select('a')) => lifecycle.current!.openWorkspace('a', isCurrent, activate), + }; +} + +afterEach(cleanupFakeDom); + +test('cancelled activation stays unvisited so reopening reloads and adopts the saved draft', async () => { + const refreshStarted = deferred(); + const refresh = deferred(); + let waitForRefresh = true; + const binding = await mountBinding({ refresh: async () => { + refreshStarted.resolve(); + if (waitForRefresh) await refresh.promise; + } }); + let current = true; + let opening!: Promise; + await act(async () => { + opening = binding.open(() => current); + await refreshStarted.promise; + }); + assert.equal(binding.state.active, 'a'); + assert.equal(binding.state.restoring, true); + await act(async () => { + current = false; + binding.select('b'); + refresh.resolve(); + await opening; + }); + assert.equal(binding.state.restoring, false); + assert.deepEqual(binding.state.released, [saved]); + assert.equal(binding.state.restored.length, 0); + assert.equal(binding.state.adopted.length, 0); + waitForRefresh = false; + await act(async () => { await binding.open(); }); + assert.equal(binding.state.loads, 2, 'cancelled selection must not mark the Session as visited'); + assert.equal(binding.state.restored[0]!.text, saved.drafts[0]!.text); + assert.deepEqual(binding.state.staged[0]!.context.stagedAttachments, [{ + approvalId: 'local-recovery:handoff', name: 'draft.txt', mimeType: undefined, size: 5, + }]); + assert.deepEqual(binding.state.adopted, [saved]); + assert.equal(binding.state.clearSelections, 2, 'a saved handoff resets the previous selection before each attempt'); + await act(async () => { binding.select('b'); }); + await act(async () => { await binding.open(); }); + assert.equal(binding.state.loads, 2, 'successfully adopted drafts use ordinary local navigation'); +}); + +test('restoration freezes the surface while loading, activating, and adopting the draft', async () => { + const load = deferred(); + const refreshStarted = deferred(); + const refresh = deferred(); + const adoptStarted = deferred(); + const adopt = deferred(); + const binding = await mountBinding({ + load: () => load.promise, + refresh: () => { refreshStarted.resolve(); return refresh.promise; }, + adopt: () => { adoptStarted.resolve(); return adopt.promise; }, + }); + let opening!: Promise; + await act(async () => { opening = binding.open(); }); + assert.equal(binding.state.restoring, true); + assert.equal(binding.state.active, 'b'); + await act(async () => { load.resolve(); await refreshStarted.promise; }); + assert.equal(binding.state.restoring, true); + assert.equal(binding.state.active, 'a'); + assert.equal(binding.state.restored.length, 0); + await act(async () => { refresh.resolve(); await adoptStarted.promise; }); + assert.equal(binding.state.restoring, true); + assert.equal(binding.state.restored[0]!.text, saved.drafts[0]!.text); + await act(async () => { adopt.resolve(); await opening; }); + assert.equal(binding.state.restoring, false); + assert.deepEqual(binding.state.adopted, [saved]); +}); + +test('activation failure releases temporary attachment handles and allows retry', async () => { + const binding = await mountBinding(); + await act(async () => { + await assert.rejects(binding.open(() => true, () => { throw new Error('activation failed'); }), /activation failed/); + }); + assert.equal(binding.state.restoring, false); + assert.deepEqual(binding.state.released, [saved]); + assert.equal(binding.state.restored.length, 0); + assert.equal(binding.state.adopted.length, 0); + await act(async () => { await binding.open(); }); + assert.equal(binding.state.loads, 2); + assert.equal(binding.state.restored[0]!.text, saved.drafts[0]!.text); + assert.deepEqual(binding.state.adopted, [saved]); +}); + + +test('opening a Session without a handoff snapshot keeps ordinary selection behavior', async () => { + const empty: SessionWorkspaceSnapshot = { capturedAt: 0, drafts: [], sideConversations: [] }; + const binding = await mountBinding({ snapshot: empty }); + await act(async () => { await binding.open(); }); + assert.equal(binding.state.active, 'a'); + assert.equal(binding.state.clearSelections, 0); + assert.equal(binding.state.restored.length, 0); + assert.deepEqual(binding.state.adopted, [empty]); +}); + +test('an already active Session keeps its local draft without loading a handoff', async () => { + const binding = await mountBinding({ initialSessionId: 'a' }); + await act(async () => { await binding.open(); }); + assert.equal(binding.state.active, 'a'); + assert.equal(binding.state.loads, 0); + assert.equal(binding.state.clearSelections, 0); + assert.equal(binding.state.restoring, false); + assert.equal(binding.state.restored.length, 0); + assert.equal(binding.state.adopted.length, 0); +}); 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..c7a436d736b --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-coordinator.test.ts @@ -0,0 +1,525 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { createSessionWindowCoordinator, registerSessionWindowIpc } from '../session-window-coordinator.js'; +import { createWorkspaceWindowRegistry } from '../workspace-window-registry.js'; +import type { SessionWindowCommand, SessionWindowContext, SessionWindowGroup } from '../../shared/session-window-contract.js'; +import type { SessionWorkspaceSnapshot } from '../../shared/session-workspace-state.js'; + +const snapshot = (): SessionWorkspaceSnapshot => ({ capturedAt: 1, drafts: [], sideConversations: [] }); +const group: SessionWindowGroup = { hostId: 'local', groupId: 'A', sessionIds: ['A', 'A-child', 'A-version'] }; + +function harness() { + const registry = createWorkspaceWindowRegistry(); + const mainCommands: string[] = []; + let closeGate: { started(): void; wait: Promise } | undefined; + const commands: Array<{ windowId: string; command: SessionWindowCommand }> = []; + const errors: unknown[] = []; + const migrations: string[] = []; + const saves: string[] = []; + const loads: string[] = []; + const released: string[] = []; + const drafts = new Map(); + let restoreGate: { started(): void; wait: Promise } | undefined; + const toSnapshot = (entries: readonly [string, string][]): SessionWorkspaceSnapshot => ({ + ...snapshot(), drafts: entries.map(([sessionId, text]) => ({ sessionId, text, references: [], attachments: [], directoryReferences: [], quotes: [] })), + }); + let failRestore: 'main' | 'session' | undefined; + let prepareWait: Promise | undefined; + let failSave = false; + let failAdopt = false; + let failConfirmation = false; + let closeAnyway = false; + const closeWarnings: readonly string[][] = []; + let failLoad = false; + let failRelease = false; + let nextId = 0; + let coordinator: ReturnType; + class FakeWindow extends EventEmitter { + readonly id = String(++nextId); + private readonly contents = Object.assign(new EventEmitter(), { + id: Number(this.id), + isDestroyed: () => this.destroyed, + send: (channel: string, command: SessionWindowCommand) => { + if (channel !== 'session-window:command') { mainCommands.push(channel); return; } + commands.push({ windowId: this.id, command }); + queueMicrotask(async () => { + if (command.kind === 'prepare-transfer' && prepareWait) await prepareWait; + if (restoreGate && command.kind === 'restore' && this.context.kind === 'main') { + const gate = restoreGate; restoreGate = undefined; gate.started(); await gate.wait; + } + const shouldFail = command.kind === 'restore' && this.context.kind === failRestore; + if (shouldFail) failRestore = undefined; + const captured = command.kind === 'prepare-transfer' + ? toSnapshot([...this.drafts].filter(([id]) => command.group.sessionIds.includes(id))) + : toSnapshot([...this.drafts]); + const result = { snapshot: captured, activeSessionId: this.active }; + if (!shouldFail) { + if (command.kind === 'clear') { + for (const id of command.group.sessionIds) this.drafts.delete(id); + if (command.group.sessionIds.includes(this.active ?? '')) this.active = undefined; + } else if (command.kind === 'restore') { + for (const draft of command.snapshot.drafts) this.drafts.set(draft.sessionId, draft.text); + this.active = command.navigation.sessionId; + } else if (command.kind === 'navigate') this.active = command.navigation.sessionId; + else if (command.kind === 'resume' && command.navigation !== undefined) this.active = command.navigation?.sessionId; + } + coordinator.reply(this.contents as never, shouldFail + ? { requestId: command.requestId, ok: false, error: 'restore failed' } + : { requestId: command.requestId, ok: true, result }); + }); + }, + }); + get webContents() { + if (this.destroyed) throw new Error('Object has been destroyed'); + return this.contents; + } + destroyed = false; + focused = false; + revealed = false; + active: string | undefined = 'A'; + readonly drafts = new Map(); + context: SessionWindowContext; + constructor(context: Omit, 'windowId'> | { kind: 'main' }) { + super(); + this.context = { ...context, windowId: this.id }; + } + isDestroyed() { return this.destroyed; } + isFocused() { return this.focused; } + destroy() { this.destroyed = true; this.emit('closed'); } + close() { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + this.emit('close', event); + if (!event.defaultPrevented) this.destroy(); + } + } + const windows: FakeWindow[] = []; + function add(context: Omit, 'windowId'> | { kind: 'main' }) { + const window = new FakeWindow(context); + const controller = { + browserWindow: () => window, + reveal: () => { window.revealed = true; }, + focus: () => { window.focused = true; }, + }; + registry.register({ context: window.context, window: window as never, controller: controller as never }); + windows.push(window); + if (coordinator) coordinator.sessionWindowReady(window.webContents as never); + return { window, controller }; + } + const main = add({ kind: 'main' }).window; + coordinator = createSessionWindowCoordinator({ + windows: { registry, createSessionWindow: async (context: any) => add(context).controller } as never, + signal: new AbortController().signal, + ensureMainWindow: async () => { if (!registry.main()) add({ kind: 'main' }); }, + resolveGroup: async (id) => group.sessionIds.includes(id) ? group : { hostId: 'local', groupId: id, sessionIds: [id] }, + saveSnapshot: async (sender, state) => { if (failSave) throw new Error('attachment unavailable'); saves.push(String(sender.id)); for (const draft of state.drafts) drafts.set(draft.sessionId, draft.text); }, + saveForClose: async (sender, state) => { + saves.push(String(sender.id)); + for (const draft of state.drafts) drafts.set(draft.sessionId, draft.text); + return { snapshot: state, losses: failSave ? ['attachment unavailable'] : [] }; + }, + confirmClose: async (_sender, losses) => { + (closeWarnings as string[][]).push([...losses]); + if (closeGate) { const gate = closeGate; closeGate = undefined; gate.started(); await gate.wait; } + if (failConfirmation) throw new Error('confirmation failed'); + return closeAnyway; + }, + adoptSnapshot: async (_sender, snapshot) => { + if (failAdopt) throw new Error('copy no longer available'); + for (const draft of snapshot.drafts) drafts.delete(draft.sessionId); + }, + loadSnapshot: async (sender, ids) => { if (failLoad) throw new Error('recovery capacity'); loads.push(String(sender.id)); return toSnapshot([...drafts].filter(([id]) => ids.includes(id))); }, + forgetSnapshot: (_sender, snapshot) => { for (const draft of snapshot.drafts) drafts.delete(draft.sessionId); }, + releaseSnapshot: (sender) => { released.push(String(sender.id)); }, + transferResources: async ({ source, target }) => { migrations.push(`${source?.id}->${target.id}`); }, + releaseResources: () => { if (failRelease) throw new Error('resource release failed'); }, + isQuitting: () => false, + onError: (error) => { errors.push(error); }, + commandTimeoutMs: 30, + }); + coordinator.sessionWindowReady(main.webContents as never); + return { + coordinator, registry, main, windows, commands, errors, migrations, saves, loads, released, + pauseNextMainRestore() { + let started!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { started = resolve; }); + const wait = new Promise((resolve) => { release = resolve; }); + restoreGate = { started, wait }; + return { entered, release }; + }, + drafts, closeWarnings, mainCommands, + pauseCloseConfirmation() { + let started!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { started = resolve; }); + closeGate = { started, wait: new Promise((resolve) => { release = resolve; }) }; + return { entered, release }; + }, + closeAnyway: () => { closeAnyway = true; }, + failRestore: () => { failRestore = 'session'; }, + failMainRestore: () => { failRestore = 'main'; }, + delayPrepare: (wait: Promise) => { prepareWait = wait; }, + failSave: () => { failSave = true; }, + failAdopt: () => { failAdopt = true; }, + failConfirmation: () => { failConfirmation = true; }, + failLoad: () => { failLoad = true; }, + failRelease: () => { failRelease = true; }, + }; +} + +const settle = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, 10)); }; + +test('duplicate opens and group members share one independent window', async () => { + const h = harness(); + await Promise.all([ + h.coordinator.openSessionWindow(h.main.webContents as never, 'A'), + h.coordinator.openSessionWindow(h.main.webContents as never, 'A-child'), + ]); + assert.equal(h.windows.length, 2); + const child = h.windows[1]!; + assert.equal(child.revealed, true); + assert.equal(h.coordinator.ownerForSession('A-version'), child.webContents); + assert.equal(h.coordinator.canUseSession(h.main.webContents as never, 'A'), false); + assert.equal(await h.coordinator.routeSession(h.main.webContents as never, { sessionId: 'A-version' }), 'routed'); + assert.equal(await h.coordinator.routeSession(child.webContents as never, { sessionId: 'A-child' }), 'local'); + assert.equal(h.commands.filter(({ command }) => command.kind === 'clear').length, 1); + assert.deepEqual(h.loads, ['2'], 'only the receiving renderer gets attachment recovery handles'); +}); + +test('unrelated child navigation targets the main window without replacing the child', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + assert.equal(await h.coordinator.routeSession(child.webContents as never, { sessionId: 'B' }), 'routed'); + assert.equal(h.commands.at(-1)?.windowId, h.main.id); + assert.equal(h.coordinator.ownerForSession('A'), child.webContents); + assert.equal(child.destroyed, false); +}); + +test('returning transfers current child member and closes only after restore', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + child.active = 'A-child'; + await h.coordinator.openInMainWindow(child.webContents as never); + assert.equal(child.destroyed, true); + assert.equal(h.errors.length, 0); + assert.equal(h.coordinator.ownerForSession('A'), undefined); + const restore = h.commands.at(-1)?.command; + assert.equal(restore?.kind, 'restore'); + if (restore?.kind === 'restore') assert.equal(restore.navigation.sessionId, 'A-child'); + assert.deepEqual(h.migrations, ['1->2', '2->1']); +}); + +test('failed destination restoration revokes it before restoring original owner', async () => { + const h = harness(); + h.failRestore(); + await assert.rejects(h.coordinator.openSessionWindow(h.main.webContents as never, 'A'), /restore failed/); + assert.equal(h.windows[1]?.destroyed, true); + assert.equal(h.coordinator.ownerForSession('A'), undefined); + assert.equal(h.main.destroyed, false); + assert.deepEqual(h.migrations, ['1->2', '2->1']); + assert.deepEqual(h.loads, ['2', '1'], 'issue once to destination and once to rollback source'); + assert.deepEqual(h.released, ['2'], 'discard failed destination handles before rollback'); + const tail = h.commands.slice(-2); + assert.deepEqual(tail.map(({ windowId, command }) => [windowId, command.kind]), [['2', 'clear'], ['1', 'restore']]); +}); + +test('normal close saves workspace before releasing ownership and does not close main', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.windows[1]!.close(); + assert.equal(h.windows[1]!.destroyed, false); + await settle(); + assert.equal(h.windows[1]!.destroyed, true); + assert.equal(h.errors.length, 0); + assert.equal(h.main.destroyed, false); + assert.equal(h.coordinator.ownerForSession('A'), undefined); + assert.equal(await h.coordinator.routeSession(h.main.webContents as never, { sessionId: 'A' }), 'local'); +}); + +test('canceling a close with attachment losses keeps the child and its ownership', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.failSave(); + h.windows[1]!.close(); + await settle(); + assert.equal(h.windows[1]!.destroyed, false); + assert.equal(h.coordinator.ownerForSession('A'), h.windows[1]!.webContents); + assert.deepEqual(h.closeWarnings, [['attachment unavailable']]); +}); + +test('closing main leaves the session window and return recreates main', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.main.close(); + await settle(); + assert.equal(h.main.destroyed, true); + assert.equal(h.windows[1]!.destroyed, false); + await h.coordinator.openInMainWindow(h.windows[1]!.webContents as never); + assert.equal(h.windows.length, 3); + assert.equal(h.registry.main()?.context.windowId, '3'); +}); + + +test('failed return restores child ownership and the main window previous task', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.main.active = 'B'; + h.failMainRestore(); + await assert.rejects(h.coordinator.openInMainWindow(h.windows[1]!.webContents as never), /restore failed/); + assert.equal(h.coordinator.ownerForSession('A'), h.windows[1]!.webContents); + assert.equal(h.windows[1]!.destroyed, false); + assert.equal(h.main.active, 'B'); + assert.equal(h.commands.at(-1)?.command.kind, 'resume'); +}); + +test('reloaded child restores the last viewed group member without opening another window', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + await h.coordinator.routeSession(child.webContents as never, { sessionId: 'A-child' }); + child.webContents.emit('render-process-gone'); + h.coordinator.sessionWindowReady(child.webContents as never); + await settle(); + assert.equal(h.windows.length, 2); + const last = h.commands.at(-1)?.command; + assert.equal(last?.kind, 'navigate'); + if (last?.kind === 'navigate') assert.equal(last.navigation.sessionId, 'A-child'); + assert.equal(h.loads.length, 1, 'reload never replays an adopted draft'); +}); + +test('synchronous cleanup failure after native destruction is reported without throwing from closed', async () => { + const h = harness(); + h.failRelease(); + assert.doesNotThrow(() => h.main.destroy()); + assert.throws(() => h.main.webContents, /Object has been destroyed/); + await settle(); + assert.equal(h.errors.length, 1); + assert.match(String(h.errors[0]), /resource release failed/); +}); + + +test('navigation IPC accepts omitted optional fields from a normal session row', async () => { + const h = harness(); + const handlers = new Map any>(); + registerSessionWindowIpc(h.coordinator, { handle: (channel: string, listener: (event: any, input: unknown) => any) => { handlers.set(channel, listener); } } as never); + const route = handlers.get('session-window:route')!; + const event = { sender: h.main.webContents, senderFrame: undefined }; + assert.equal(await route(event, { sessionId: 'A', turnId: undefined, sequence: undefined }), 'local'); + assert.equal(await route(event, { sessionId: 'A', turnId: 'turn-1', sequence: 0 }), 'local'); + assert.throws(() => route(event, { sessionId: 'A', turnId: 1 }), /Invalid turn identity/); + assert.throws(() => route(event, { sessionId: 'A', sequence: -1 }), /Invalid turn sequence/); +}); + + +test('concurrent groups returning to a recreated main preserve both drafts and serialize restoration', async () => { + const h = harness(); + h.main.drafts.set('A', 'draft A'); + h.main.drafts.set('B', 'draft B'); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.main.active = 'B'; + await h.coordinator.openSessionWindow(h.main.webContents as never, 'B'); + const a = h.windows[1]!; + const b = h.windows[2]!; + b.active = 'B'; + h.main.close(); + await settle(); + const gate = h.pauseNextMainRestore(); + const returning = Promise.all([ + h.coordinator.openInMainWindow(a.webContents as never), + h.coordinator.openInMainWindow(b.webContents as never), + ]); + await gate.entered; + assert.equal(h.windows.length, 4, 'one replacement main window is shared by both returns'); + const main = h.windows[3]!; + assert.equal(h.commands.filter(({ windowId, command }) => windowId === main.id && command.kind === 'prepare-navigation').length, 1, + 'B must wait until A has restored before switching the shared main workspace'); + gate.release(); + await returning; + assert.equal(a.destroyed, true); + assert.equal(b.destroyed, true); + assert.equal(main.drafts.get('A'), 'draft A'); + assert.equal(main.drafts.get('B'), 'draft B'); + assert.equal(h.drafts.size, 0, 'live renderer owns adopted drafts; no stale main-process copies'); + assert.equal(h.errors.length, 0); +}); + + +test('adopted snapshots are removed while closed-window snapshots wait for their next owner', async () => { + const h = harness(); + h.main.drafts.set('A', 'unsent'); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + assert.equal(h.drafts.size, 0); + child.drafts.set('A', 'newer unsent'); + child.close(); + await settle(); + assert.equal(h.drafts.get('A'), 'newer unsent'); + const snapshot = await h.coordinator.loadSnapshot(h.main.webContents as never, ['A']); + await h.coordinator.adoptSnapshot(h.main.webContents as never, snapshot); + assert.equal(h.drafts.size, 0); +}); + + +test('failed handoff before source clear and failed normal close discard stale custody', async () => { + for (const operation of ['load', 'close'] as const) { + const h = harness(); + h.main.drafts.set('A', 'old unsent draft'); + if (operation === 'load') { + h.failLoad(); + await assert.rejects(h.coordinator.openSessionWindow(h.main.webContents as never, 'A'), /recovery capacity/); + } else { + h.failSave(); + h.main.close(); + await settle(); + } + assert.equal(h.main.destroyed, false); + assert.equal(h.main.drafts.get('A'), 'old unsent draft'); + h.main.drafts.set('A', ''); // The owner can continue editing or send successfully. + assert.equal(h.drafts.size, 0, 'a resumed owner must not leave a stale draft to replay'); + } +}); + + +test('a timed-out source preparation resumes its window and ignores the late snapshot', async () => { + const h = harness(); + h.main.drafts.set('A', 'draft'); + let complete!: () => void; + h.delayPrepare(new Promise((resolve) => { complete = resolve; })); + await assert.rejects(h.coordinator.openSessionWindow(h.main.webContents as never, 'A'), /not confirmed/); + assert.ok(h.commands.some(({ windowId, command }) => windowId === h.main.id && command.kind === 'resume')); + complete(); + await settle(); + assert.equal(h.main.destroyed, false); + assert.equal(h.drafts.size, 0); +}); + +test('late replies cannot discard a snapshot belonging to another window', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + const retained = { ...snapshot(), drafts: [{ sessionId: 'A', text: 'child draft', references: [], attachments: [], directoryReferences: [], quotes: [] }] }; + await h.coordinator.saveSnapshot(child.webContents as never, retained); + h.coordinator.reply(h.main.webContents as never, { requestId: 'invented', ok: true, result: { snapshot: retained } }); + await settle(); + assert.equal(h.drafts.get('A'), 'child draft'); + assert.equal(h.errors.length, 0, 'late replies have no side effects'); +}); + + +test('confirmed attachment loss closes once and still runs native close finalization', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + const child = h.windows[1]!; + let nativeFinalizations = 0; + child.on('close', (event) => { if (!event.defaultPrevented) nativeFinalizations += 1; }); + h.failSave(); + h.closeAnyway(); + child.close(); + await settle(); + assert.equal(child.destroyed, true); + assert.equal(nativeFinalizations, 1); + assert.equal(h.closeWarnings.length, 1); + assert.equal(h.commands.filter(({ windowId, command }) => windowId === child.id && command.kind === 'flush').length, 1); +}); + +test('closing B does not wait for A restoring into Main', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'B'); + const a = h.windows[1]!; + const b = h.windows[2]!; + const gate = h.pauseNextMainRestore(); + const returning = h.coordinator.openInMainWindow(a.webContents as never); + await gate.entered; + b.close(); + await settle(); + assert.equal(b.destroyed, true); + assert.equal(a.destroyed, false); + gate.release(); + await returning; +}); + +test('returning A never exports unrelated B drafts in Main', async () => { + const h = harness(); + h.main.drafts.set('A', 'A draft'); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.main.active = 'B'; + h.main.drafts.set('B', 'B draft with an unavailable attachment'); + const previousSaves = h.saves.length; + await h.coordinator.openInMainWindow(h.windows[1]!.webContents as never); + assert.deepEqual(h.saves.slice(previousSaves), ['2']); + assert.equal(h.main.drafts.get('B'), 'B draft with an unavailable attachment'); + assert.equal(h.main.active, 'A'); +}); + + +test('main commands cannot enter Main while its close confirmation is pending', async () => { + const h = harness(); + await h.coordinator.openSessionWindow(h.main.webContents as never, 'A'); + h.failSave(); + h.closeAnyway(); + const gate = h.pauseCloseConfirmation(); + h.main.close(); + await gate.entered; + const child = h.windows[1]!; + for (const command of ['newTask', 'openSettings', 'openHelp'] as const) { + await assert.rejects(h.coordinator.commandMain(child.webContents as never, command), /closing/); + } + assert.deepEqual(h.mainCommands, []); + gate.release(); + await settle(); + assert.equal(h.main.destroyed, true); + assert.equal(child.destroyed, false); +}); + +test('failed cancellation retains the recovery snapshot when custody cannot return to its renderer', async () => { + const h = harness(); + h.main.drafts.set('A', 'recoverable unsent text'); + h.failSave(); + const gate = h.pauseCloseConfirmation(); + h.main.close(); + await gate.entered; + h.failAdopt(); // A companion becomes unavailable while confirmation is open. + gate.release(); + await settle(); + assert.equal(h.main.destroyed, false); + assert.equal(h.drafts.get('A'), 'recoverable unsent text'); + assert.equal(h.commands.at(-1)?.command.kind, 'resume'); + assert.match(String(h.errors.at(-1)), /copy no longer available/); +}); + +test('a rejected close dialog returns custody and resumes the source', async () => { + const h = harness(); + h.main.drafts.set('A', 'unsent text'); + h.failSave(); + h.failConfirmation(); + h.main.close(); + await settle(); + assert.equal(h.main.destroyed, false); + assert.equal(h.drafts.size, 0, 'successful return to renderer releases the retained snapshot'); + assert.equal(h.main.drafts.get('A'), 'unsent text'); + assert.equal(h.commands.at(-1)?.command.kind, 'resume'); + assert.match(String(h.errors.at(-1)), /confirmation failed/); +}); diff --git a/apps/desktop/src/main/__tests__/session-window-group.test.ts b/apps/desktop/src/main/__tests__/session-window-group.test.ts new file mode 100644 index 00000000000..72ae72ba303 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-group.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { SessionWindowGroups, type WindowGroupSession } from '../session-window-group.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; +const key = (id: string, hostId = 'host') => desktopSessionKey({ hostId, sessionId: id }); +const parent = (parentSessionId: string): WindowGroupSession['subagentParent'] => ({ kind: 'subagent', parentSessionId, lifecycle: 'foreground', spawnedBy: { parentRunId: 'run', parentTurnId: 'turn', toolCallId: 'call' } }); +const row = (id: string, extra: Partial = {}): WindowGroupSession => ({ id, labels: [], ...extra }); + +test('session windows group nested subagents, revisions and side conversations while keeping ordinary branches separate', () => { + const groups = new SessionWindowGroups(); + const sessions = [row('root'), row('root-v2', { revisionRootSessionId: 'root' }), + row('child', { subagentParent: parent('root-v2') }), + row('grandchild', { subagentParent: parent('child') }), + row('side', { parentSessionId: 'root', labels: ['mode:side_conversation'] }), + row('branch', { parentSessionId: 'root' })]; + const group = groups.resolve(key('grandchild'), sessions); + assert.equal(group.groupId, key('root')); + assert.deepEqual(group.sessionIds, ['child', 'grandchild', 'root', 'root-v2', 'side'].map((id) => key(id))); + assert.deepEqual(groups.resolve(key('branch'), sessions).sessionIds, [key('branch')]); +}); + +test('deleting a root preserves its open family and equal IDs on different Hosts remain independent', () => { + const groups = new SessionWindowGroups(); + const child = row('child', { subagentParent: parent('root') }); + const original = groups.resolve(key('child'), [row('root'), child]); + assert.deepEqual(groups.resolve(key('child'), [child]), original); + assert.equal(groups.resolve(key('child', 'other'), [row('child')]).groupId, key('child', 'other')); +}); + +test('missing parents retain a stable family and corrupt cyclic ancestry terminates deterministically', () => { + const groups = new SessionWindowGroups(); + const rows = [row('a', { subagentParent: parent('b') }), row('b', { subagentParent: parent('a') })]; + assert.deepEqual(groups.resolve(key('a'), rows), groups.resolve(key('b'), rows)); + assert.throws(() => groups.resolve(key('unknown'), []), /unavailable/); +}); diff --git a/apps/desktop/src/main/__tests__/session-window-pip.test.ts b/apps/desktop/src/main/__tests__/session-window-pip.test.ts new file mode 100644 index 00000000000..2d90a8febb6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-pip.test.ts @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createComputerUsePipController } from '../computer-use/pip-window.js'; +import type { ParentWindowLike, PipWindowLike } from '../computer-use/pip-electron.js'; + +test('PiP resolves the session owner before construction and reparents on transfer', () => { + const main = { isDestroyed: () => false }; + const child = { isDestroyed: () => false }; + let owner: ParentWindowLike = main; + let createdParent: unknown; + let movedParent: unknown; + let subscriptionCount = 0; + let unsubscribed = 0; + const resolvedIds: string[] = []; + const bounds = { x: 20, y: 20, width: 320, height: 200 }; + const window: PipWindowLike = { + send: () => undefined, + onReady: () => undefined, + onGone: () => undefined, + onLoadFailure: () => undefined, + onMessage: () => undefined, + setParentWindow: (parent) => { movedParent = parent; }, + setBounds: () => undefined, + getBounds: () => bounds, + showInactive: () => undefined, + setIgnoreMouseEvents: () => undefined, + isDestroyed: () => false, + destroy: () => undefined, + }; + const pip = createComputerUsePipController({ + resolveBounds: () => bounds, + resolveParentWindow: (id) => { resolvedIds.push(id); return owner; }, + resolveAnchorRect: () => ({ x: 0, y: 0, width: 1000, height: 800 }), + subscribeAnchorChanges: (id) => { + assert.equal(id, 'session-A'); + subscriptionCount += 1; + return () => { unsubscribed += 1; }; + }, + createWindow: (options) => { createdParent = options.parent; return window; }, + cursorPoint: () => ({ x: 0, y: 0 }), + workAreaFor: () => ({ x: 0, y: 0, width: 1000, height: 800 }), + scheduleFrame: () => () => undefined, + scheduleHoverCheck: () => () => undefined, + }); + pip.present({ sessionId: 'session-A', base64: 'image', mimeType: 'image/png', widthPx: 320, heightPx: 200 }); + assert.equal(createdParent, main); + owner = child; + pip.refreshOwner(); + assert.equal(movedParent, child); + assert.deepEqual(resolvedIds, ['session-A', 'session-A']); + assert.equal(subscriptionCount, 2); + assert.equal(unsubscribed, 1); + pip.destroyAll(); +}); diff --git a/apps/desktop/src/main/__tests__/session-window-renderer.test.ts b/apps/desktop/src/main/__tests__/session-window-renderer.test.ts new file mode 100644 index 00000000000..a51ee70a2a1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-window-renderer.test.ts @@ -0,0 +1,301 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAppShellSessionUiStateController, createConversationWorkspace, stubConversationServices, replaceWorkspaceReadingAnchors } from '../../renderer/features/conversation/testing.js'; +import { bootstrapSessionWindow, createSessionWindowCommandHandler, createSessionWindowNavigator, restoreSessionWindowWorkspace } from '../../renderer/features/session-windows/testing.js'; +import { createDesktopSessionWindowServices } from '../../renderer/platform/desktop/create-session-window-services.js'; +import { createSessionCatalogController } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js'; +import { clearNewTaskReloadIntent, markNewTaskReloadIntent } from '../../renderer/application/contracts/new-task-reload-intent.js'; +import type { SessionWindowNavigation } from '../../shared/session-window-contract.js'; +import type { SessionWorkspaceSnapshot } from '../../shared/session-workspace-state.js'; + +const group = { hostId: 'host', groupId: 'A', sessionIds: ['A', 'child'] }; +const snapshot: SessionWorkspaceSnapshot = { capturedAt: 1, drafts: [], sideConversations: [] }; +function harness(selected = 'A') { + let active: string | undefined = selected; + const events: string[] = []; + const handler = createSessionWindowCommandHandler({ + freeze: (value) => { events.push(value ? 'freeze' : 'resume'); }, + activeSessionId: () => active, + sessionIds: () => ['A', 'B'], + capture: async (ids) => { events.push(`capture:${ids.join(',')}`); return snapshot; }, + clear: (ids, selection) => { events.push(`clear:${ids.join(',')}`); if (selection) active = undefined; }, + restore: () => { events.push('restore'); }, + navigate: async ({ sessionId }) => { events.push(`navigate:${sessionId}`); active = sessionId; }, + detach: () => { events.push('detach'); }, + resume: () => { events.push('resume-resources'); }, + }); + return { handler, events, active: () => active }; +} + +test('clearing A releases its displayed child but preserves an unrelated B selection', async () => { + for (const selected of ['A', 'child', 'B']) { + const state = harness(selected); + await state.handler({ requestId: 'clear', kind: 'clear', group }); + assert.equal(state.active(), selected === 'B' ? 'B' : undefined); + } +}); + +test('handoff freezes before capture and activates the destination before restoring its live draft', async () => { + const state = harness('B'); + const prepared = await state.handler({ requestId: 'prepare', kind: 'prepare-transfer', group }); + assert.deepEqual(state.events, ['freeze', 'capture:A,child']); + assert.equal(prepared?.activeSessionId, 'B'); + state.events.length = 0; + await state.handler({ requestId: 'restore', kind: 'restore', group, navigation: { sessionId: 'child' }, snapshot }); + assert.deepEqual(state.events, ['freeze', 'navigate:child', 'restore', 'resume']); + assert.equal(state.active(), 'child'); +}); + +test('close captures without detaching until main accepts custody', async () => { + const state = harness(); + await state.handler({ requestId: 'flush', kind: 'flush' }); + assert.deepEqual(state.events, ['freeze', 'capture:A,B']); + await state.handler({ requestId: 'detach', kind: 'detach' }); + assert.equal(state.events.at(-1), 'detach'); +}); + +test('a failed close capture resumes the source without detaching or discarding it', async () => { + const events: string[] = []; + const handler = createSessionWindowCommandHandler({ + freeze: (value) => { events.push(value ? 'freeze' : 'thaw'); }, activeSessionId: () => 'A', sessionIds: () => ['A'], + capture: async () => { throw new Error('attachment unavailable'); }, clear: () => { throw new Error('unexpected clear'); }, restore: () => undefined, + navigate: async () => undefined, + detach: () => { events.push('detach'); }, resume: () => { events.push('resume'); }, + }); + await assert.rejects(handler({ requestId: 'flush', kind: 'flush' }), /attachment unavailable/); + assert.deepEqual(events, ['freeze', 'resume', 'thaw']); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test('an owned task routes to its other window without restoring or mounting locally', async () => { + const calls: string[] = []; + const open = createSessionWindowNavigator({ route: async () => 'routed', openWorkspace: async (_id, _current, activate) => { calls.push('restore'); activate(); }, blocked: () => false }); + await open.open({ sessionId: 'A' }, () => { calls.push('activate'); }); + assert.deepEqual(calls, []); +}); + +test('local navigation delegates selection to the workspace handoff', async () => { + const calls: string[] = []; + const open = createSessionWindowNavigator({ route: async () => 'local', openWorkspace: async (_id, _current, activate) => { calls.push('restore'); activate(); }, blocked: () => false }); + await open.open({ sessionId: 'A' }, () => { calls.push('activate'); }); + assert.deepEqual(calls, ['restore', 'activate']); +}); + +test('a superseded route and a route arriving during handoff cannot replace the current task', async () => { + const first = deferred<'local'>(); + let blocked = false; + const selected: string[] = []; + const open = createSessionWindowNavigator({ route: (nav: SessionWindowNavigation) => nav.sessionId === 'A' ? first.promise : Promise.resolve('local'), openWorkspace: async (_id, _current, activate) => activate(), blocked: () => blocked }); + const old = open.open({ sessionId: 'A' }, () => { selected.push('A'); }); + await open.open({ sessionId: 'B' }, () => { selected.push('B'); }); + first.resolve('local'); + await old; + assert.deepEqual(selected, ['B']); + const restoring = deferred(); + const during = createSessionWindowNavigator({ route: async () => 'local', openWorkspace: async (_id, _current, activate) => { await restoring.promise; activate(); }, blocked: () => blocked }); + const pending = during.open({ sessionId: 'A' }, () => { selected.push('A'); }); + await Promise.resolve(); + blocked = true; + restoring.resolve(); + await pending; + assert.deepEqual(selected, ['B']); +}); + +test('returning a task that follows latest clears its old main-window anchor and preserves B', () => { + const ui = createAppShellSessionUiStateController(); + ui.setTranscriptReadingAnchor('A', { turnId: 'old-main-anchor' }); + ui.setTranscriptReadingAnchor('B', { turnId: 'B-anchor' }); + // The child scrolled to the tail; absent A is intentional, not a partial patch. + replaceWorkspaceReadingAnchors(['A', 'child'], { child: { turnId: 'child-anchor' } }, ui.setTranscriptReadingAnchor); + assert.equal(ui.transcriptReadingAnchorBySessionRef.current.A, undefined); + assert.deepEqual(ui.transcriptReadingAnchorBySessionRef.current.child, { turnId: 'child-anchor' }); + assert.deepEqual(ui.transcriptReadingAnchorBySessionRef.current.B, { turnId: 'B-anchor' }); + // Source clear and confirmed deletion use the same scoped replacement. + replaceWorkspaceReadingAnchors(['A', 'child'], {}, ui.setTranscriptReadingAnchor); + assert.equal(ui.transcriptReadingAnchorBySessionRef.current.child, undefined); + assert.deepEqual(ui.transcriptReadingAnchorBySessionRef.current.B, { turnId: 'B-anchor' }); +}); + +function readableSessions() { + return ['A', 'B', 'child'].map((id) => ({ + id, name: id, isFlagged: false, isArchived: false, labels: [], lastMessageAt: 1, + hasUnread: false, status: 'active' as const, backend: 'ai-sdk' as const, + revision: 1, runtimeHostId: 'local', profileId: 'local', profileName: 'Local', + llmConnectionSlug: 'test', connectionLocked: false, model: 'test', + permissionMode: 'ask' as const, profileKind: 'local' as const, + })); +} + +test('releasing a pending task revokes late transcript publication while preserving the other displayed task', async () => { + const catalog = createSessionCatalogController(); + catalog.commitSessions(readableSessions()); + const workspace = createConversationWorkspace(catalog, stubConversationServices().observation, false); + workspace.commands.setActiveId('B'); + const b = [{ id: 'b-message', type: 'user' as const, text: 'B', ts: 1, turnId: 'b-turn' }]; + workspace.commitTranscript('B', b); + workspace.commands.setActiveId('A'); + const ownsPendingA = workspace.commands.captureSelection(); + assert.equal(workspace.activeIdRef.current, 'B'); + const handler = createSessionWindowCommandHandler({ + freeze() {}, activeSessionId: () => workspace.activeIdRef.current, sessionIds: () => ['B'], + capture: async () => snapshot, restore() {}, navigate: async () => {}, detach() {}, resume() {}, + clear: (ids) => { for (const id of ids) workspace.commands.clearOwnedSessionState(id); }, + }); + await handler({ requestId: 'release-a', kind: 'clear', group }); + assert.equal(ownsPendingA(), false); + assert.equal(catalog.getState().activeSessionId, 'B'); + assert.equal(workspace.commitTranscript('A', [{ ...b[0]!, id: 'late-a', text: 'A' }]), false); + assert.equal(workspace.activeIdRef.current, 'B'); + assert.deepEqual(workspace.messages.getSnapshot(), b); + + // Releasing the displayed member must also leave another requested task alive. + workspace.commands.setActiveId('A'); + workspace.commitTranscript('A', []); + workspace.commands.setActiveId('B'); + await handler({ requestId: 'release-displayed-a', kind: 'clear', group }); + assert.equal(catalog.getState().activeSessionId, 'B'); + assert.equal(workspace.commitTranscript('B', b), true); +}); + +test('a completed freeze/resume cycle invalidates both pending routes and pending workspace restores', async () => { + for (const phase of ['route', 'restore'] as const) { + const wait = deferred(); + const effects: string[] = []; + let blocked = false; + const navigator = createSessionWindowNavigator({ + blocked: () => blocked, + route: async () => { if (phase === 'route') await wait.promise; return 'local'; }, + openWorkspace: async (_id, isCurrent, activate) => { + if (phase === 'restore') await wait.promise; + if (isCurrent()) { effects.push('restored-A'); activate(); } + }, + }); + const pending = navigator.open({ sessionId: 'A' }, () => { effects.push('selected-A'); }); + await Promise.resolve(); + blocked = true; + navigator.invalidate(); + blocked = false; + wait.resolve(); + await pending; + assert.equal(effects.length, 0, phase); + await navigator.open({ sessionId: 'B' }, () => { effects.push('selected-B'); }); + assert.equal(effects.at(-1), 'selected-B', 'new navigation still works after resume'); + } +}); + +test('a discarded workspace load releases main and side-chat recovery handles without populating draft caches', async () => { + const draft = { + sessionId: 'A', text: 'unsent', references: [], directoryReferences: [], quotes: [], + attachments: [{ type: 'approval' as const, approvalId: 'local-recovery:main', name: 'a.png', size: 1 }], + }; + const loaded: SessionWorkspaceSnapshot = { + capturedAt: 1, drafts: [draft], + sideConversations: [{ panel: { id: 'side', sourceSessionId: 'A', quotes: [] }, hasContent: true, active: true, + draft: { ...draft, sessionId: 'side', attachments: [ + ...draft.attachments, + { type: 'approval', approvalId: 'local-recovery:side', name: 'b.png', size: 1 }, + { type: 'upload', name: 'not-a-handle', mimeType: 'text/plain', base64: 'aGVsbG8=' }, + ] } }], + }; + const released: (readonly string[])[] = []; + const services = createDesktopSessionWindowServices({ + appWindow: {}, sessionLocal: { releaseRecoveryAttachments: async (ids: readonly string[]) => { released.push(ids); } }, + } as unknown as Parameters[0]); + const ready = deferred(); + const restored = new Map(); + const selected: string[] = []; + const navigator = createSessionWindowNavigator({ + blocked: () => false, route: async () => 'local', + openWorkspace: (_id, isCurrent, activate) => restoreSessionWindowWorkspace({ + load: () => ready.promise, release: services.releaseSnapshot, isCurrent, + activate: async () => activate(), adopt: async () => {}, + restore: (state) => { for (const member of state.drafts) restored.set(member.sessionId, member.text); }, + }), + }); + const pending = navigator.open({ sessionId: 'A' }, () => { selected.push('A'); }); + await Promise.resolve(); + navigator.invalidate(); + ready.resolve(loaded); + await pending; + assert.equal(restored.size, 0); + assert.equal(selected.length, 0); + assert.deepEqual(released, [['local-recovery:main', 'local-recovery:side']]); + + // A subsequent accepted load adopts the snapshot, retaining its handles. + await navigator.open({ sessionId: 'A' }, () => { selected.push('A'); }); + assert.equal(restored.get('A'), 'unsent'); + assert.deepEqual(selected, ['A']); + assert.equal(released.length, 1); + await services.releaseSnapshot(snapshot); + assert.equal(released.length, 1, 'empty snapshots do not send disposal IPC'); +}); + +test('window bootstrap preserves an explicit new-task reload intent before and during catalog loading', async () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + const entries = new Map(); + const storage = { getItem: (key: string) => entries.get(key) ?? null, setItem: (key: string, value: string) => { entries.set(key, value); }, removeItem: (key: string) => { entries.delete(key); } }; + Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, value: storage }); + try { + const selected: string[] = []; + const input = { kind: 'main' as const, refresh: async () => readableSessions(), revision: () => 0, open: (id: string) => { selected.push(id); } }; + markNewTaskReloadIntent(); + await bootstrapSessionWindow(input); + assert.deepEqual(selected, []); + clearNewTaskReloadIntent(); + const refresh = deferred>(); + const boot = bootstrapSessionWindow({ ...input, refresh: () => refresh.promise }); + markNewTaskReloadIntent(); + refresh.resolve(readableSessions()); + await boot; + assert.deepEqual(selected, []); + clearNewTaskReloadIntent(); + await bootstrapSessionWindow(input); + assert.deepEqual(selected, ['A'], 'cold main windows still select history'); + await bootstrapSessionWindow({ ...input, kind: 'session' }); + assert.deepEqual(selected, ['A'], 'session windows never choose recent history'); + } finally { + if (previous) Object.defineProperty(globalThis, 'sessionStorage', previous); + else Reflect.deleteProperty(globalThis, 'sessionStorage'); + } +}); + + +test('workspace adoption follows activation and restore; cancelled activation retains custody', async () => { + for (const cancelled of [false, true]) { + const events: string[] = []; + let current = true; + await restoreSessionWindowWorkspace({ + load: async () => snapshot, + isCurrent: () => current, + activate: async () => { events.push('activate'); if (cancelled) current = false; }, + restore: () => { events.push('restore'); }, + adopt: async () => { events.push('adopt'); }, + release: async () => { events.push('release'); }, + }); + assert.deepEqual(events, cancelled ? ['activate', 'release'] : ['activate', 'restore', 'adopt']); + } +}); diff --git a/apps/desktop/src/main/__tests__/session-workspace-state-store.test.ts b/apps/desktop/src/main/__tests__/session-workspace-state-store.test.ts new file mode 100644 index 00000000000..a74fdc3a95a --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-workspace-state-store.test.ts @@ -0,0 +1,407 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createSessionCopyCleanupAuthority } from '@maka/storage/session-copy-cleanup'; +import { adoptWorkspaceCopies } from '../session-workspace-custody.js'; +import { test } from 'node:test'; +import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; +import { SessionLocalAttachmentRecovery } from '../session-local-attachment-recovery.js'; +import { SessionWorkspaceStateStore } from '../session-workspace-state-store.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; +import type { SessionWorkspaceDraft, SessionWorkspaceSnapshot } from '../../shared/session-workspace-state.js'; + +const owner = { senderId: 11, partition: 'authority-a', scope: { hostId: 'host-a', targetEpoch: 'epoch-a' } }; +const key = (id: string) => desktopSessionKey({ hostId: owner.scope.hostId, sessionId: id }); +const emptyDraft = (id = 'a'): SessionWorkspaceDraft => ({ sessionId: key(id), text: '', references: [], quotes: [], attachments: [], directoryReferences: [] }); +const snapshot = (...drafts: SessionWorkspaceDraft[]): SessionWorkspaceSnapshot => ({ capturedAt: Date.now(), drafts, sideConversations: [] }); +function fixture(t: { after(fn: () => void): void }, now: () => number = Date.now) { + const dir = mkdtempSync(join(tmpdir(), 'maka-workspace-state-')); + const approvals = createAttachmentApprovalRegistry({ now }); + const recovery = new SessionLocalAttachmentRecovery(now); + const store = new SessionWorkspaceStateStore({ approvals, recovery }); + t.after(() => { store.close(); rmSync(dir, { recursive: true, force: true }); }); + return { dir, store, approvals, recovery }; +} + +test('hands off the complete group without replacing another session or crossing authority boundaries', async (t) => { + const { store } = fixture(t); + const a: SessionWorkspaceDraft = { + ...emptyDraft(), text: 'draft @file.ts', + references: [{ kind: 'workspace_file', value: '@file.ts', label: 'file.ts', start: 6 }], + quotes: [{ text: 'an excerpt', sourceTurnId: 'turn-1' }], + directoryReferences: [{ hostId: 'host-a', path: '/project' }], + replacesMessageId: 'local-message', + revision: { sourceSessionId: key('a'), draftSessionId: key('a'), sourceTurnId: 'turn-2', copyId: 'copy', copyPhase: 'reserved', previousComposerText: 'previous', originalQuotes: [], originalAttachments: [] }, + }; + await store.save(owner, snapshot({ ...emptyDraft('unrelated'), text: 'keep me' })); + await store.save(owner, { ...snapshot(a, { ...emptyDraft('child'), text: 'child draft' }), readingAnchors: { [key('a')]: { turnId: 'turn-1' } } }); + const loaded = store.load({ ...owner, senderId: 22 }, [key('a'), key('child')]); + assert.deepEqual(loaded.drafts, [a, { ...emptyDraft('child'), text: 'child draft' }]); + assert.deepEqual(loaded.readingAnchors, { [key('a')]: { turnId: 'turn-1' } }); + assert.equal(store.load(owner, [key('unrelated')]).drafts[0]!.text, 'keep me'); + assert.equal(store.load({ ...owner, partition: 'different-authority' }, [key('a')]).drafts[0]!.text, ''); + assert.throws(() => store.load({ ...owner, scope: { ...owner.scope, hostId: 'other-host' } }, [key('a')]), /different Host/); +}); + +test('a new process-lifetime store starts empty and shutdown invalidates pending handoffs', async (t) => { + const { store, approvals, recovery } = fixture(t); + await store.save(owner, { + ...snapshot({ ...emptyDraft(), text: 'only for this app lifetime', attachments: [{ type: 'upload', name: 'draft.txt', mimeType: 'text/plain', base64: Buffer.from('draft bytes').toString('base64') }] }), + readingAnchors: { [key('a')]: { turnId: 'turn-1' } }, + }); + const pending = store.save(owner, snapshot({ ...emptyDraft(), text: 'closing' })); + store.close(); + await assert.rejects(pending, /authorization was cleared/); + await assert.rejects(store.save(owner, snapshot(emptyDraft())), /store is closed/); + const restarted = new SessionWorkspaceStateStore({ approvals, recovery }); + t.after(() => restarted.close()); + assert.deepEqual(restarted.load(owner, [key('a')]), { + capturedAt: 0, drafts: [emptyDraft()], sideConversations: [], workbar: {}, readingAnchors: {}, + }); +}); + +test('destination edits do not mutate the snapshot needed for transfer rollback', async (t) => { + const { store } = fixture(t); + const draft = { ...emptyDraft(), text: 'original', quotes: [{ text: 'quoted text' }] }; + await store.save(owner, { ...snapshot(draft), readingAnchors: { [key('a')]: { turnId: 'turn-1' } } }); + const destination = store.load({ ...owner, senderId: 22 }, [key('a')]); + Object.assign(destination.drafts[0]!.quotes[0]!, { text: 'changed' }); + Object.assign(destination.readingAnchors![key('a')]!, { turnId: 'changed' }); + assert.deepEqual(store.load(owner, [key('a')]).drafts, [draft]); + assert.deepEqual(store.load(owner, [key('a')]).readingAnchors, { [key('a')]: { turnId: 'turn-1' } }); +}); + +test('picked and recovery attachments are saved by main, reissued for the destination, and retain source send leases', async (t) => { + const { dir, store, approvals, recovery } = fixture(t); + const file = join(dir, 'picked.txt'); + writeFileSync(file, 'picked bytes'); + const picked = approvals.issueApprovals(owner.senderId, [{ path: file, name: 'picked.txt', size: 12 }])[0]!; + const recoveryOwner = { ...owner, sessionId: 'a' }; + const recovered = recovery.issue(recoveryOwner, [{ name: 'recovered.txt', mimeType: 'text/plain', content: Buffer.from('recovery bytes') }])[0]!; + const sending = recovery.prepare(recoveryOwner, [recovered]); + const draft: SessionWorkspaceDraft = { ...emptyDraft(), attachments: [ + { type: 'approval', ...picked }, { type: 'approval', ...recovered }, + { type: 'upload', name: 'paste.txt', mimeType: 'text/plain', base64: Buffer.from('paste bytes').toString('base64') }, + ] }; + await store.save(owner, snapshot(draft)); + assert.ok(approvals.peekApproval(owner.senderId, picked.approvalId), 'preparing a handoff does not consume a picker approval'); + const target = { ...owner, senderId: 22, scope: { ...owner.scope, targetEpoch: 'new-epoch' } }; + const loaded = store.load(target, [key('a')]); + const issued = loaded.drafts[0]!.attachments; + assert.equal(issued.length, 3); + assert.ok(issued.every((item) => item.type === 'approval' && item.approvalId.startsWith('local-recovery:'))); + assert.ok(!JSON.stringify(loaded).includes('base64')); + assert.ok(!JSON.stringify(loaded).includes(file)); + assert.ok(!JSON.stringify(loaded).includes(picked.approvalId)); + recovery.release(owner.senderId, [recovered.approvalId]); + assert.equal(sending.commit(() => 'source send admitted'), 'source send admitted'); + sending.dispose(); + approvals.clearSender(owner.senderId); + const restoredOwner = { ...target, sessionId: 'a' }; + const prepared = recovery.prepare(restoredOwner, issued); + assert.deepEqual(prepared.items.map((item) => Buffer.from((item as { base64: string }).base64, 'base64').toString()), ['picked bytes', 'recovery bytes', 'paste bytes']); + assert.throws(() => recovery.prepare({ ...restoredOwner, senderId: 11 }, issued), { code: 'source_expired' }); + prepared.dispose(); +}); + +test('forged attachments fail without replacing a previously saved draft', async (t) => { + const { store } = fixture(t); + await store.save(owner, snapshot({ ...emptyDraft(), text: 'saved' })); + await assert.rejects(store.save(owner, snapshot({ ...emptyDraft(), text: 'forged', attachments: [{ type: 'approval', approvalId: 'forged', name: 'secret', size: 3 }] }))); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, 'saved'); +}); + +test('releasing abandoned large restorations keeps repeated window transfers below the recovery byte budget', async (t) => { + const { store, recovery } = fixture(t); + const original = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'large.bin', mimeType: 'application/octet-stream', content: new Uint8Array(45 * 1024 * 1024) }])[0]!; + await store.save(owner, snapshot({ ...emptyDraft(), attachments: [{ type: 'approval', ...original }] })); + recovery.releaseSender(owner.senderId); + const target = { ...owner, senderId: 22 }; + for (let attempt = 0; attempt < 4; attempt++) { + const source = store.load(owner, [key('a')]); + const destination = store.load(target, [key('a')]); + assert.equal(destination.drafts[0]!.attachments[0]!.type, 'approval'); + store.releaseSnapshot(target.senderId, destination); + store.releaseSnapshot(owner.senderId, source); + const rollback = store.load(owner, [key('a')]); + const restored = rollback.drafts[0]!.attachments[0]!; + assert.equal(restored.type, 'approval'); + if (restored.type === 'approval') assert.equal(recovery.snapshot({ ...owner, sessionId: 'a' }, restored.approvalId).content.byteLength, 45 * 1024 * 1024); + store.releaseSnapshot(owner.senderId, rollback); + } +}); + +test('snapshot release includes side-conversation approvals and remains sender-scoped', async (t) => { + const { store, recovery } = fixture(t); + const ordinary = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'a', mimeType: 'text/plain', content: Uint8Array.of(1) }])[0]!; + const side = recovery.issue({ ...owner, sessionId: 'side' }, [{ name: 'side', mimeType: 'text/plain', content: Uint8Array.of(2) }])[0]!; + const state = { ...snapshot({ ...emptyDraft(), attachments: [{ type: 'approval' as const, ...ordinary }] }), sideConversations: [{ + panel: { id: 'panel', sourceSessionId: key('a'), quotes: [] }, active: true, hasContent: true, + draft: { ...emptyDraft('side'), attachments: [{ type: 'approval' as const, ...side }] }, + }] }; + store.releaseSnapshot(22, state); + assert.equal(recovery.snapshot({ ...owner, sessionId: 'side' }, side.approvalId).content[0], 2); + store.releaseSnapshot(owner.senderId, state); + for (const [sessionId, approval] of [['a', ordinary], ['side', side]] as const) + assert.throws(() => recovery.snapshot({ ...owner, sessionId }, approval.approvalId), { code: 'source_expired' }); +}); + +test('more than 256 visited sessions preserve both empty and nonempty drafts', async (t) => { + const { store } = fixture(t); + const drafts = Array.from({ length: 257 }, (_, index) => ({ ...emptyDraft(String(index)), text: index % 2 ? `draft ${index}` : '' })); + await store.save(owner, snapshot(...drafts)); + assert.deepEqual(store.load(owner, drafts.map((draft) => draft.sessionId)).drafts, drafts); +}); + +test('a workspace can save three independently valid 45 MiB drafts together', async (t) => { + const { dir, store, approvals } = fixture(t); + const path = join(dir, 'large-draft.bin'); + writeFileSync(path, new Uint8Array(45 * 1024 * 1024)); + const drafts = ['a', 'b', 'c'].map((id) => ({ ...emptyDraft(id), attachments: [{ type: 'approval' as const, + ...approvals.issueApprovals(owner.senderId, [{ path, name: `${id}.bin`, size: 45 * 1024 * 1024 }])[0]!, + }] })); + await store.save(owner, snapshot(...drafts)); + for (const draft of drafts) { + const restored = store.load(owner, [draft.sessionId]); + const attachment = restored.drafts[0]!.attachments[0]!; + assert.equal(attachment.type, 'approval'); + if (attachment.type === 'approval') assert.equal(attachment.size, 45 * 1024 * 1024); + store.releaseSnapshot(owner.senderId, restored); + } +}); + +test('a handoff snapshot does not extend source approval or recovery token lifetime', async (t) => { + let now = 0; + const { dir, store, approvals, recovery } = fixture(t, () => now); + const path = join(dir, 'long-lived.txt'); + writeFileSync(path, 'original'); + const picked = approvals.issueApprovals(owner.senderId, [{ path, name: 'long-lived.txt', size: 8 }])[0]!; + const recovered = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'recovered.txt', mimeType: 'text/plain', content: Buffer.from('recovered') }])[0]!; + const draft = { ...emptyDraft(), attachments: [{ type: 'approval' as const, ...picked }, { type: 'approval' as const, ...recovered }] }; + await store.save(owner, snapshot(draft)); + rmSync(path); + now = 31 * 60 * 1000; + assert.equal(approvals.peekApproval(owner.senderId, picked.approvalId), null); + assert.throws(() => recovery.snapshot({ ...owner, sessionId: 'a' }, recovered.approvalId), { code: 'source_expired' }); + for (const other of [ + { ...owner, senderId: 22 }, { ...owner, partition: 'other' }, + { ...owner, scope: { ...owner.scope, targetEpoch: 'other' } }, + ]) await assert.rejects(store.save(other, snapshot(draft)), { code: 'source_expired' }); + await assert.rejects(store.save(owner, snapshot({ ...draft, sessionId: key('b') })), { code: 'source_expired' }); + await assert.rejects(store.save(owner, snapshot({ ...draft, text: 'edited after expiry' })), { code: 'source_expired' }); + const target = { ...owner, senderId: 22 }; + const loaded = store.load(target, [key('a')]); + assert.equal(loaded.drafts[0]!.text, '', 'failed preparation preserves the last transferable snapshot'); + const prepared = recovery.prepare({ ...target, sessionId: 'a' }, loaded.drafts[0]!.attachments); + assert.deepEqual(prepared.items.map((item) => Buffer.from((item as { base64: string }).base64, 'base64').toString()), ['original', 'recovered']); + prepared.dispose(); + store.forget(owner.partition, [key('a')], loaded.capturedAt); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.attachments.length, 0); + await assert.rejects(store.save(owner, snapshot(draft)), { code: 'source_expired' }, 'adoption does not retain a second attachment recovery path'); +}); + +test('adoption forgets transferred state while preserving newer captures and future handoffs', async (t) => { + const { store } = fixture(t); + await store.save(owner, { ...snapshot({ ...emptyDraft(), text: 'older' }), capturedAt: 10 }); + const earlier = store.load({ ...owner, senderId: 22 }, [key('a')]); + await store.save(owner, { ...snapshot({ ...emptyDraft(), text: 'newer' }), capturedAt: 20 }); + store.forget(owner.partition, [key('a')], earlier.capturedAt); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, 'newer'); + store.forget(owner.partition, [key('a')], 20); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, ''); + await store.save(owner, { ...snapshot({ ...emptyDraft(), text: 'next handoff' }), capturedAt: 30 }); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, 'next handoff', 'adoption is not session deletion'); +}); + +test('only an authorized recovery owner can place bytes in a workspace', async (t) => { + const { store, recovery } = fixture(t); + const issued = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'private', mimeType: 'text/plain', content: Uint8Array.of(1) }])[0]!; + const draft = { ...emptyDraft(), attachments: [{ type: 'approval' as const, ...issued }] }; + for (const other of [ + { ...owner, senderId: 22 }, + { ...owner, partition: 'other' }, + { ...owner, scope: { ...owner.scope, targetEpoch: 'other' } }, + ]) await assert.rejects(store.save(other, snapshot(draft))); + await assert.rejects(store.save(owner, snapshot({ ...draft, sessionId: key('b') }))); + await store.save(owner, snapshot(draft)); + assert.equal(store.load({ ...owner, senderId: 22 }, [key('a')]).drafts[0]!.attachments.length, 1); +}); + +test('side conversations keep their panel, fork, own-turn boundary and draft', async (t) => { + const { store } = fixture(t); + const state: SessionWorkspaceSnapshot = { + ...snapshot(emptyDraft()), sideConversations: [{ + panel: { id: 'panel', sourceSessionId: key('a'), quotes: [{ id: 'quote', value: { text: 'question' } }] }, + active: true, hasContent: true, + companion: { sessionId: key('side'), ownTurnIds: ['own-turn'], queueOwnedMessageIds: ['queued-message'] }, + draft: { ...emptyDraft('side'), text: 'follow-up question' }, + }], + }; + await store.save(owner, state); + assert.deepEqual(store.load({ ...owner, senderId: 22 }, [key('a')]).sideConversations, state.sideConversations); + await store.save(owner, { ...snapshot(emptyDraft()), capturedAt: state.capturedAt + 1 }); + assert.deepEqual(store.load(owner, [key('a')]).sideConversations, []); +}); + +test('a stale checkpoint cannot overwrite a newer final snapshot', async (t) => { + const { store } = fixture(t); + await store.save(owner, { ...snapshot({ ...emptyDraft(), text: 'final' }), capturedAt: 20 }); + await store.save(owner, { ...snapshot({ ...emptyDraft(), text: 'older checkpoint' }), capturedAt: 10 }); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, 'final'); +}); + +test('revocation blocks already queued saves and clears only the revoked partition', async (t) => { + const { store } = fixture(t); + await store.save({ ...owner, partition: 'other' }, snapshot({ ...emptyDraft(), text: 'other authority' })); + const pending = store.save(owner, snapshot({ ...emptyDraft(), text: 'queued before revoke' })); + store.clear(owner.partition); + await assert.rejects(pending, /authorization was cleared/); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, ''); + assert.equal(store.load({ ...owner, partition: 'other' }, [key('a')]).drafts[0]!.text, 'other authority'); +}); + + +test('confirmed deletion ignores pending and late checkpoints without blocking unrelated draft saves', async (t) => { + const { store } = fixture(t); + await store.save(owner, snapshot({ ...emptyDraft(), text: 'before deletion' })); + const saving = store.save(owner, snapshot({ ...emptyDraft(), text: 'pending' }, { ...emptyDraft('b'), text: 'B stays' })); + store.delete(owner.partition, [key('a')]); + await saving; + await store.save(owner, snapshot({ ...emptyDraft(), text: 'late source checkpoint' })); + const loaded = store.load(owner, [key('a'), key('b')]); + assert.equal(loaded.drafts[0]!.text, ''); + assert.equal(loaded.drafts[1]!.text, 'B stays'); +}); + +test('deletion during a picker read ignores its revoked approval and still saves other sessions', async (t) => { + const { dir, store, approvals } = fixture(t); + const path = join(dir, 'pending.txt'); + writeFileSync(path, 'pending'); + const picked = approvals.issueApprovals(owner.senderId, [{ path, name: 'pending.txt', size: 7 }])[0]!; + const peek = approvals.peekApproval; + let deleted = false; + approvals.peekApproval = (senderId, approvalId) => { + const approved = peek(senderId, approvalId); + if (!deleted) { + deleted = true; + store.delete(owner.partition, [key('a')]); + approvals.clearSender(owner.senderId); + } + return approved; + }; + await store.save(owner, snapshot({ ...emptyDraft(), attachments: [{ type: 'approval', ...picked }] }, { ...emptyDraft('b'), text: 'unrelated draft' })); + const loaded = store.load(owner, [key('a'), key('b')]); + assert.ok(deleted); + assert.equal(loaded.drafts[0]!.attachments.length, 0); + assert.equal(loaded.drafts[1]!.text, 'unrelated draft'); +}); + +test('a failed multi-Host window capture releases earlier partitions instead of replaying stale drafts', async (t) => { + const { store } = fixture(t); + const other = { senderId: owner.senderId, partition: 'authority-b', scope: { hostId: 'host-b', targetEpoch: 'epoch-b' } }; + const otherKey = desktopSessionKey({ hostId: 'host-b', sessionId: 'b' }); + const first = snapshot({ ...emptyDraft(), text: 'still owned by the renderer' }); + const invalid = snapshot({ ...emptyDraft(), sessionId: otherKey, attachments: [{ type: 'approval', approvalId: 'expired', name: 'draft.txt', size: 1 }] }); + await store.save(owner, snapshot({ ...emptyDraft('unrelated'), text: 'closed window keeps this' })); + await assert.rejects(store.saveAll([{ owner, snapshot: first }, { owner: other, snapshot: invalid }])); + assert.equal(store.load(owner, [key('a')]).drafts[0]!.text, ''); + assert.equal(store.load(owner, [key('unrelated')]).drafts[0]!.text, 'closed window keeps this'); + assert.equal(store.load(other, [otherKey]).drafts[0]!.text, ''); +}); + + +test('ordinary close retains text and valid attachments while reporting each expired or missing source', async (t) => { + let now = 0; + const { store, approvals, dir, recovery } = fixture(t, () => now); + const expired = approvals.issueApprovals(owner.senderId, [{ path: join(dir, 'expired.txt'), name: 'expired.txt', size: 1 }])[0]!; + now = 31 * 60 * 1000; + const missing = approvals.issueApprovals(owner.senderId, [{ path: join(dir, 'missing.txt'), name: 'missing.txt', size: 1 }])[0]!; + const valid = recovery.issue({ ...owner, sessionId: 'a' }, [{ name: 'valid.txt', mimeType: 'text/plain', content: Buffer.from('valid') }])[0]!; + const state = snapshot({ ...emptyDraft(), text: 'keep unsent text', attachments: [ + { type: 'approval', ...expired }, { type: 'approval', ...missing }, { type: 'approval', ...valid }, + ] }); + await assert.rejects(store.save(owner, state)); + const losses: string[] = []; + await store.save(owner, state, (loss) => losses.push(loss)); + const restored = store.load({ ...owner, senderId: 22 }, [key('a')]); + assert.equal(restored.drafts[0]!.text, 'keep unsent text'); + assert.equal(restored.drafts[0]!.attachments.length, 1); + assert.equal((restored.drafts[0]!.attachments[0] as { name: string }).name, 'valid.txt'); + assert.equal(losses.length, 2); + assert.match(losses[0]!, /expired.txt/); + assert.match(losses[1]!, /missing.txt/); + assert.equal(state.drafts[0]!.attachments.length, 3, 'cancel leaves the original renderer contents intact'); +}); + + +for (const closing of [false, true]) { + test(`${closing ? 'close retains valid state' : 'handoff rolls back custody'} when the second side conversation cannot be adopted`, async (t) => { + const { store, dir } = fixture(t); + const removed: string[] = []; + const authority = createSessionCopyCleanupAuthority({ workspaceRoot: dir, removeSession: async (id) => { removed.push(id); } }); + for (const id of ['fork-a', 'fork-b']) await authority.ownCreation({ sessionId: id, sourceSessionId: 'a', kind: 'branch', + intent: 'side_conversation', ownerId: 'web-contents:11' }, async () => {}); + await authority.cleanup('fork-b'); + removed.length = 0; + const state: SessionWorkspaceSnapshot = { + ...snapshot({ ...emptyDraft(), text: 'Unsent A' }), + sideConversations: ['fork-a', 'fork-b'].map((id) => ({ + panel: { id, sourceSessionId: key('a'), quotes: [] }, hasContent: true, active: false, + companion: { sessionId: key(id), ownTurnIds: [], queueOwnedMessageIds: [] }, + draft: { ...emptyDraft(id), text: `Unsent ${id}` }, + })), + }; + const resolve = (side: SessionWorkspaceSnapshot['sideConversations'][number]) => ({ authority, sourceSessionId: 'a', sessionId: side.panel.id }); + await store.save(owner, state); + const losses: string[] = []; + const transfer = adoptWorkspaceCopies(state, { + ownerId: 'workspace', previousOwnerId: 'web-contents:11', resolve, + ...(closing ? { unavailable: (side: SessionWorkspaceSnapshot['sideConversations'][number]) => { + losses.push(side.panel.id); + store.releaseUnavailableCompanion(owner.partition, side.panel.sourceSessionId, side.panel.id, state.capturedAt); + } } : {}), + }); + if (!closing) { + await assert.rejects(transfer, /no longer available/); + store.forget(owner.partition, [key('a')], state.capturedAt); + await authority.abandonOwner('web-contents:11'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(removed, ['fork-a'], 'failed handoff restores cleanup to the source'); + assert.equal(state.drafts[0]!.text, 'Unsent A'); + return; + } + await transfer; + await authority.abandonOwner('web-contents:11'); + assert.deepEqual(removed, [], 'valid copy remains with the retained workspace'); + assert.deepEqual(losses, ['fork-b']); + const restored = store.load({ ...owner, senderId: 22 }, [key('a')]); + assert.equal(restored.drafts[0]!.text, 'Unsent A'); + assert.equal(restored.sideConversations[0]!.companion?.sessionId, key('fork-a')); + assert.equal(restored.sideConversations[1]!.companion, undefined); + assert.equal(restored.sideConversations[1]!.draft.text, 'Unsent fork-b'); + assert.equal(restored.sideConversations[1]!.draft.sessionId, key('a')); + await adoptWorkspaceCopies(restored, { ownerId: 'web-contents:22', previousOwnerId: 'workspace', resolve }); + await authority.abandonOwner('web-contents:22'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(removed, ['fork-a']); + }); +} diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 6656247033c..5af4f7b6553 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -421,6 +421,51 @@ 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 layout = controller().commands.captureWorkspace(['a', 'b']); + 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); + assert.deepEqual(layout.a!.panels, layout.b!.panels); + assert.equal(layout.a!.rightWidth, layout.b!.rightWidth); + }); + + it('captures without changing B and restores the transferred layout only once', 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')); + const transferred = controller().commands.captureWorkspace(['a']); + await act(async () => show('b')); + await act(async () => controller().commands.openTool('inspector', 'bottom')); + const bPanels = controller().host.panelsState; + controller().commands.captureWorkspace(['a']); + assert.deepEqual(controller().host.panelsState, bPanels); + assert.equal(controller().host.activeId, 'b'); + assert.equal(controller().host.bottomOpen, true); + await act(async () => show('a')); + await act(async () => controller().commands.restoreWorkspace(transferred)); + assert.deepEqual(controller().host.panelsState, transferred.a!.panels); + assert.equal(controller().host.bottomOpen, false); + await act(async () => show('b')); + await act(async () => controller().commands.openTool('browser')); + await act(async () => show('a')); + assert.equal(controller().host.panelsState.right.activeTabId, 'workbar:browser'); + }); + function fakeRail(width = 260) { const listeners = new Set<() => void>(); return { diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index c95887a5f76..20eaa26b4a3 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -71,6 +71,7 @@ function createBridgeRecorder(answerOverrides: Record = {}): { bridge: { gitReview: domain('gitReview'), sessions: domain('sessions'), + sessionLocal: domain('sessionLocal'), shellRuns: domain('shellRuns'), browser: domain('browser'), artifacts: domain('artifacts'), @@ -241,6 +242,7 @@ describe('createDesktopWorkbarServices', () => { await services.attachments.pickFiles(); await services.attachments.previewApproval('approval'); + await services.attachments.releaseRecoveryAttachments(['local-recovery:side']); await services.sideChat.listSessions(); await services.sideChat.listTurns('s'); @@ -321,6 +323,7 @@ describe('createDesktopWorkbarServices', () => { 'inspector.subscribeUsageChanges', 'attachments.pickFiles', 'attachments.previewApproval', + 'sessionLocal.releaseRecoveryAttachments', 'sessions.list', 'sessions.listTurns', 'sessions.branchFromTurn', diff --git a/apps/desktop/src/main/__tests__/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..0907aa18e97 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-window-creation.test.ts @@ -0,0 +1,368 @@ +/* + * 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 snapshot = { capturedAt: 1, drafts: [], sideConversations: [] }; + const coordinator = createSessionWindowCoordinator({ + windows: h.controller, signal: new AbortController().signal, ensureMainWindow: async () => {}, + resolveGroup: async (id) => ({ hostId: 'host', groupId: id, sessionIds: [id] }), + saveSnapshot: async () => {}, saveForClose: async () => ({ snapshot, losses: [] }), confirmClose: async () => false, + adoptSnapshot: async () => {}, forgetSnapshot: () => {}, loadSnapshot: async () => snapshot, + releaseSnapshot: () => {}, 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: { snapshot } }))); + 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/attachment-preview.ts b/apps/desktop/src/main/attachment-preview.ts index 5128a6516ec..54ddbe377f4 100644 --- a/apps/desktop/src/main/attachment-preview.ts +++ b/apps/desktop/src/main/attachment-preview.ts @@ -20,6 +20,7 @@ import { Buffer } from 'node:buffer'; import { MAX_ATTACHMENT_BYTES, sniffAttachmentMimeType } from '@maka/core/attachments'; import type { AttachmentApprovalRegistry } from './attachment-approval.js'; +import type { SessionLocalAttachmentRecovery, RecoveryOwner } from './session-local-attachment-recovery.js'; export type AttachmentPreviewResult = | { ok: true; base64: string; mimeType: string } @@ -91,6 +92,33 @@ export async function loadApprovalPreview(input: { } } +/** Recovery data is only exposed as a freshly encoded thumbnail, never original bytes. */ +export async function loadRecoveryApprovalPreview(input: { + senderId: number; + approvalId: string; + recovery: SessionLocalAttachmentRecovery; + authorizeRecovery(owner: RecoveryOwner): boolean; + renderPreview: AttachmentPreviewDeps['renderPreview']; +}): Promise { + const owner = input.recovery.ownerForPreview(input.senderId, input.approvalId); + if (!owner || !input.authorizeRecovery(owner)) return { ok: false, reason: 'not_found' }; + try { + const { content } = input.recovery.snapshot(owner, input.approvalId); + if (content.byteLength > MAX_ATTACHMENT_BYTES) return { ok: false, reason: 'unreadable' }; + if (!sniffAttachmentMimeType(content)?.startsWith('image/')) return { ok: false, reason: 'not_image' }; + const preview = await input.renderPreview(content); + // A window can close or its Host authorization can be revoked while the + // decoder runs. Never return an already revoked snapshot to that renderer. + if (!input.recovery.ownerForPreview(input.senderId, input.approvalId) || !input.authorizeRecovery(owner)) + return { ok: false, reason: 'not_found' }; + if (!preview || preview.mimeType !== 'image/png' || preview.bytes.byteLength > INLINE_PREVIEW_FALLBACK_MAX_BYTES) + return { ok: false, reason: 'unreadable' }; + return { ok: true, base64: Buffer.from(preview.bytes).toString('base64'), mimeType: preview.mimeType }; + } catch { + return { ok: false, reason: 'unreadable' }; + } +} + /** * Register the client-owned attachment preview boundary used by Runtime Host * Desktop. Preview admission and byte validation remain local because the @@ -104,9 +132,13 @@ export function registerAttachmentPreviewIpc(input: { ): void; }; approvals: AttachmentApprovalRegistry; + recovery: SessionLocalAttachmentRecovery; + authorizeRecovery(owner: RecoveryOwner): boolean; } & AttachmentPreviewDeps): void { input.ipcMain.handle('attachments:previewApproval', (event, approvalId) => - loadApprovalPreview({ + typeof approvalId === 'string' && approvalId.startsWith('local-recovery:') + ? loadRecoveryApprovalPreview({ senderId: event.sender.id, approvalId, recovery: input.recovery, authorizeRecovery: input.authorizeRecovery, renderPreview: input.renderPreview }) + : loadApprovalPreview({ senderId: event.sender.id, approvalId, approvals: input.approvals, diff --git a/apps/desktop/src/main/browser-ipc-main.ts b/apps/desktop/src/main/browser-ipc-main.ts index e59bca71efa..e5cb6c1b903 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; } @@ -96,9 +102,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 isWorkHubCoordinationSessionId(ref.sessionId) && deps.isHostActive(ref); }; const revokeHiddenActions = (): void => { @@ -153,10 +159,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 +197,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 +268,8 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { selection: RendererSelection, sessionId: string | null, ): void => { + if (sessionId && deps.mainWindowController.isWorkspaceRenderer(contents) && + !deps.canUseSession(contents, desktopSessionKey(parseDesktopSessionResourceKey(sessionId)))) return; const previousOwner = sessionId ? ownerForSession(sessionId) : undefined; if (previousOwner && previousOwner !== contents && (!isCoordination(sessionId!) || deps.mainWindowController.isMainRenderer(previousOwner) === deps.mainWindowController.isMainRenderer(contents))) return; @@ -260,7 +283,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 +298,8 @@ export function registerBrowserIpc(deps: BrowserIpcDeps): BrowserIpcController { const contents = ownedRenderer(event); if (!contents) return undefined; const sessionId = requireBrowserTarget(scope, target); - return sessionId && browserParentForRenderer(contents) && + return sessionId && (!deps.mainWindowController.isWorkspaceRenderer(contents) || + deps.canUseSession(contents, desktopSessionKey(parseDesktopSessionResourceKey(sessionId)))) && browserParentForRenderer(contents) && selections.get(contents)?.sessionId === sessionId ? sessionId : undefined; @@ -366,6 +390,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/computer-use/pip-electron.ts b/apps/desktop/src/main/computer-use/pip-electron.ts index 67614f1d9d4..eb1d0896162 100644 --- a/apps/desktop/src/main/computer-use/pip-electron.ts +++ b/apps/desktop/src/main/computer-use/pip-electron.ts @@ -75,6 +75,7 @@ export interface PipWindowLike { * the controls cannot be driven by any other renderer in the app. */ onMessage(cb: (channel: string, payload: unknown) => void): void; + setParentWindow(parent: ParentWindowLike | undefined): void; setBounds(bounds: Rectangle): void; getBounds(): Rectangle; showInactive(): void; @@ -262,6 +263,7 @@ export function defaultCreateWindow( w.webContents.ipc.on(channel, (_event, payload) => cb(channel, payload)); } }, + setParentWindow: (parent) => w.setParentWindow(parent as import('electron').BrowserWindow ?? null), setBounds: (bounds) => w.setBounds(bounds), getBounds: () => w.getBounds(), showInactive: () => auxiliaryWindowRegistry.show('pip', w, revealMode), diff --git a/apps/desktop/src/main/computer-use/pip-window.ts b/apps/desktop/src/main/computer-use/pip-window.ts index 4356fd204fe..58eca73b02b 100644 --- a/apps/desktop/src/main/computer-use/pip-window.ts +++ b/apps/desktop/src/main/computer-use/pip-window.ts @@ -91,6 +91,8 @@ export interface ComputerUsePipController { complete(sessionId: string): void; clearForSession(sessionId: string): void; destroyAll(): void; + /** Rebind the live mirror after its session moves to another workspace. */ + refreshOwner(): void; isVisible(): boolean; /** * What the mirror's stop control runs. Same shape the status item uses, and @@ -151,14 +153,14 @@ export interface CreatePipControllerDeps { * falls back to the primary display's corner, as Codex's own * `defaultHostForPositioning` / `fallbackAnchor` do. */ - resolveAnchorRect?: () => Rectangle | undefined; + resolveAnchorRect?: (sessionId: string) => Rectangle | undefined; /** * Subscribe to app-window geometry changes; return an unsubscribe. * * Only resizes are acted on — see the subscription below for why a move is * deliberately ignored. */ - subscribeAnchorChanges?: (cb: () => void) => () => void; + subscribeAnchorChanges?: (sessionId: string, cb: () => void) => () => void; /** * The app window itself, to make the mirror its child. * @@ -182,7 +184,7 @@ export interface CreatePipControllerDeps { * The comment on the geometry subscription used to say repositioning a small * window was "cheap enough not to debounce". It is cheap. It is also late. */ - resolveParentWindow?: () => ParentWindowLike | undefined; + resolveParentWindow?: (sessionId: string) => ParentWindowLike | undefined; /** * Where the pointer is, in screen points. * @@ -230,7 +232,7 @@ export function createComputerUsePipController( deps: CreatePipControllerDeps = {}, ): ComputerUsePipController { const resolveBounds = - deps.resolveBounds ?? ((aspect: number) => defaultResolveBounds(aspect, deps.resolveAnchorRect?.())); + deps.resolveBounds ?? ((aspect: number) => defaultResolveBounds(aspect, resolveAnchorRect())); const preloadPath = deps.preloadPath ?? join(defaultDistDir(), 'pip-preload.cjs'); const htmlPath = deps.htmlPath ?? join(defaultDistDir(), 'pip.html'); @@ -359,8 +361,12 @@ export function createComputerUsePipController( if (w && !w.isDestroyed()) w.destroy(); } + function resolveAnchorRect(): Rectangle | undefined { + return sessionId ? deps.resolveAnchorRect?.(sessionId) : undefined; + } + function liveParent(): ParentWindowLike | undefined { - const candidate = deps.resolveParentWindow?.(); + const candidate = sessionId ? deps.resolveParentWindow?.(sessionId) : undefined; return candidate && !candidate.isDestroyed() ? candidate : undefined; } @@ -370,7 +376,7 @@ export function createComputerUsePipController( * `defaultHostForPositioning` falls back the same way. */ function hostRect(current: Rectangle): Rect { - return deps.resolveAnchorRect?.() ?? workAreaOf(current); + return resolveAnchorRect() ?? workAreaOf(current); } function workAreaOf(rect: Rectangle): Rect { @@ -596,9 +602,29 @@ export function createComputerUsePipController( } } + function bindAnchor(created: PipWindowLike): void { + unsubscribeAnchor?.(); + unsubscribeAnchor = + deps.subscribeAnchorChanges?.(sessionId!, () => { + if (win !== created || created.isDestroyed()) return; + // A drag outranks the app window: whatever the window is doing, the + // mirror is where the pointer is holding it. + if (drag) return; + const next = resolveAnchorRect(); + if (parented && next && anchorSize && + next.width === anchorSize.width && next.height === anchorSize.height) { + return; + } + anchorSize = next ? { width: next.width, height: next.height } : null; + const current = created.getBounds(); + moveTo(created, restPoint(current), { width: current.width, height: current.height }); + }) ?? null; + } + function ensure(nextSessionId: string): PipWindowLike { if (win && !win.isDestroyed() && sessionId === nextSessionId) return win; if (win) teardown(); + sessionId = nextSessionId; const parent = liveParent(); const bounds = resolveBounds(aspect); const options = pipWindowOptions(bounds, preloadPath, parent); @@ -610,7 +636,7 @@ export function createComputerUsePipController( ready = false; queue = []; parented = Boolean(parent); - const anchor = deps.resolveAnchorRect?.(); + const anchor = resolveAnchorRect(); anchorSize = anchor ? { width: anchor.width, height: anchor.height } : null; created.onMessage((channel, payload) => handleMessage(created, channel, payload)); // Queued until the page loads. A fresh window inherits whichever corner the @@ -640,25 +666,22 @@ export function createComputerUsePipController( // exactly the trailing the mirror used to show during a drag. A resize // leaves the child where it is while the corner moves out from under it, so // that one has to be recomputed. - unsubscribeAnchor = - deps.subscribeAnchorChanges?.(() => { - if (win !== created || created.isDestroyed()) return; - // A drag outranks the app window: whatever the window is doing, the - // mirror is where the pointer is holding it. - if (drag) return; - const next = deps.resolveAnchorRect?.(); - if (parented && next && anchorSize && - next.width === anchorSize.width && next.height === anchorSize.height) { - return; - } - anchorSize = next ? { width: next.width, height: next.height } : null; - const current = created.getBounds(); - moveTo(created, restPoint(current), { width: current.width, height: current.height }); - }) ?? null; + bindAnchor(created); return created; } return { + refreshOwner(): void { + if (!win || win.isDestroyed() || !sessionId) return; + const parent = liveParent(); + win.setParentWindow(parent); + parented = Boolean(parent); + bindAnchor(win); + const anchor = resolveAnchorRect(); + anchorSize = anchor ? { width: anchor.width, height: anchor.height } : null; + const current = win.getBounds(); + moveTo(win, restPoint(current), { width: current.width, height: current.height }); + }, present(input: PipPresentInput): void { if (typeof input.sessionId !== 'string' || input.sessionId.length === 0) return; if (!input.base64) return; diff --git a/apps/desktop/src/main/desktop-native-capability-assembly.ts b/apps/desktop/src/main/desktop-native-capability-assembly.ts index e7729e789c3..cec9d36693e 100644 --- a/apps/desktop/src/main/desktop-native-capability-assembly.ts +++ b/apps/desktop/src/main/desktop-native-capability-assembly.ts @@ -49,11 +49,11 @@ export interface DesktopNativeCapabilityAssemblyDeps { readonly revealMode: WindowRevealMode; readonly locale: Pick; readonly keepSystemAwake?: { hold(reason: string): void; release(reason: string): void }; - readonly mainWindow?: { + readonly sessionWindow?: (sessionId: string) => { windowBounds(): { x: number; y: number; width: number; height: number } | undefined; onWindowGeometryChanged(cb: () => void): () => void; browserWindow(): { isDestroyed(): boolean } | undefined; - }; + } | undefined; } /** Assemble the Desktop-owned capabilities that a Host may call. */ @@ -63,11 +63,11 @@ export function assembleDesktopNativeCapabilities( const browserTools = buildBrowserTools(); const computerUseOverlay = createCursorOverlayController({ revealMode: deps.revealMode }); const computerUsePip = createComputerUsePipController( - deps.mainWindow + deps.sessionWindow ? { - resolveAnchorRect: () => deps.mainWindow?.windowBounds(), - subscribeAnchorChanges: (cb) => deps.mainWindow?.onWindowGeometryChanged(cb) ?? (() => {}), - resolveParentWindow: () => deps.mainWindow?.browserWindow(), + resolveAnchorRect: (sessionId) => deps.sessionWindow?.(sessionId)?.windowBounds(), + subscribeAnchorChanges: (sessionId, cb) => deps.sessionWindow?.(sessionId)?.onWindowGeometryChanged(cb) ?? (() => {}), + resolveParentWindow: (sessionId) => deps.sessionWindow?.(sessionId)?.browserWindow(), cursorPoint: () => screen.getCursorScreenPoint(), workAreaFor: (rect) => screen.getDisplayMatching(rect).workArea, revealMode: deps.revealMode, diff --git a/apps/desktop/src/main/desktop-shell-presentation.ts b/apps/desktop/src/main/desktop-shell-presentation.ts index 91afa20ce36..21f33fa3295 100644 --- a/apps/desktop/src/main/desktop-shell-presentation.ts +++ b/apps/desktop/src/main/desktop-shell-presentation.ts @@ -38,7 +38,7 @@ export function installDesktopShellPresentation( isPackaged: app.isPackaged, dispatch: (command) => { if (deps.mainWindowController.hasOpenWindows()) { - deps.mainWindowController.send('window:command', { id: command }); + deps.mainWindowController.sendCommand('window:command', { id: command }); } else { deps.focusOrCreateWindow(); } diff --git a/apps/desktop/src/main/early-window.ts b/apps/desktop/src/main/early-window.ts index 4684d41a176..4233eddf093 100644 --- a/apps/desktop/src/main/early-window.ts +++ b/apps/desktop/src/main/early-window.ts @@ -85,7 +85,7 @@ export const workspaceRoot = join( // controller reads them lazily so the window never waits on that wiring. export const mainWindowDelegates = { onMainWindowClose: (): void => {}, - onMainWindowClosed: (): void => {}, + onWorkspaceWindowClosed: (): void => {}, }; export const desktopDiagnostics: DesktopDiagnosticsDeps = { @@ -256,7 +256,7 @@ export const mainWindowController = createMainWindowController({ window?.on('restore', settingsRecovery.onWindowReady); }, onClose: () => mainWindowDelegates.onMainWindowClose(), - onClosed: () => mainWindowDelegates.onMainWindowClosed(), + onClosed: () => mainWindowDelegates.onWorkspaceWindowClosed(), onRendererProcessGone: async (details) => { const diagnosticInput = createDesktopMainRendererDiagnosticInput({ title: "Maka main Renderer process exited unexpectedly", @@ -277,7 +277,8 @@ export const mainWindowController = createMainWindowController({ if (await mainWindowController.reloadMainRenderer()) return; if (!mainWindowController.browserWindow()) break; } - app.quit(); + mainWindowController.browserWindow()?.destroy(); + if (!mainWindowController.hasOpenWindows()) app.quit(); }, }); resolveBrowserDialogParent = () => { @@ -291,7 +292,7 @@ export const quitCoordinator = createAppQuitCoordinator({ prepareToQuit: () => bootContext.prepareToQuit?.() ?? Promise.resolve("ready" as const), cleanup: () => bootContext.cleanup?.() ?? Promise.resolve(), focusOrCreateWindow: (signal) => { - if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); + if (mainWindowController.browserWindow()) mainWindowController.focus(); else return mainWindowController.createWindow(signal); }, onPreparationError: (error) => { diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index a7043d9998f..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/notifications-main.ts b/apps/desktop/src/main/notifications-main.ts index f0a4f622ac3..a057a638a66 100644 --- a/apps/desktop/src/main/notifications-main.ts +++ b/apps/desktop/src/main/notifications-main.ts @@ -35,6 +35,8 @@ interface NotificationsDeps { settingsStore: { get(): Promise }; locale: Pick; mainWindowController: MainWindowController; + focusSession: (input: RunNotificationEvent) => void; + isSessionFocused: (input: RunNotificationEvent) => boolean; e2e: boolean; } @@ -47,7 +49,7 @@ export function createRunNotifier(deps: NotificationsDeps): (input: RunNotificat const gate = { enabled: settings.notifications.runComplete, supported, - windowFocused: deps.mainWindowController.isFocused(), + windowFocused: deps.isSessionFocused(input), e2e: deps.e2e, }; if (!shouldRaiseRunNotification(gate)) return; @@ -56,7 +58,7 @@ export function createRunNotifier(deps: NotificationsDeps): (input: RunNotificat input, deps.locale.observe(settings), ); - showNativeNotification(copy, () => deps.mainWindowController.focus()); + showNativeNotification(copy, () => deps.focusSession(input)); app.dock?.bounce('informational'); }); } diff --git a/apps/desktop/src/main/pet-pack-import.ts b/apps/desktop/src/main/pet-pack-import.ts index 4657e400a05..242e0b680bd 100644 --- a/apps/desktop/src/main/pet-pack-import.ts +++ b/apps/desktop/src/main/pet-pack-import.ts @@ -208,11 +208,11 @@ export function registerPetPackIpc(input: { } }, ); - input.ipcMain.handle('pets:importLocalDirectory', async (): Promise => { + input.ipcMain.handle('pets:importLocalDirectory', async (event): Promise => { const selection = await input.mainWindowController.showOpenDialog({ title: nativeFileDialogCopy(await input.resolveLocale()).importCustomPet, properties: ['openDirectory'], - }); + }, event.sender); if (selection.canceled || selection.filePaths.length === 0) { return { ok: false, reason: 'cancelled' }; } diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 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 3df1a5d7727..5394c025581 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -17,11 +17,18 @@ * under the License. */ +import { linkedSubagentParentSessionId } from '@maka/core/session'; +import { isSideConversationSession } from '@maka/core/side-conversation'; +import { adoptWorkspaceCopies } from './session-workspace-custody.js'; +import { bindSessionCopyOwner } from './runtime-host-session-execution-ipc-main.js'; import { resolveDesktopWslHostHandoff } from './runtime-host-wsl-handoff.js'; import { app, autoUpdater as nativeAutoUpdater, type BrowserWindow, + type WebContents, + View, + dialog, clipboard, ipcMain, Menu, @@ -56,6 +63,7 @@ import { loadOrCreateRuntimeHostClientInstanceId, listRuntimeHostWslDistributions, runtimeHostProfileAccess, + projectSessionCatalogSummary, RuntimeHostProfileConnectionError, } from "@maka/runtime-host/client"; import { @@ -272,11 +280,18 @@ import { } from "./startup-context.js"; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; import { + desktopSessionKey, + parseDesktopSessionKey, parseDesktopSessionResourceKey, requireDesktopTargetScope, type DesktopTargetScope, } from "../shared/runtime-host-identity.js"; +import { SessionWindowGroups } from './session-window-group.js'; +import { SessionWorkspaceStateStore } from './session-workspace-state-store.js'; +import { createSessionWindowCoordinator, registerSessionWindowIpc } from './session-window-coordinator.js'; +import type { SessionWorkspaceSnapshot } from '../shared/session-workspace-state.js'; + const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000; bootContext.prepareToQuit = prepareRuntimeHostDesktopQuit; bootContext.cleanup = closeRuntimeHostDesktop; @@ -413,7 +428,10 @@ const native = assembleDesktopNativeCapabilities({ revealMode, locale: desktopLocale, keepSystemAwake, - mainWindow: mainWindowController, + sessionWindow: (id) => { + const owner = sessionWindows.ownerForSession(desktopSessionKey(parseDesktopSessionResourceKey(id))); + return owner ? mainWindowController.registry.forSender(owner)?.controller : mainWindowController.registry.main()?.controller; + }, }); const riveWorkflowTool = buildRiveWorkflowTool(); const completeDesktopInteractionTurn = (sessionId: string): void => { @@ -436,10 +454,6 @@ const permissionOverlay = createPermissionOverlayMain({ resolveLocale: () => desktopLocale.resolve(), revealMode, }); -mainWindowDelegates.onMainWindowClose = () => { - native.computerUseOverlay.destroyAll(); - native.computerUsePip.destroyAll(); -}; const attachmentApprovals = createAttachmentApprovalRegistry(); /** * Dedicated task directories live in the user-owned `~/Maka/tasks` root — @@ -478,6 +492,102 @@ const sessionLocal = new DesktopSessionLocalService(sessionLocalStore, { changed: localSessionChanged, onError: (error) => console.error('[session-local] background synchronization failed:', error), }); +const sessionWorkspaceStore = new SessionWorkspaceStateStore({ + approvals: attachmentApprovals, recovery: sessionLocal.attachmentRecovery, +}); +const sessionWindowGroups = new SessionWindowGroups(); +const sessionWindowLifetime = new AbortController(); + +function workspaceOwner(sender: Pick, hostId: string) { + const state = runtimeHostManager?.entries().find((entry) => entry.hostId === hostId); + if (!state || (state.readiness === 'unavailable' && state.error instanceof RuntimeHostProfileConnectionError && state.error.reason === 'credential_rejected')) { + throw new Error('Session Host authority is unavailable'); + } + return { + senderId: sender.id, + partition: desktopSessionLocalPartition({ profileId: state.target.profile.id, hostId, incarnation: state.target.profileIncarnationId, credential: state.target.credential }), + scope: { hostId, targetEpoch: state.epoch }, + }; +} +async function saveWorkspaceSnapshot(sender: WebContents, snapshot: SessionWorkspaceSnapshot, onLoss?: (message: string) => void): Promise { + const keys = [ + ...snapshot.drafts.map((draft) => draft.sessionId), + ...snapshot.sideConversations.map((side) => side.panel.sourceSessionId), + ...Object.keys(snapshot.workbar ?? {}), ...Object.keys(snapshot.readingAnchors ?? {}), + ]; + const hosts = new Set(keys.map((key) => parseDesktopSessionKey(key).hostId)); + const workspaces = [...hosts].map((hostId) => { + const belongs = (key: string) => parseDesktopSessionKey(key).hostId === hostId; + return { owner: workspaceOwner(sender, hostId), snapshot: { + capturedAt: snapshot.capturedAt, + drafts: snapshot.drafts.filter((draft) => belongs(draft.sessionId)), + sideConversations: snapshot.sideConversations.filter((side) => belongs(side.panel.sourceSessionId)), + workbar: Object.fromEntries(Object.entries(snapshot.workbar ?? {}).filter(([key]) => belongs(key))), + readingAnchors: Object.fromEntries(Object.entries(snapshot.readingAnchors ?? {}).filter(([key]) => belongs(key))), + } }; + }); + await sessionWorkspaceStore.saveAll(workspaces, onLoss); + const unavailable = new Set(); + try { + await moveWorkspaceCopies(snapshot, 'workspace', sender, onLoss ? (side, error) => { + const { hostId } = parseDesktopSessionKey(side.panel.sourceSessionId); + sessionWorkspaceStore.releaseUnavailableCompanion(workspaceOwner(sender, hostId).partition, side.panel.sourceSessionId, side.panel.id, snapshot.capturedAt); + unavailable.add(side.panel.id); + onLoss(`Side conversation history: ${error instanceof Error ? error.message : String(error)}`); + } : undefined); + } catch (error) { forgetWorkspaceSnapshot(sender, snapshot); throw error; } + return { ...snapshot, sideConversations: snapshot.sideConversations.map((side) => { + if (!unavailable.has(side.panel.id)) return side; + const { companion: _companion, ...retained } = side; + return { ...retained, active: false, hasContent: false, draft: { ...side.draft, sessionId: side.panel.sourceSessionId } }; + }) }; +} +async function moveWorkspaceCopies( + snapshot: SessionWorkspaceSnapshot, owner: 'workspace' | WebContents, previousOwner: 'workspace' | WebContents, + unavailable?: (side: SessionWorkspaceSnapshot['sideConversations'][number], error: unknown) => void, +): Promise { + const ownerId = (value: 'workspace' | WebContents) => value === 'workspace' ? value : `web-contents:${value.id}`; + await adoptWorkspaceCopies(snapshot, { + ownerId: ownerId(owner), previousOwnerId: ownerId(previousOwner), unavailable, + resolve: (side) => { + const { hostId, sessionId } = parseDesktopSessionKey(side.companion!.sessionId); + const state = runtimeHostManager?.entries().find((entry) => entry.hostId === hostId); + const authority = state?.readiness === 'ready' ? state.candidate.sessionCopyCleanup : undefined; + if (!authority) throw new Error('Side conversation cleanup authority is unavailable'); + if (owner !== 'workspace') bindSessionCopyOwner(authority, owner, (error) => console.error('[session-copy]', error)); + return { authority, sessionId, sourceSessionId: parseDesktopSessionKey(side.panel.sourceSessionId).sessionId }; + }, + }); +} + +function forgetWorkspaceSnapshot(sender: WebContents, snapshot: SessionWorkspaceSnapshot): void { + const hosts = new Map(); + for (const draft of snapshot.drafts) { + const hostId = parseDesktopSessionKey(draft.sessionId).hostId; + const ids = hosts.get(hostId) ?? []; + ids.push(draft.sessionId); + hosts.set(hostId, ids); + } + for (const [hostId, ids] of hosts) { + sessionWorkspaceStore.forget(workspaceOwner(sender, hostId).partition, ids, snapshot.capturedAt); + } +} +async function loadWorkspaceSnapshot(sender: WebContents, sessionIds: readonly string[]): Promise { + const hosts = new Set(sessionIds.map((key) => parseDesktopSessionKey(key).hostId)); + const snapshots: SessionWorkspaceSnapshot[] = []; + try { + for (const hostId of hosts) snapshots.push(sessionWorkspaceStore.load(workspaceOwner(sender, hostId), sessionIds.filter((key) => parseDesktopSessionKey(key).hostId === hostId))); + } catch (error) { + for (const snapshot of snapshots) sessionWorkspaceStore.releaseSnapshot(sender.id, snapshot); + throw error; + } + return { + capturedAt: Math.max(0, ...snapshots.map((snapshot) => snapshot.capturedAt)), drafts: snapshots.flatMap((snapshot) => snapshot.drafts), + sideConversations: snapshots.flatMap((snapshot) => snapshot.sideConversations), + workbar: Object.assign({}, ...snapshots.map((snapshot) => snapshot.workbar)), + readingAnchors: Object.assign({}, ...snapshots.map((snapshot) => snapshot.readingAnchors)), + }; +} registerDesktopSessionLocalIpc({ ipcMain, service: sessionLocal, approvals: attachmentApprovals, resizeImage: resizeImageForAttachment, changed: localSessionChanged, @@ -804,7 +914,82 @@ 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, []); + }, + saveSnapshot: async (sender, snapshot) => { await saveWorkspaceSnapshot(sender, snapshot); }, + saveForClose: async (sender, snapshot) => { + const losses: string[] = []; + const retained = await saveWorkspaceSnapshot(sender, snapshot, (message) => losses.push(message)); + return { snapshot: retained, losses }; + }, + confirmClose: async (sender, losses) => { + const zh = (await desktopLocale.resolve()).startsWith('zh'); + const options = { + type: 'warning' as const, title: 'Maka', + message: zh ? '部分内容无法保留,仍要关闭窗口吗?' : 'Some content could not be retained. Close the window anyway?', + detail: (zh ? '已尽量保留草稿。以下内容无法保留:\n' : 'Drafts have been retained where possible. The following could not be retained:\n') + losses.join('\n'), + buttons: zh ? ['取消', '仍然关闭'] : ['Cancel', 'Close anyway'], + defaultId: 0, cancelId: 0, + }; + const window = mainWindowController.browserWindowFor(sender); + const result = await (window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)); + return result.response === 1; + }, + forgetSnapshot: forgetWorkspaceSnapshot, + adoptSnapshot: async (sender, snapshot) => { + await moveWorkspaceCopies(snapshot, sender, 'workspace'); + forgetWorkspaceSnapshot(sender, snapshot); + }, + loadSnapshot: loadWorkspaceSnapshot, + releaseSnapshot: (sender, snapshot) => sessionWorkspaceStore.releaseSnapshot(sender.id, snapshot), + transferResources: async ({ group, source, target }) => { + browserIpc.transferSessions(group.sessionIds, source, target); + native.computerUsePip.refreshOwner(); + }, + releaseResources: (sender) => { + sessionLocal.attachmentRecovery.releaseSender(sender.id); + browserIpc.releaseRenderer(sender); + const sessionId = native.computerUsePip.currentSessionId(); + if (sessionId) { + const owner = sessionWindows.ownerForSession(desktopSessionKey(parseDesktopSessionResourceKey(sessionId))); + if (owner === sender || (!owner && mainWindowController.isMainRenderer(sender))) native.computerUsePip.clearForSession(sessionId); + } + }, + onError: (error, sender) => { + console.error('[session-window]', error); + const options = { type: 'error' as const, title: 'Maka', message: error instanceof Error ? error.message : String(error) }; + const window = sender && mainWindowController.browserWindowFor(sender); + void (window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)); + }, +}); +registerSessionWindowIpc(sessionWindows, ipcMain); let workHubEnabled = false; const workHubPresentation = createWorkHubPresentation({ isEnabled: () => workHubEnabled, @@ -839,10 +1024,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, @@ -1010,6 +1195,16 @@ const notifyRun = createRunNotifier({ locale: desktopLocale, mainWindowController, e2e: isE2e, + focusSession: (input) => { + const state = runtimeHostManager?.entries().find((entry) => entry.readiness === 'ready' && entry.candidate.client.hostEpoch === input.hostEpoch); + if (state) void sessionWindows.focusSession(desktopSessionKey({ hostId: state.hostId, sessionId: input.sessionId })).catch(console.error); + }, + isSessionFocused: (input) => { + const state = runtimeHostManager?.entries().find((entry) => entry.readiness === 'ready' && entry.candidate.client.hostEpoch === input.hostEpoch); + if (!state) return false; + const owner = sessionWindows.ownerForSession(desktopSessionKey({ hostId: state.hostId, sessionId: input.sessionId })); + return (owner ? mainWindowController.browserWindowFor(owner) : mainWindowController.browserWindow())?.isFocused() ?? false; + }, }); const sessionCopyOwnerProcessId = randomUUID(); @@ -1048,6 +1243,16 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( ipcMain, workspaceRoot, attachmentApprovals, + recoverAttachmentItems: (senderId, scope, sessionId, items) => { + const owner = workspaceOwner({ id: senderId }, scope.hostId); + if (owner.scope.targetEpoch !== scope.targetEpoch) throw new Error('Attachment Host authority changed'); + return sessionLocal.attachmentRecovery.prepare({ ...owner, sessionId }, items); + }, + transferRecoveryAttachments: (senderId, scope, sourceSessionId, targetSessionId, ids) => { + const owner = workspaceOwner({ id: senderId }, scope.hostId); + if (owner.scope.targetEpoch !== scope.targetEpoch) throw new Error('Attachment Host authority changed'); + sessionLocal.attachmentRecovery.transfer({ ...owner, sessionId: sourceSessionId }, targetSessionId, ids); + }, stat: (path) => import("node:fs/promises").then(({ stat }) => stat(path)), resizeImage: resizeImageForAttachment, mainWindowController, @@ -1199,6 +1404,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) => @@ -1236,7 +1448,7 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( if (localTarget) { sessionLocal.connectionChanged(localTarget); sessionLocalStore.bindAuthority(localTarget.profileId, localTarget.partition); - if (state.readiness === 'unavailable' && state.error instanceof RuntimeHostProfileConnectionError && state.error.reason === 'credential_rejected') sessionLocal.purge(localTarget); + if (state.readiness === 'unavailable' && state.error instanceof RuntimeHostProfileConnectionError && state.error.reason === 'credential_rejected') { sessionLocal.purge(localTarget); sessionWorkspaceStore.clear(localTarget.partition); sessionWindowGroups.forgetHost(state.hostId); } } sessionLocal.wake(); const event = runtimeHostTargetEvent(state); @@ -1280,7 +1492,8 @@ const createLocalRuntimeHostManager = () => createRuntimeHostDesktopManager( }, onTargetRemoved: (state) => { const localTarget = localSessionTarget(state); - if (localTarget) sessionLocal.purge(localTarget); + if (localTarget) { sessionLocal.purge(localTarget); sessionWorkspaceStore.clear(localTarget.partition); } + sessionWindowGroups.forgetHost(state.hostId); mainWindowController.send("runtime-host-profiles:changed", { ...runtimeHostTargetEvent(state), readiness: "unavailable", @@ -2019,7 +2232,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', ); @@ -2028,7 +2241,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] } }; }); @@ -2036,7 +2249,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"); @@ -2053,6 +2266,11 @@ function registerPersistentClientIpc(): void { registerAttachmentPreviewIpc({ ipcMain, approvals: attachmentApprovals, + recovery: sessionLocal.attachmentRecovery, + authorizeRecovery: (owner) => { + try { return sessionLocal.target(owner.scope).partition === owner.partition; } + catch { return false; } + }, readFile: readFileCapped, renderPreview: renderAttachmentPreview, }); @@ -2108,7 +2326,11 @@ 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); + sessionWorkspaceStore.delete(target.partition, [desktopSessionKey({ ...scope, sessionId })]); + } catch { /* A retired target cannot repopulate its cache. */ } } const event: SessionChangedEvent = { reason, @@ -2163,7 +2385,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; } @@ -2172,6 +2396,9 @@ function closeRuntimeHostDesktop(): Promise { } async function disposeRuntimeHostDesktop(): Promise { + sessionWindowLifetime.abort(); + sessionWindows.dispose(); + sessionWorkspaceStore.close(); sessionLocal.close(); powerMonitor.off("resume", wakePeerRecoveryAfterResume); clientSettingsWatcher.stop(); diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 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 ceee3919c1e..c47afd252d9 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -129,6 +129,7 @@ import { type CandidateIpcMain = ReconnectableReadIpcMain & Pick; export interface DesktopRuntimeHostCandidateDeps { + readonly canUseSession?: (senderId: number, scope: DesktopTargetScope, sessionId: string) => boolean | Promise; readonly terminalCloses?: import('./terminal-close-intents.js').TerminalCloseIntents; readonly retireCancelledMessages?: (scope: DesktopTargetScope, sessionId: string, messageIds: readonly string[]) => void; readonly failNotAdmittedMessages?: (scope: DesktopTargetScope, sessionId: string, messageIds: readonly string[]) => void; @@ -136,6 +137,8 @@ export interface DesktopRuntimeHostCandidateDeps { readonly ipcMain: RuntimeHostTargetIpcMain; readonly workspaceRoot: string; readonly attachmentApprovals: AttachmentApprovalRegistry; + readonly recoverAttachmentItems?: (senderId: number, scope: DesktopTargetScope, sessionId: string, items: unknown) => { items: unknown[]; validate(): void; dispose(): void }; + readonly transferRecoveryAttachments?: (senderId: number, scope: DesktopTargetScope, sourceSessionId: string, targetSessionId: string, ids: unknown) => void; readonly stat: (path: string) => Promise<{ size: number }>; readonly resizeImage: (bytes: Uint8Array) => Promise; /** Native file dialogs for moving a Session in or out as a bundle. */ @@ -275,6 +278,7 @@ export interface DesktopRuntimeHostCandidate { submitLocalMessage(input: TurnMessageSubmitInput): Promise; readonly botIncoming: BotIncomingMainService; readonly client: DesktopRuntimeHostClient; + readonly sessionCopyCleanup?: SessionCopyCleanupAuthority; readonly closed: Promise; readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid?: number; @@ -286,6 +290,7 @@ export interface DesktopRuntimeHostCandidate { class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly botIncoming: BotIncomingMainService; readonly client: DesktopRuntimeHostClient; + readonly sessionCopyCleanup?: SessionCopyCleanupAuthority; readonly closed: Promise; readonly hostOwnership: DesktopRuntimeHostOwnership; readonly hostPid: number | undefined; @@ -305,6 +310,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { constructor(input: { client: DesktopRuntimeHostClient; + sessionCopyCleanup?: SessionCopyCleanupAuthority; observer: RuntimeHostSessionObserver; ipc: ScopedIpcMain; botIncoming: BotIncomingMainService; @@ -322,6 +328,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { }) { this.#client = input.client; this.client = input.client; + this.sessionCopyCleanup = input.sessionCopyCleanup; this.#observer = input.observer; this.#ipc = input.ipc; this.#botIncoming = input.botIncoming; @@ -782,6 +789,7 @@ export async function createDesktopRuntimeHostCandidate( { client, terminalCloses, + canUseSession: deps.canUseSession ? (sender, id) => deps.canUseSession!(sender.id, scope, id) : undefined, sessionObserver, emitModeChanged, ...(deps.renderer ? { sendToRenderer } : {}), @@ -955,6 +963,7 @@ export async function createDesktopRuntimeHostCandidate( : {}), emitSessionsChanged, releaseSessionResources: releaseNativeSession, + ...(deps.transferRecoveryAttachments ? { transferRecoveryAttachments: (senderId: number, source: string, target: string, ids: unknown) => deps.transferRecoveryAttachments!(senderId, scope, source, target, ids) } : {}), sessionCopyCleanup, ...(deps.newId ? { newId: deps.newId } : {}), }, @@ -996,6 +1005,8 @@ export async function createDesktopRuntimeHostCandidate( observer: sessionObserver, retireCancelledMessages, attachmentApprovals: deps.attachmentApprovals, + ...(deps.recoverAttachmentItems ? { recoverAttachmentItems: (senderId: number, sessionId: string, items: unknown) => deps.recoverAttachmentItems!(senderId, scope, sessionId, items) } : {}), + ...(deps.transferRecoveryAttachments ? { transferRecoveryAttachments: (senderId: number, source: string, target: string, ids: unknown) => deps.transferRecoveryAttachments!(senderId, scope, source, target, ids) } : {}), emitSessionsChanged, stat: deps.stat, resizeImage: deps.resizeImage, @@ -1039,6 +1050,7 @@ export async function createDesktopRuntimeHostCandidate( ); return new DesktopRuntimeHostCandidateImpl({ client, + ...(sessionCopyCleanup ? { sessionCopyCleanup } : {}), observer: sessionObserver, ipc, botIncoming, 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-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index 1e6e7f86f9a..e3d844b6016 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -17,6 +17,7 @@ * under the License. */ +import { isSideConversationSession } from '@maka/core/side-conversation'; import { isExecutorConfiguration } from '@maka/core/executor-catalog'; import { randomUUID } from 'node:crypto'; import { isCollaborationMode } from '@maka/core/collaboration'; @@ -75,6 +76,7 @@ export interface DesktopHostSessionSummary extends SessionCatalogSummary { } export interface RuntimeHostSessionCatalogIpcDeps { + transferRecoveryAttachments?: (senderId: number, sourceSessionId: string, targetSessionId: string, ids: unknown) => void; client: RuntimeHostSessionCatalogClient; queryExecutors?: (input: import('@maka/runtime-host/protocol').PluginExecutorQueryInput) => Promise; /** Observer state supplements the Host catalog without falling back to the durable header. */ @@ -146,7 +148,19 @@ export function registerRuntimeHostSessionCatalogIpc( ? null : toDesktopHostSessionListSummary(session, deps.runningTurnIds(sessionId)); }); - ipcMain.handle('sessions:cleanupSessionCopy', async (_event, sessionId: string) => { + ipcMain.handle('sessions:cleanupSessionCopy', async (event, sessionId: string, recovery?: unknown) => { + if (recovery !== undefined) { + if (!recovery || typeof recovery !== 'object' || !('sourceSessionId' in recovery) || typeof recovery.sourceSessionId !== 'string' || + !('attachmentIds' in recovery) || !Array.isArray(recovery.attachmentIds) || !deps.transferRecoveryAttachments) + throw new Error('Invalid side conversation recovery attachments'); + const copy = await deps.client.getSession(sessionId); + if (copy && (!isSideConversationSession(copy.labels) || copy.parentSessionId !== recovery.sourceSessionId)) + throw new Error('Recovery attachments belong to another session'); + // Return the exact handles before removing an empty fork. The next fork + // can use them without extending their lifetime or widening their scope. + deps.transferRecoveryAttachments(event.sender.id, copy ? sessionId : recovery.sourceSessionId, + recovery.sourceSessionId, recovery.attachmentIds); + } await deps.sessionCopyCleanup.cleanup(sessionId); pendingCleanup.delete(sessionId); }); 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-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 1c47fd362f9..d454de05508 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -171,6 +171,8 @@ export interface RuntimeHostSessionExecutionIpcDeps { client: RuntimeHostSessionExecutionClient; observer: RuntimeHostSessionObserver; attachmentApprovals: AttachmentApprovalRegistry; + recoverAttachmentItems?: (senderId: number, sessionId: string, items: unknown) => { items: unknown[]; validate(): void; dispose(): void }; + transferRecoveryAttachments?: (senderId: number, sourceSessionId: string, targetSessionId: string, ids: unknown) => void; emitSessionsChanged: ( reason: SessionChangedReason, sessionId?: string, @@ -199,33 +201,41 @@ type MessageAttachmentResult = | { readonly ok: false; readonly reason: 'attachment_blocked'; readonly code: AttachmentIngestBlockedCode }; async function prepareMessageAttachments(input: { - deps: Pick; + deps: Pick; getSenderId: () => number; sessionId: string; retainedAttachments: readonly AttachmentRef[]; attachmentItems: unknown; }): Promise { const attachments = retainedAttachmentsForSession(input.sessionId, input.retainedAttachments); + let recovery: ReturnType> | undefined; try { if (input.attachmentItems !== undefined) { + recovery = input.deps.recoverAttachmentItems?.(input.getSenderId(), input.sessionId, input.attachmentItems); const files = await resolveIngestItems({ senderId: input.getSenderId(), - items: input.attachmentItems, + items: recovery?.items ?? input.attachmentItems, approvals: input.deps.attachmentApprovals, stat: input.deps.stat, }); + recovery?.validate(); attachments.push(...await resolveAttachmentRefs({ files, resizeImage: input.deps.resizeImage, snapshot: ({ name, mimeType, content }) => input.deps.client.ingestAttachment({ sessionId: input.sessionId, name, mimeType, content }), })); + recovery?.validate(); } } catch (error) { if (error instanceof AttachmentIngestBlockedError) { return { ok: false, reason: 'attachment_blocked', code: error.code }; } throw error; + } finally { + // The renderer retains a failed send's draft and releases its approvals on + // success. Only hold the byte snapshot while it is being ingested here. + recovery?.dispose(); } return attachments.length > MAX_ATTACHMENT_COUNT ? { ok: false, reason: 'attachment_blocked', code: 'count_limit' } @@ -323,6 +333,30 @@ async function observationIpcResult( } } +const observedCopyOwners = new WeakMap>(); + +/** A restored panel adopts the same cleanup lease as a newly created panel. */ +export function bindSessionCopyOwner(authority: SessionCopyCleanupAuthority, sender: IpcMainInvokeEvent['sender'], onError: (error: unknown) => void): string { + const owners = observedCopyOwners.get(authority) ?? new Set(); + observedCopyOwners.set(authority, owners); + const ownerId = `web-contents:${sender.id}`; + if (!owners.has(sender.id)) { + owners.add(sender.id); + const abandon = () => { + if (!owners.delete(sender.id)) return; + sender.removeListener('render-process-gone', abandon); + sender.removeListener('destroyed', abandon); + sender.removeListener('did-start-navigation', onNavigation); + void authority.abandonOwner(ownerId).catch(onError); + }; + const onNavigation = (_event: unknown, _url: string, inPlace: boolean, mainFrame: boolean) => { if (mainFrame && !inPlace) abandon(); }; + sender.on('did-start-navigation', onNavigation); + sender.once('render-process-gone', abandon); + sender.once('destroyed', abandon); + } + return ownerId; +} + /** * Project Host-owned Session execution onto the Desktop renderer IPC contract. * The adapter owns client validation and presentation events, never Runtime @@ -332,22 +366,6 @@ export function registerRuntimeHostSessionExecutionIpc( deps: RuntimeHostSessionExecutionIpcDeps, ipcMain: ReconnectableReadIpcMain, ): (sessionId: string) => Promise { - const observedCopyOwners = new Set(); - const bindCopyOwner = (event: IpcMainInvokeEvent): string => { - const ownerId = `web-contents:${event.sender.id}`; - if (!observedCopyOwners.has(ownerId)) { - observedCopyOwners.add(ownerId); - const abandon = () => { - if (!observedCopyOwners.delete(ownerId)) return; - event.sender.removeListener('render-process-gone', abandon); - event.sender.removeListener('destroyed', abandon); - void deps.sessionCopyCleanup.abandonOwner(ownerId).catch(deps.onBackgroundError); - }; - event.sender.once('render-process-gone', abandon); - event.sender.once('destroyed', abandon); - } - return ownerId; - }; const newId = deps.newId ?? randomUUID; const stopSession = createRuntimeHostSessionStop(deps, newId); @@ -804,6 +822,11 @@ export function registerRuntimeHostSessionExecutionIpc( "sessions:branchFromTurn", async (event, sessionId: string, input: unknown) => { const normalized = normalizeRuntimeHostBranchFromTurnInput(input); + const recoveryIds = input && typeof input === 'object' && 'recoveryAttachmentIds' in input + ? input.recoveryAttachmentIds : undefined; + if (recoveryIds !== undefined && (!normalized.sideConversation || !Array.isArray(recoveryIds) || + recoveryIds.length > MAX_ATTACHMENT_COUNT || recoveryIds.some((id) => typeof id !== 'string' || !id.startsWith('local-recovery:')))) + throw new Error('Invalid side conversation recovery attachments'); const createBranch = () => deps.client.copySession("branch", { sourceSessionId: sessionId, @@ -825,7 +848,7 @@ export function registerRuntimeHostSessionExecutionIpc( ? {} : { sourceTurnId: normalized.sourceTurnId }), intent: 'side_conversation', - ownerId: bindCopyOwner(event), + ownerId: bindSessionCopyOwner(deps.sessionCopyCleanup, event.sender, deps.onBackgroundError), }, createBranch, ) @@ -844,6 +867,10 @@ export function registerRuntimeHostSessionExecutionIpc( } throw error; } + if (Array.isArray(recoveryIds) && recoveryIds.length > 0) { + if (!deps.transferRecoveryAttachments) throw new Error('Attachment recovery is unavailable'); + deps.transferRecoveryAttachments(event.sender.id, sessionId, branch.id, recoveryIds); + } if (normalized.name) { branch = await deps.client.updateSessionMetadata(branch.id, { name: normalized.name, diff --git a/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts b/apps/desktop/src/main/runtime-host-shell-runs-ipc-main.ts index d3dfe318baf..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-local-attachment-recovery.ts b/apps/desktop/src/main/session-local-attachment-recovery.ts index 2ad90f368e8..58e97fdf378 100644 --- a/apps/desktop/src/main/session-local-attachment-recovery.ts +++ b/apps/desktop/src/main/session-local-attachment-recovery.ts @@ -23,7 +23,7 @@ import type { DesktopTargetScope } from '../shared/runtime-host-identity.js'; import { ATTACHMENT_APPROVAL_TTL_MS, type IssuedAttachmentApproval } from './attachment-approval.js'; import { MAX_LOCAL_MESSAGE_BYTES } from './session-local-store.js'; -interface RecoveryOwner { +export interface RecoveryOwner { senderId: number; partition: string; scope: DesktopTargetScope; @@ -33,6 +33,8 @@ interface RecoveryEntry { owner: string; senderId: number; partition: string; + scope: DesktopTargetScope; + sessionId: string; issuedAt: number; leases: number; releaseRequested: boolean; @@ -43,7 +45,7 @@ function ownerKey(owner: RecoveryOwner): string { return JSON.stringify([owner.senderId, owner.partition, owner.scope.hostId, owner.scope.targetEpoch, owner.sessionId]); } -/** Main-only byte snapshots. Tokens cannot be redeemed by preview or Host-direct ingest. */ +/** Main-only byte snapshots. Previews are decoded and re-encoded; Host-direct ingest cannot redeem these tokens. */ export class SessionLocalAttachmentRecovery { readonly #entries = new Map(); constructor(private readonly now: () => number = Date.now) {} @@ -66,7 +68,7 @@ export class SessionLocalAttachmentRecovery { return attachments.map((attachment) => { const approvalId = `local-recovery:${randomUUID()}`; this.#entries.set(approvalId, { - owner: ownerKey(owner), senderId: owner.senderId, partition: owner.partition, issuedAt: this.now(), + owner: ownerKey(owner), senderId: owner.senderId, partition: owner.partition, scope: { ...owner.scope }, sessionId: owner.sessionId, issuedAt: this.now(), leases: 0, releaseRequested: false, attachment: { ...attachment, content: new Uint8Array(attachment.content) }, }); @@ -74,6 +76,23 @@ export class SessionLocalAttachmentRecovery { }); } + /** Main-internal lookup: callers must revalidate the current Host authority. */ + ownerForPreview(senderId: number, approvalId: string): RecoveryOwner | undefined { + this.#prune(); + const entry = this.#entries.get(approvalId); + if (!entry || entry.senderId !== senderId || entry.releaseRequested) return undefined; + return { senderId, partition: entry.partition, scope: { ...entry.scope }, sessionId: entry.sessionId }; + } + + /** Main-only copy for durable workspace custody. No lease is consumed or revoked. */ + snapshot(owner: RecoveryOwner, approvalId: string): { name: string; mimeType: string; content: Uint8Array } { + this.#prune(); + const entry = this.#entries.get(approvalId); + if (!entry || entry.owner !== ownerKey(owner) || entry.releaseRequested) + throw new AttachmentIngestBlockedError('source_expired'); + return { ...entry.attachment, content: new Uint8Array(entry.attachment.content) }; + } + /** Cleanup is sender-scoped, not target-scoped: an abandoned old target can still be released. */ release(senderId: number, ids: unknown): void { if (!Array.isArray(ids) || ids.length > 1000 || @@ -89,8 +108,37 @@ export class SessionLocalAttachmentRecovery { } } + /** A closing renderer relinquishes its drafts without revoking an in-flight send. */ + releaseSender(senderId: number): void { + this.release(senderId, [...this.#entries].filter(([, entry]) => entry.senderId === senderId).map(([id]) => id)); + } + + /** Main calls this only after creating the specific side-conversation copy. */ + transfer(owner: RecoveryOwner, targetSessionId: string, ids: unknown): void { + if (!Array.isArray(ids) || ids.length > MAX_ATTACHMENT_COUNT || + ids.some((id) => typeof id !== 'string' || !id.startsWith('local-recovery:') || id.length > 256)) + throw new AttachmentIngestBlockedError('items_invalid'); + if (new Set(ids).size !== ids.length) throw new AttachmentIngestBlockedError('duplicate_source'); + this.#prune(); + const source = ownerKey(owner); + const target = ownerKey({ ...owner, sessionId: targetSessionId }); + const entries = ids.map((id) => { + const entry = this.#entries.get(id); + // A replay of the same acknowledged copy may already have moved these + // exact handles. Neither case grants another window or Session access. + if (!entry || (entry.owner !== source && entry.owner !== target) || entry.releaseRequested || entry.leases) + throw new AttachmentIngestBlockedError('source_expired'); + return entry; + }); + for (const entry of entries) { + entry.owner = target; + entry.sessionId = targetSessionId; + } + } + prepare(owner: RecoveryOwner, items: unknown): { items: unknown[]; + validate(): void; commit(admit: () => T): T; dispose(): void; } { @@ -122,6 +170,10 @@ export class SessionLocalAttachmentRecovery { let disposed = false; return { items: expanded, + validate: () => { + if (disposed) throw new AttachmentIngestBlockedError('source_expired'); + for (const id of ids) requireEntry(id, true); + }, commit: (admit) => { if (disposed) throw new AttachmentIngestBlockedError('source_expired'); // Admission is synchronous (SQLite enqueue): no event-loop turn may diff --git a/apps/desktop/src/main/session-window-coordinator.ts b/apps/desktop/src/main/session-window-coordinator.ts new file mode 100644 index 00000000000..d0566bd54cc --- /dev/null +++ b/apps/desktop/src/main/session-window-coordinator.ts @@ -0,0 +1,553 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import type { IpcMain, WebContents } from 'electron'; +import type { + SessionWindowCommand, SessionWindowCommandReply, SessionWindowCommandResult, + SessionWindowGroup, SessionWindowNavigation, +} from '../shared/session-window-contract.js'; +import type { SessionWorkspaceSnapshot } from '../shared/session-workspace-state.js'; +import type { MainWindowController } from './main-window.js'; +import type { WorkspaceWindowEntry } from './workspace-window-registry.js'; + +type CommandInput = SessionWindowCommand extends infer Command + ? Command extends SessionWindowCommand ? Omit : never : never; + +export interface SessionWindowCoordinatorDeps { + readonly windows: MainWindowController; + readonly signal: AbortSignal; + readonly ensureMainWindow: () => Promise; + readonly resolveGroup: (sessionId: string) => Promise; + readonly saveSnapshot: (sender: WebContents, snapshot: SessionWorkspaceSnapshot) => Promise; + readonly saveForClose: (sender: WebContents, snapshot: SessionWorkspaceSnapshot) => Promise<{ snapshot: SessionWorkspaceSnapshot; losses: readonly string[] }>; + readonly confirmClose: (sender: WebContents, losses: readonly string[]) => Promise; + readonly adoptSnapshot: (sender: WebContents, snapshot: SessionWorkspaceSnapshot) => Promise; + readonly forgetSnapshot: (sender: WebContents, snapshot: SessionWorkspaceSnapshot) => void; + readonly loadSnapshot: (sender: WebContents, sessionIds: readonly string[]) => Promise; + readonly releaseSnapshot: (sender: WebContents, snapshot: SessionWorkspaceSnapshot) => void; + readonly transferResources: (input: { + readonly group: SessionWindowGroup; + readonly source: WebContents | undefined; + readonly target: WebContents; + }) => Promise; + readonly releaseResources: (sender: WebContents) => void | Promise; + readonly isQuitting: () => boolean; + readonly onError: (error: unknown, sender?: WebContents) => void; + readonly commandTimeoutMs?: number; +} + +/** A process-scoped ownership authority; a renderer never chooses its own owner. */ +export function createSessionWindowCoordinator(deps: SessionWindowCoordinatorDeps) { + const registry = deps.windows.registry; + const owners = new Map(); + const groups = new Map(); + const memberGroups = new Map(); + const queues = new Map>(); + let mainPresentation: Promise = Promise.resolve(); + const ready = new Set(); + const selections = new Map(); + const readyWaiters = new Map void>>(); + const pending = new Map void; + reject: (error: Error) => void; + }>(); + const closing = new Set(); + const finalized = new Set(); + const timeoutMs = deps.commandTimeoutMs ?? 20_000; + const groupKey = (group: SessionWindowGroup): string => JSON.stringify([group.hostId, group.groupId]); + const reportError = (error: unknown, sender?: WebContents): void => deps.onError(error, sender); + + function requireWindow(sender: WebContents): WorkspaceWindowEntry { + const entry = registry.forSender(sender); + if (!entry) throw new Error('This operation requires a workspace window'); + return entry; + } + + function rejectWindowRequests(windowId: string): void { + ready.delete(windowId); + for (const 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')); + } + } + + function attachWindow(entry: WorkspaceWindowEntry): void { + const windowId = entry.context.windowId; + const sender = entry.window.webContents; + sender.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => { + if (isMainFrame && !isInPlace) rejectWindowRequests(windowId); + }); + sender.on('render-process-gone', () => { + rejectWindowRequests(windowId); + void Promise.resolve().then(() => deps.releaseResources(sender)).catch((error) => reportError(error, sender)); + }); + entry.window.once('closed', () => { + rejectWindowRequests(windowId); + void Promise.resolve().then(() => deps.releaseResources(sender)).catch((error) => reportError(error, sender)); + for (const [key, owner] of owners) if (owner === windowId) owners.delete(key); + selections.delete(windowId); + finalized.delete(windowId); + }); + entry.window.on('close', (event) => { + if (deps.isQuitting() || finalized.has(windowId) || !ready.has(windowId)) return; + event.preventDefault(); + if (closing.has(windowId)) return; + closing.add(windowId); + let retainedSnapshot: SessionWorkspaceSnapshot | undefined; + void (async () => { + // Only the owning group's queue can touch a child. Other children may + // close while a transfer to Main is still waiting for its renderer. + const key = entry.context.kind === 'session' && memberGroups.get(entry.context.initialSessionId); + await Promise.allSettled(entry.context.kind === 'main' ? [mainPresentation] : key ? [queues.get(key)] : []); + if (entry.window.isDestroyed()) return; + const losses: string[] = []; + try { + const result = await request(entry, { kind: 'flush' }); + if (!result.snapshot) throw new Error('The window did not confirm its workspace'); + await requireSnapshotOwner(sender, result.snapshot); + const retained = await deps.saveForClose(sender, result.snapshot); + losses.push(...retained.losses); + retainedSnapshot = retained.snapshot; + } catch (error) { + losses.push(error instanceof Error ? error.message : String(error)); + } + const confirmed = !losses.length || await deps.confirmClose(sender, losses).catch((error) => { + reportError(error, sender); + return false; + }); + if (!confirmed) { + if (retainedSnapshot) await deps.adoptSnapshot(sender, retainedSnapshot); + await request(entry, { kind: 'resume' }); + return; + } + // Detach only after custody has been saved (or the user accepted loss). + // A stalled renderer must not prevent an explicitly confirmed close. + await request(entry, { kind: 'detach' }).catch(() => undefined); + finalized.add(windowId); + entry.window.close(); + })().catch(async (error) => { + // A failed cancellation rolls copy custody back to the workspace. Keep + // its snapshot until a later successful adoption can release both. + if (!entry.window.isDestroyed()) await request(entry, { kind: 'resume' }).catch(() => undefined); + reportError(error, sender); + }).finally(() => closing.delete(windowId)); + }); + } + for (const entry of registry.entries()) attachWindow(entry); + const unsubscribe = registry.onRegistered(attachWindow); + + async function resolveGroup(sessionId: string): Promise { + const resolved = await deps.resolveGroup(sessionId); + // Keep a known group stable when its root is deleted or temporarily absent. + const knownKeys = new Set(resolved.sessionIds.map((id) => memberGroups.get(id)).filter((id): id is string => !!id)); + const ownKnown = memberGroups.get(sessionId); + const key = ownKnown ?? [...knownKeys].find((id) => owners.has(id)) ?? groupKey(resolved); + const previous = groups.get(key); + const group: SessionWindowGroup = { + ...resolved, + groupId: previous?.groupId ?? resolved.groupId, + sessionIds: [...new Set([...(previous?.sessionIds ?? []), ...resolved.sessionIds])], + }; + const conflicting = [...knownKeys].filter((id) => id !== key && owners.has(id)); + if (conflicting.length) throw new Error('Related sessions are already assigned to different windows'); + groups.set(key, group); + for (const member of group.sessionIds) memberGroups.set(member, key); + return group; + } + + function serial(key: string, operation: () => Promise): Promise { + const prior = queues.get(key) ?? Promise.resolve(); + const next = prior.catch(() => undefined).then(operation); + queues.set(key, next); + void next.finally(() => { if (queues.get(key) === next) queues.delete(key); }).catch(() => undefined); + return next; + } + + // Every transfer touches the one main workspace. Keep its snapshot/restore + // transaction intact across unrelated groups; always acquire group first. + function withMainPresentation(operation: () => Promise): Promise { + const next = mainPresentation.catch(() => undefined).then(operation); + mainPresentation = next; + return next; + } + + async function waitReady(entry: WorkspaceWindowEntry): Promise { + const id = entry.context.windowId; + if (ready.has(id)) return; + await new Promise((resolve, reject) => { + const complete = (error?: Error): void => { clearTimeout(timer); waiters.delete(complete); if (error) reject(error); else resolve(); }; + const waiters = readyWaiters.get(id) ?? new Set<(error?: Error) => void>(); + readyWaiters.set(id, waiters); + waiters.add(complete); + const timer = setTimeout(() => { + waiters.delete(complete); + reject(new Error('The workspace window did not become ready')); + }, timeoutMs); + }); + } + + async function request(entry: WorkspaceWindowEntry, input: CommandInput): Promise { + await waitReady(entry); + if (entry.window.isDestroyed()) throw new Error('The workspace window is unavailable'); + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => complete(new Error(`Workspace ${input.kind} was not confirmed`)), timeoutMs); + const complete = (error?: Error, result?: SessionWindowCommandResult): void => { + clearTimeout(timer); + pending.delete(requestId); + if (error) reject(error); + else resolve(result ?? {}); + }; + pending.set(requestId, { + windowId: entry.context.windowId, + resolve: (result) => complete(undefined, result), + reject: (error) => complete(error), + }); + try { entry.window.webContents.send('session-window:command', { ...input, requestId }); } + catch (error) { complete(error instanceof Error ? error : new Error(String(error))); } + }); + } + + function owner(group: SessionWindowGroup): WorkspaceWindowEntry | undefined { + const id = owners.get(groupKey(group)); + return id ? registry.get(id) : undefined; + } + + async function ensureMain(): Promise { + await deps.ensureMainWindow(); + const entry = registry.main(); + if (!entry) throw new Error('The main window is unavailable'); + await waitReady(entry); + return entry; + } + + async function navigate(entry: WorkspaceWindowEntry, group: SessionWindowGroup, navigation: SessionWindowNavigation): Promise { + assertOpen(entry); + const apply = () => { assertOpen(entry); return navigateNow(entry, group, navigation); }; + return entry.context.kind === 'main' ? withMainPresentation(apply) : apply(); + } + + async function navigateNow(entry: WorkspaceWindowEntry, group: SessionWindowGroup, navigation: SessionWindowNavigation): Promise { + await request(entry, { kind: 'navigate', group, navigation }); + selections.set(entry.context.windowId, navigation); + entry.controller.reveal(); + entry.controller.focus(); + } + + async function requireSnapshotOwner(sender: WebContents, snapshot: SessionWorkspaceSnapshot): Promise { + requireWindow(sender); + const sessionIds = new Set([ + ...snapshot.drafts.map((draft) => draft.sessionId), + ...snapshot.sideConversations.map((side) => side.panel.sourceSessionId), + ...Object.keys(snapshot.workbar ?? {}), ...Object.keys(snapshot.readingAnchors ?? {}), + ]); + for (const id of sessionIds) { + if (!memberGroups.has(id)) await resolveGroup(id); + if (!canUseSession(sender, id)) throw new Error('This workspace belongs to another window'); + } + } + + async function saveSnapshot(sender: WebContents, snapshot: SessionWorkspaceSnapshot): Promise { + await requireSnapshotOwner(sender, snapshot); + await deps.saveSnapshot(sender, snapshot); + } + + async function adoptSnapshot(sender: WebContents, snapshot: SessionWorkspaceSnapshot): Promise { + await requireSnapshotOwner(sender, snapshot); + await deps.adoptSnapshot(sender, snapshot); + } + + function assertOpen(entry: WorkspaceWindowEntry): void { + if (entry.window.isDestroyed() || closing.has(entry.context.windowId)) throw new Error('The workspace window is closing'); + } + + function transfer(group: SessionWindowGroup, navigation: SessionWindowNavigation, target: WorkspaceWindowEntry, source?: WorkspaceWindowEntry): Promise { + assertOpen(target); + if (source) assertOpen(source); + return withMainPresentation(() => { + assertOpen(target); + if (source) assertOpen(source); + return transferNow(group, navigation, target, source); + }); + } + + async function transferNow(group: SessionWindowGroup, navigation: SessionWindowNavigation, target: WorkspaceWindowEntry, source?: WorkspaceWindowEntry): Promise { + const key = groupKey(group); + const targetSender = target.window.webContents; + const sourceSender = source?.window.webContents; + const previousOwner = owners.get(key); + let sourcePrepared = false; + let sourceSnapshot: SessionWorkspaceSnapshot | undefined; + let sourceCleared = false; + let resourcesMoved = false; + let committed = false; + let activeSessionId: string | undefined; + let targetRetained: SessionWindowCommandResult | undefined; + let targetPrepared = false; + let snapshot: SessionWorkspaceSnapshot | undefined; + try { + await waitReady(target); + if (target.context.kind === 'main') { + targetPrepared = true; + targetRetained = await request(target, { kind: 'prepare-navigation' }); + } + if (source) { + sourcePrepared = true; + const prepared = await request(source, { kind: 'prepare-transfer', group }); + if (!prepared.snapshot) throw new Error('The source window did not save its workspace'); + activeSessionId = prepared.activeSessionId; + await saveSnapshot(sourceSender!, prepared.snapshot); + sourceSnapshot = prepared.snapshot; + } + // Issue handles only to the renderer that will receive them. A read is an + // allocation, so obtain it once and before clearing the source workspace. + snapshot = await deps.loadSnapshot(targetSender, group.sessionIds); + if (source) { + sourceCleared = true; + await request(source, { kind: 'clear', group }); + } + if (target.context.kind === 'session') owners.set(key, target.context.windowId); + else owners.delete(key); + committed = true; + resourcesMoved = true; + await deps.transferResources({ group, source: sourceSender, target: targetSender }); + await request(target, { kind: 'restore', group, navigation, snapshot }); + await deps.adoptSnapshot(targetSender, snapshot); + selections.set(target.context.windowId, navigation); + target.controller.reveal(); + target.controller.focus(); + if (source?.context.kind === 'session') source.window.destroy(); + } catch (error) { + // Revoke the destination before reactivating the source. If it cannot + // acknowledge revocation, destroy it; never leave two operable owners. + if (committed && !target.window.isDestroyed()) { + try { await request(target, { kind: 'clear', group }); } + catch { target.window.destroy(); } + } + if (snapshot) deps.releaseSnapshot(targetSender, snapshot); + if (previousOwner) owners.set(key, previousOwner); else owners.delete(key); + if (source && !source.window.isDestroyed()) { + if (resourcesMoved) { + await deps.transferResources({ group, source: targetSender, target: sourceSender! }); + } + if (sourceCleared && activeSessionId && group.sessionIds.includes(activeSessionId)) { + const restored = await deps.loadSnapshot(sourceSender!, group.sessionIds); + try { + await request(source, { kind: 'restore', group, navigation: { sessionId: activeSessionId }, snapshot: restored }); + await deps.adoptSnapshot(sourceSender!, restored); + } + catch (restoreError) { deps.releaseSnapshot(sourceSender!, restored); throw restoreError; } + } else if (sourcePrepared) { + if (!sourceCleared && sourceSnapshot) await deps.adoptSnapshot(sourceSender!, sourceSnapshot); + await request(source, { kind: 'resume', group }); + } + } + if (target.context.kind === 'main' && !target.window.isDestroyed() && targetPrepared) { + await request(target, { kind: 'resume', + ...(committed ? { navigation: targetRetained?.activeSessionId ? { sessionId: targetRetained.activeSessionId } : null } : {}), + }); + } + throw error; + } + } + + async function routeSession(sender: WebContents, navigation: SessionWindowNavigation): Promise<'local' | 'routed'> { + const origin = requireWindow(sender); + assertOpen(origin); + const group = await resolveGroup(navigation.sessionId); + return serial(groupKey(group), async () => { + assertOpen(origin); + const destination = owner(group); + if (destination) { + if (destination === origin) { selections.set(origin.context.windowId, navigation); return 'local'; } + await navigate(destination, group, navigation); + return 'routed'; + } + if (origin.context.kind === 'main') return withMainPresentation(async () => { + assertOpen(origin); + selections.set(origin.context.windowId, navigation); + return 'local' as const; + }); + await navigate(await ensureMain(), group, navigation); + return 'routed'; + }); + } + + async function openSessionWindow(sender: WebContents, sessionId: string): Promise { + requireWindow(sender); + const group = await resolveGroup(sessionId); + await serial(groupKey(group), async () => { + const existing = owner(group); + if (existing) { await navigate(existing, group, { sessionId }); return; } + const source = registry.main(); + const controller = await deps.windows.createSessionWindow({ kind: 'session', group, initialSessionId: sessionId }, deps.signal); + const window = controller.browserWindow(); + if (!window) throw new Error('The session window could not be created'); + const target = requireWindow(window.webContents); + try { await transfer(group, { sessionId }, target, source); } + catch (error) { if (!window.isDestroyed()) window.destroy(); throw error; } + }); + } + + async function openInMainWindow(sender: WebContents): Promise { + const origin = requireWindow(sender); + assertOpen(origin); + if (origin.context.kind !== 'session') return; + const initial = origin.context.initialSessionId; + const group = await resolveGroup(initial); + await serial(groupKey(group), async () => { + if (owner(group) !== origin) throw new Error('The session window no longer owns this workspace'); + // prepare-transfer returns the current member; use it when available in + // transfer rather than switching the user back to the original root. + try { + const current = await request(origin, { kind: 'prepare-transfer', group }); + const sessionId = current.activeSessionId && group.sessionIds.includes(current.activeSessionId) ? current.activeSessionId : initial; + await transfer(group, { sessionId }, await ensureMain(), origin); + } + catch (error) { + if (!origin.window.isDestroyed()) await request(origin, { kind: 'resume', group }).catch(() => undefined); + throw error; + } + }); + } + + function ownerForSession(sessionId: string): WebContents | undefined { + const key = memberGroups.get(sessionId); + const windowId = key && owners.get(key); + return windowId ? registry.get(windowId)?.window.webContents : undefined; + } + + function canUseSession(sender: WebContents, sessionId: string): boolean { + const entry = registry.forSender(sender); + if (!entry) return false; + const owned = ownerForSession(sessionId); + if (owned) return owned === sender; + return entry.context.kind === 'main'; + } + + return { + context: (sender: WebContents) => requireWindow(sender).context, + routeSession, openSessionWindow, openInMainWindow, ownerForSession, canUseSession, saveSnapshot, adoptSnapshot, + refreshGroup: resolveGroup, + async loadSnapshot(sender: WebContents, sessionIds: readonly string[]) { + requireWindow(sender); + for (const id of sessionIds) { + if (!memberGroups.has(id)) await resolveGroup(id); + if (!canUseSession(sender, id)) throw new Error('This workspace belongs to another window'); + } + requireWindow(sender); + return deps.loadSnapshot(sender, sessionIds); + }, + sessionWindowReady(sender: WebContents): void { + const entry = requireWindow(sender); + if (ready.has(entry.context.windowId)) return; + ready.add(entry.context.windowId); + for (const callback of readyWaiters.get(entry.context.windowId) ?? []) callback(); + readyWaiters.delete(entry.context.windowId); + if (entry.context.kind === 'session') { + const key = memberGroups.get(entry.context.initialSessionId); + const group = key ? groups.get(key) : undefined; + if (key && group && owners.get(key) === entry.context.windowId) { + const navigation = selections.get(entry.context.windowId) ?? { sessionId: entry.context.initialSessionId }; + void serial(key, async () => { + if (owner(group) !== entry) return; + await request(entry, { kind: 'navigate', group, navigation }); + entry.controller.reveal(); + }).catch((error) => reportError(error, sender)); + } + } + }, + reply(sender: WebContents, reply: SessionWindowCommandReply): void { + const entry = requireWindow(sender); + const operation = pending.get(reply.requestId); + if (!operation) return; // Capture alone never transfers custody; ignore late replies. + if (operation.windowId !== entry.context.windowId) return; + if (reply.ok) operation.resolve(reply.result ?? {}); + else operation.reject(new Error(reply.error)); + }, + async focusSession(sessionId: string): Promise { + const group = await resolveGroup(sessionId); + await serial(groupKey(group), async () => navigate(owner(group) ?? await ensureMain(), group, { sessionId })); + }, + async commandMain(sender: WebContents, command: 'newTask' | 'openSettings' | 'openHelp'): Promise { + const origin = requireWindow(sender); + assertOpen(origin); + const entry = await ensureMain(); + await withMainPresentation(async () => { + assertOpen(origin); + assertOpen(entry); + entry.window.webContents.send('window:command', { id: command }); + entry.controller.focus(); + }); + }, + dispose(): void { + unsubscribe(); + for (const callbacks of readyWaiters.values()) for (const complete of callbacks) complete(new Error('Window coordination stopped')); + readyWaiters.clear(); + for (const operation of pending.values()) operation.reject(new Error('Window coordination stopped')); + }, + }; +} + +export type SessionWindowCoordinator = ReturnType; + +export function registerSessionWindowIpc(coordinator: SessionWindowCoordinator, ipc: Pick): void { + function handle(channel: string, action: (sender: WebContents, input: any) => unknown): void { + ipc.handle(`session-window:${channel}`, (event, input: unknown) => { + if (event.senderFrame !== event.sender.mainFrame) throw new Error('Session windows require the main frame'); + coordinator.context(event.sender); + return action(event.sender, input); + }); + } + handle('context', (sender) => coordinator.context(sender)); + handle('ready', (sender) => coordinator.sessionWindowReady(sender)); + handle('route', (sender, input: unknown) => coordinator.routeSession(sender, requireNavigation(input))); + handle('open', (sender, input: unknown) => coordinator.openSessionWindow(sender, requireSessionId(input))); + handle('open-main', (sender) => coordinator.openInMainWindow(sender)); + handle('reply', (sender, input: unknown) => { + if (!input || typeof input !== 'object' || !('requestId' in input) || typeof input.requestId !== 'string' || !('ok' in input) || typeof input.ok !== 'boolean') throw new TypeError('Invalid window reply'); + if (!input.ok && (!('error' in input) || typeof input.error !== 'string')) throw new TypeError('Invalid window reply'); + return coordinator.reply(sender, input as SessionWindowCommandReply); + }); + handle('adopt', (sender, input) => coordinator.adoptSnapshot(sender, input)); + handle('load', (sender, input: unknown) => { + if (!Array.isArray(input)) throw new TypeError('Invalid session list'); + return coordinator.loadSnapshot(sender, input.map(requireSessionId)); + }); + handle('command-main', (sender, input: unknown) => { + if (input !== 'newTask' && input !== 'openSettings' && input !== 'openHelp') throw new TypeError('Invalid main window command'); + return coordinator.commandMain(sender, input); + }); +} + +function requireSessionId(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > 4096) throw new TypeError('Invalid session identity'); + return value; +} + +function requireNavigation(input: unknown): SessionWindowNavigation { + if (!input || typeof input !== 'object' || !('sessionId' in input)) throw new TypeError('Invalid session navigation'); + const sessionId = requireSessionId(input.sessionId); + if ('turnId' in input && input.turnId !== undefined && (typeof input.turnId !== 'string' || input.turnId.length > 4096)) throw new TypeError('Invalid turn identity'); + if ('sequence' in input && input.sequence !== undefined && (typeof input.sequence !== 'number' || !Number.isSafeInteger(input.sequence) || input.sequence < 0)) throw new TypeError('Invalid turn sequence'); + return { sessionId, ...('turnId' in input && input.turnId !== undefined ? { turnId: input.turnId as string } : {}), ...('sequence' in input && input.sequence !== undefined ? { sequence: input.sequence as number } : {}) }; +} diff --git a/apps/desktop/src/main/session-window-group.ts b/apps/desktop/src/main/session-window-group.ts new file mode 100644 index 00000000000..08ab57cbfbf --- /dev/null +++ b/apps/desktop/src/main/session-window-group.ts @@ -0,0 +1,83 @@ +/* + * 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>(); + + 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) catalog.set(session.id, session); + } + + resolve(sessionKey: string, sessions: readonly WindowGroupSession[]): SessionWindowGroup { + const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); + let catalog = this.#catalogs.get(hostId); + if (!catalog) this.#catalogs.set(hostId, catalog = new Map()); + for (const session of sessions) catalog.set(session.id, session); + if (!catalog.has(sessionId)) throw new Error('Session is unavailable'); + const familyOf = (id: string): string => { + const session = catalog.get(id); + return session ? sessionRevisionFamilyId(session) : id; + }; + const parents = new Map(); + for (const session of catalog.values()) { + const parent = linkedSubagentParentSessionId(session) + ?? (isSideConversationSession(session.labels) ? session.parentSessionId : undefined); + const family = familyOf(session.id); + if (parent && familyOf(parent) !== family) parents.set(family, familyOf(parent)); + } + const rootOf = (id: string): string => { + let current = familyOf(id); + const seen: string[] = []; + while (parents.has(current)) { + const cycleStart = seen.indexOf(current); + if (cycleStart >= 0) return seen.slice(cycleStart).sort()[0]!; + seen.push(current); + current = parents.get(current)!; + } + return current; + }; + const root = rootOf(sessionId); + const members = [...catalog.keys()].filter((id) => rootOf(id) === root); + // Include a deleted/missing root identity in ownership checks as well. + if (!members.includes(root)) members.push(root); + return { + hostId, + groupId: desktopSessionKey({ hostId, sessionId: root }), + sessionIds: members.sort().map((id) => desktopSessionKey({ hostId, sessionId: id })), + }; + } + + forgetHost(hostId: string): void { this.#catalogs.delete(hostId); } +} diff --git a/apps/desktop/src/main/session-workspace-custody.ts b/apps/desktop/src/main/session-workspace-custody.ts new file mode 100644 index 00000000000..5bb814e3b87 --- /dev/null +++ b/apps/desktop/src/main/session-workspace-custody.ts @@ -0,0 +1,55 @@ +/* + * 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 { SessionCopyCleanupAuthority } from '@maka/storage/session-copy-cleanup'; +import type { SessionWorkspaceSnapshot, SideConversationWorkspaceState } from '../shared/session-workspace-state.js'; + +export interface WorkspaceCopyLease { + authority: SessionCopyCleanupAuthority; + sessionId: string; + sourceSessionId: string; +} + +/** Move the existing cleanup leases with their workspace, including partial failure. */ +export async function adoptWorkspaceCopies(snapshot: SessionWorkspaceSnapshot, deps: { + resolve(side: SideConversationWorkspaceState): WorkspaceCopyLease; + ownerId: string; + previousOwnerId: string; + unavailable?(side: SideConversationWorkspaceState, error: unknown): void; +}): Promise { + const adopted: WorkspaceCopyLease[] = []; + try { + for (const side of snapshot.sideConversations) { + if (!side.companion) continue; + try { + const lease = deps.resolve(side); + await lease.authority.adopt(lease.sessionId, lease.sourceSessionId, deps.ownerId); + adopted.push(lease); + } catch (error) { + if (!deps.unavailable) throw error; + deps.unavailable(side, error); + } + } + } catch (error) { + // A failed transfer leaves every successfully moved copy with its prior + // custodian. Never discard its snapshot while its lease is still parked. + await Promise.all(adopted.map((lease) => lease.authority.adopt(lease.sessionId, lease.sourceSessionId, deps.previousOwnerId))); + throw error; + } +} diff --git a/apps/desktop/src/main/session-workspace-state-store.ts b/apps/desktop/src/main/session-workspace-state-store.ts new file mode 100644 index 00000000000..e4828ee9353 --- /dev/null +++ b/apps/desktop/src/main/session-workspace-state-store.ts @@ -0,0 +1,352 @@ +/* + * 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 { stat } from 'node:fs/promises'; +import { AttachmentIngestBlockedError, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, resolveAttachmentMimeType } from '@maka/core/attachments'; +import { isAttachmentRef, isDirectoryReference, isInlineReference, isQuoteRef } from '@maka/core/events'; +import type { AttachmentApprovalRegistry } from './attachment-approval.js'; +import { prepareIngestItems, resolveAttachmentRefs } from './attachment-ingest.js'; +import { SessionLocalAttachmentRecovery, type RecoveryOwner } from './session-local-attachment-recovery.js'; +import { MAX_LOCAL_MESSAGE_BYTES } from './session-local-store.js'; +import { parseDesktopSessionKey, type DesktopTargetScope } from '../shared/runtime-host-identity.js'; +import type { SessionWorkspaceAttachment, SessionWorkspaceDraft, SessionWorkspaceSnapshot, SideConversationWorkspaceState } from '../shared/session-workspace-state.js'; + +export interface SessionWorkspaceOwner { + readonly senderId: number; + readonly partition: string; + readonly scope: DesktopTargetScope; +} + +type StoredAttachment = Exclude | { + readonly type: 'stored'; readonly ordinal: number; readonly name: string; readonly mimeType: string; readonly size: number; +}; +type StoredDraft = Omit & { attachments: readonly StoredAttachment[] }; +type StoredSide = Omit & { draft: StoredDraft }; +type AttachmentBytes = { name: string; mimeType: string; content: Uint8Array }; +type StoredEntry = { kind: 'draft' | 'side'; payload: StoredDraft | StoredSide; bytes: AttachmentBytes[] }; +type StoredWorkspace = { + capturedAt: number; + entries: StoredEntry[]; + workbar?: import('../shared/session-workbar-state.js').SessionWorkbarLayoutSnapshot; + readingAnchor?: { readonly turnId: string }; +}; + +/** Process-lifetime custody while an existing draft moves between windows. */ +export class SessionWorkspaceStateStore { + readonly #workspaces = new Map>(); + readonly #pending = new Map>(); + #revision = 0; + #closed = false; + readonly #deleted = new Set(); + #wasDeleted(partition: string, sessionId: string): boolean { return this.#deleted.has(JSON.stringify([partition, sessionId])); } + constructor(private readonly sources: { + approvals: AttachmentApprovalRegistry; + recovery: SessionLocalAttachmentRecovery; + }) {} + + #recoveryOwner(owner: SessionWorkspaceOwner, key: string): RecoveryOwner { + const ref = parseDesktopSessionKey(key); + if (ref.hostId !== owner.scope.hostId) throw new Error('Workspace draft belongs to a different Host'); + return { ...owner, sessionId: ref.sessionId }; + } + + async #prepareDraft(owner: SessionWorkspaceOwner, draft: SessionWorkspaceDraft, onLoss?: (message: string) => void): Promise<{ draft: StoredDraft; bytes: AttachmentBytes[]; revalidate(): void }> { + this.#recoveryOwner(owner, draft.sessionId); + if (typeof draft.text !== 'string' || draft.text.length > 2_000_000 || !Array.isArray(draft.references) || + !Array.isArray(draft.quotes) || !Array.isArray(draft.directoryReferences) || !Array.isArray(draft.attachments) || + !draft.references.every(isInlineReference) || !draft.quotes.every(isQuoteRef) || !draft.directoryReferences.every(isDirectoryReference)) + throw new Error('Invalid workspace draft'); + if (draft.attachments.length > MAX_ATTACHMENT_COUNT) throw new AttachmentIngestBlockedError('count_limit'); + const bytes: AttachmentBytes[] = []; + const attachments: StoredAttachment[] = []; + const seen = new Set(); + const revalidations: Array<() => void> = []; + for (const attachment of draft.attachments) { + const byteStart = bytes.length; + const attachmentStart = attachments.length; + const validationStart = revalidations.length; + const lost = (error: unknown) => { + const name = attachment.type === 'retained' ? attachment.attachment.name : attachment.name; + onLoss!(`${name ?? 'Attachment'}: ${error instanceof Error ? error.message : String(error)}`); + }; + try { + if (attachment.type === 'retained') { + if (!isAttachmentRef(attachment.attachment)) throw new Error('Invalid retained workspace attachment'); + attachments.push(structuredClone(attachment)); + continue; + } + let saved: AttachmentBytes; + if (attachment.type === 'approval') { + if (seen.has(attachment.approvalId)) throw new AttachmentIngestBlockedError('duplicate_source'); + seen.add(attachment.approvalId); + } + if (attachment.type === 'approval' && attachment.approvalId.startsWith('local-recovery:')) { + revalidations.push(() => { this.sources.recovery.snapshot(this.#recoveryOwner(owner, draft.sessionId), attachment.approvalId); }); + saved = this.sources.recovery.snapshot(this.#recoveryOwner(owner, draft.sessionId), attachment.approvalId); + } else { + const prepared = await prepareIngestItems({ + senderId: owner.senderId, approvals: this.sources.approvals, stat, + items: attachment.type === 'approval' ? [attachment] : attachment.type === 'upload' ? [attachment] : null, + maxBytes: MAX_ATTACHMENT_BYTES, maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, + }); + const snapshots = await resolveAttachmentRefs({ + files: prepared.files, maxTotalBytes: MAX_LOCAL_MESSAGE_BYTES, + snapshot: async (item) => ({ name: item.name, mimeType: item.mimeType, content: item.content }), + }); + // Keep the source approval alive until transfer succeeds or rolls back. + if (attachment.type === 'approval' && !this.sources.approvals.peekApproval(owner.senderId, attachment.approvalId)) + throw new AttachmentIngestBlockedError('source_expired'); + if (attachment.type === 'approval') revalidations.push(() => { + if (!this.sources.approvals.peekApproval(owner.senderId, attachment.approvalId)) throw new AttachmentIngestBlockedError('source_expired'); + }); + saved = snapshots[0]!; + } + saved.mimeType = resolveAttachmentMimeType(saved.content, saved.mimeType, saved.name); + bytes.push(saved); + if (bytes.reduce((sum, entry) => sum + entry.content.byteLength, 0) > MAX_LOCAL_MESSAGE_BYTES) + throw new AttachmentIngestBlockedError('total_size_exceeded'); + const stored: StoredAttachment = { type: 'stored', ordinal: bytes.length - 1, name: saved.name, mimeType: saved.mimeType, size: saved.content.byteLength }; + attachments.push(stored); + if (onLoss) { + const checks = revalidations.splice(validationStart); + revalidations.push(() => { + try { for (const check of checks) check(); } + catch (error) { + attachments.splice(attachments.indexOf(stored), 1); + bytes[stored.ordinal] = { ...saved, content: new Uint8Array() }; + lost(error); + } + }); + } + } catch (error) { + if (!onLoss) throw error; + bytes.splice(byteStart); + attachments.splice(attachmentStart); + revalidations.splice(validationStart); + lost(error); + } + } + const safeDraft: StoredDraft = { + sessionId: draft.sessionId, text: draft.text, references: structuredClone(draft.references), + ...(draft.revision ? { revision: structuredClone(draft.revision) } : {}), + ...(draft.retractedReferences ? { retractedReferences: structuredClone(draft.retractedReferences) } : {}), + ...(draft.replacesMessageId ? { replacesMessageId: draft.replacesMessageId } : {}), + quotes: structuredClone(draft.quotes), directoryReferences: structuredClone(draft.directoryReferences), attachments, + }; + if (JSON.stringify(safeDraft).length > 4_000_000) throw new Error('Workspace draft is too large'); + return { draft: safeDraft, bytes, revalidate: () => { for (const validate of revalidations) validate(); } }; + } + + /** A window may contain several Hosts; a failed flush leaves all drafts with it. */ + async saveAll(workspaces: readonly { owner: SessionWorkspaceOwner; snapshot: SessionWorkspaceSnapshot }[], onLoss?: (message: string) => void): Promise { + try { + for (const { owner, snapshot } of workspaces) await this.save(owner, snapshot, onLoss); + } catch (error) { + for (const { owner, snapshot } of workspaces) { + this.forget(owner.partition, snapshot.drafts.map((draft) => draft.sessionId), snapshot.capturedAt); + } + throw error; + } + } + + save(owner: SessionWorkspaceOwner, snapshot: SessionWorkspaceSnapshot, onLoss?: (message: string) => void): Promise { + if (this.#closed) return Promise.reject(new Error('Workspace handoff store is closed')); + // Serialize asynchronous file reads so a slow earlier transfer cannot + // overwrite the state captured when its source window closes. + const pendingKey = JSON.stringify([owner.partition, owner.scope.hostId]); + const revision = this.#revision; + const previous = this.#pending.get(pendingKey) ?? Promise.resolve(); + const next = previous.catch(() => {}).then(() => this.#save(owner, snapshot, revision, onLoss)); + this.#pending.set(pendingKey, next); + void next.finally(() => { if (this.#pending.get(pendingKey) === next) this.#pending.delete(pendingKey); }).catch(() => {}); + return next; + } + + async #save(owner: SessionWorkspaceOwner, snapshot: SessionWorkspaceSnapshot, revision: number, onLoss?: (message: string) => void): Promise { + if (!snapshot || !Array.isArray(snapshot.drafts) || !Array.isArray(snapshot.sideConversations) || !Number.isFinite(snapshot.capturedAt)) + throw new Error('Invalid workspace snapshot'); + const revalidations: Array<{ source: string; validate(): void }> = []; + const entries: Array = []; + const members = new Set(snapshot.drafts.map((draft) => draft.sessionId).filter((key) => !this.#wasDeleted(owner.partition, key))); + for (const draft of snapshot.drafts) { + if (!members.has(draft.sessionId)) continue; + let prepared; + try { prepared = await this.#prepareDraft(owner, draft, onLoss); } + catch (error) { + if (this.#wasDeleted(owner.partition, draft.sessionId)) continue; + throw error; + } + revalidations.push({ source: draft.sessionId, validate: prepared.revalidate }); + entries.push({ source: draft.sessionId, kind: 'draft', payload: prepared.draft, bytes: prepared.bytes }); + } + for (const side of snapshot.sideConversations) { + if (this.#wasDeleted(owner.partition, side.panel.sourceSessionId)) continue; + if (!members.has(side.panel.sourceSessionId)) throw new Error('Side conversation is outside the workspace snapshot'); + let prepared; + try { prepared = await this.#prepareDraft(owner, side.draft, onLoss); } + catch (error) { + if (this.#wasDeleted(owner.partition, side.panel.sourceSessionId)) continue; + throw error; + } + revalidations.push({ source: side.panel.sourceSessionId, validate: prepared.revalidate }); + entries.push({ source: side.panel.sourceSessionId, kind: 'side', + payload: { ...structuredClone(side), draft: prepared.draft }, bytes: prepared.bytes }); + } + for (const member of members) if (this.#wasDeleted(owner.partition, member)) members.delete(member); + for (let index = entries.length - 1; index >= 0; index -= 1) { + if (!members.has(entries[index]!.source)) entries.splice(index, 1); + } + for (const item of revalidations) if (members.has(item.source)) item.validate(); + if (revision !== this.#revision) throw new Error('Workspace authorization was cleared during save'); + const next = new Map(this.#workspaces.get(owner.partition)); + for (const source of members) { + if ((next.get(source)?.capturedAt ?? -Infinity) > snapshot.capturedAt) continue; + const ui = { workbar: snapshot.workbar?.[source], readingAnchor: snapshot.readingAnchors?.[source] }; + if (JSON.stringify(ui).length > 1_000_000) throw new Error('Workspace layout is too large'); + next.set(source, { + capturedAt: snapshot.capturedAt, + entries: entries.filter((entry) => entry.source === source), + ...structuredClone(ui), + }); + } + let storedBytes = 0; + const countBytes = (workspaces: Map) => { + for (const workspace of workspaces.values()) for (const entry of workspace.entries) + for (const attachment of entry.bytes) storedBytes += attachment.content.byteLength; + }; + countBytes(next); + for (const [partition, workspaces] of this.#workspaces) if (partition !== owner.partition) countBytes(workspaces); + if (onLoss && storedBytes > 256 * 1024 * 1024) { + for (const entry of entries) { + if (!next.get(entry.source)?.entries.includes(entry)) continue; + const draft = entry.kind === 'draft' ? entry.payload as StoredDraft : (entry.payload as StoredSide).draft; + draft.attachments = draft.attachments.filter((attachment) => { + if (attachment.type !== 'stored' || storedBytes <= 256 * 1024 * 1024) return true; + storedBytes -= entry.bytes[attachment.ordinal]!.content.byteLength; + entry.bytes[attachment.ordinal]!.content = new Uint8Array(); + onLoss(`${attachment.name}: workspace attachment capacity exceeded`); + return false; + }); + } + } + if (storedBytes > 256 * 1024 * 1024) throw new AttachmentIngestBlockedError('total_size_exceeded'); + // Publish only after every draft and attachment succeeds, preserving the + // previous snapshot for a failed transfer's rollback. + this.#workspaces.set(owner.partition, next); + } + + load(owner: SessionWorkspaceOwner, sessionIds: readonly string[]): SessionWorkspaceSnapshot { + if (this.#closed) throw new Error('Workspace handoff store is closed'); + const drafts: SessionWorkspaceDraft[] = []; + const sideConversations: SideConversationWorkspaceState[] = []; + const workbar: Record = {}; + const readingAnchors: Record = {}; + let capturedAt = 0; + const issued: string[] = []; + const restore = (bytes: AttachmentBytes[], draft: StoredDraft): SessionWorkspaceDraft => ({ + ...draft, + attachments: draft.attachments.map((item): SessionWorkspaceAttachment => { + if (item.type === 'retained') return item; + const saved = bytes[item.ordinal]; + if (!saved) throw new Error('Saved workspace attachment is missing'); + const approval = this.sources.recovery.issue(this.#recoveryOwner(owner, draft.sessionId), [saved])[0]!; + issued.push(approval.approvalId); + return { type: 'approval', ...approval }; + }), + }); + try { + for (const sessionId of new Set(sessionIds)) { + this.#recoveryOwner(owner, sessionId); + const workspace = this.#workspaces.get(owner.partition)?.get(sessionId); + if (workspace?.workbar) workbar[sessionId] = structuredClone(workspace.workbar); + if (workspace?.readingAnchor) readingAnchors[sessionId] = structuredClone(workspace.readingAnchor); + capturedAt = Math.max(capturedAt, workspace?.capturedAt ?? 0); + let hasDraft = false; + for (const entry of workspace?.entries ?? []) { + if (entry.kind === 'draft') { + drafts.push(restore(entry.bytes, structuredClone(entry.payload) as StoredDraft)); + hasDraft = true; + } else { + const side = structuredClone(entry.payload) as StoredSide; + sideConversations.push({ ...side, draft: restore(entry.bytes, side.draft) }); + } + } + if (!hasDraft) drafts.push({ sessionId, text: '', references: [], attachments: [], directoryReferences: [], quotes: [] }); + } + return { capturedAt, drafts, sideConversations, workbar, readingAnchors }; + } catch (error) { + this.sources.recovery.release(owner.senderId, issued); + throw error; + } + } + + /** A failed copy can lose its history while its unsent draft remains recoverable. */ + releaseUnavailableCompanion(partition: string, sourceSessionId: string, panelId: string, capturedAt: number): void { + const workspace = this.#workspaces.get(partition)?.get(sourceSessionId); + if (!workspace || workspace.capturedAt !== capturedAt) return; + for (const entry of workspace.entries) { + if (entry.kind !== 'side') continue; + const side = entry.payload as StoredSide; + if (side.panel.id !== panelId) continue; + const { companion: _companion, ...retained } = side; + entry.payload = { ...retained, active: false, hasContent: false, draft: { ...side.draft, sessionId: sourceSessionId } }; + } + } + + /** Discard destination approvals after an interrupted or failed restoration. */ + releaseSnapshot(senderId: number, snapshot: SessionWorkspaceSnapshot): void { + const ids = new Set(); + for (const draft of [...snapshot.drafts, ...snapshot.sideConversations.map((side) => side.draft)]) { + for (const item of draft.attachments) { + if (item.type === 'approval' && item.approvalId.startsWith('local-recovery:')) ids.add(item.approvalId); + } + } + this.sources.recovery.release(senderId, [...ids]); + } + + /** The destination now owns these drafts; do not replay them on a later reload. */ + forget(partition: string, sessionIds: readonly string[], capturedAt: number): void { + const workspaces = this.#workspaces.get(partition); + if (!workspaces) return; + for (const key of sessionIds) { + const workspace = workspaces.get(key); + if (workspace && workspace.capturedAt <= capturedAt) workspaces.delete(key); + } + if (!workspaces.size) this.#workspaces.delete(partition); + } + + delete(partition: string, sessionIds: readonly string[]): void { + for (const key of sessionIds) { + this.#deleted.add(JSON.stringify([partition, key])); + this.#workspaces.get(partition)?.delete(key); + } + } + + clear(partition?: string): void { + this.#revision += 1; + if (partition === undefined) this.#workspaces.clear(); + else this.#workspaces.delete(partition); + } + close(): void { + this.#closed = true; + this.clear(); + this.#deleted.clear(); + this.#pending.clear(); + } +} 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 42de2cd7b65..7c7b6f26d5b 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..bf7d5f5bc13 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,20 @@ const makaBridge = { }, }, appWindow: { + context: () => invokeWhenReady('session-window:context'), + routeSession: (navigation) => invokeWhenReady('session-window:route', navigation), + openSessionWindow: (sessionId) => invokeWhenReady('session-window:open', sessionId), + openInMainWindow: () => invokeWhenReady('session-window:open-main'), + sessionWindowReady: () => invokeWhenReady('session-window:ready'), + subscribeSessionWindowCommand(handler) { + const listener = (_event: Electron.IpcRendererEvent, command: import('../shared/session-window-contract.js').SessionWindowCommand) => handler(command); + ipcRenderer.on('session-window:command', listener); + return () => ipcRenderer.off('session-window:command', listener); + }, + ackSessionWindowCommand: (reply) => invokeWhenReady('session-window:reply', reply), + adoptWorkspaceSnapshot: (snapshot) => invokeWhenReady('session-window:adopt', snapshot), + loadWorkspaceSnapshot: (sessionIds) => invokeWhenReady('session-window:load', sessionIds), + commandMain: (command) => invokeWhenReady('session-window:command-main', command), popupMenu(input: import('../shared/native-menu.js').NativeMenuRequest): Promise { return invokeWhenReady('window:popupMenu', input); }, diff --git a/apps/desktop/src/renderer/app-shell-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 ed4f51b20aa..d4ca9098e33 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -32,9 +32,7 @@ import { type SetStateAction, } from 'react'; import type { QuoteRef } from '@maka/core/events'; -import type { OrchestrationMode } from '@maka/core/orchestration'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; -import { collapseSessionRevisions } from '@maka/core/session-revisions'; import { isLinkedSubagentSession } from '@maka/core/session'; import { resolveUiLocale } from '@maka/core/ui-locale'; import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; @@ -60,11 +58,13 @@ import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery import { AgentGraphPanel } from './agent-graph-panel'; import { ChatComposerRegion } from './chat-composer-region'; import { WorkbarHost, WorkbarProvider, WorkbarShellRoot, type WorkbarShellProjection } from './features/workbar'; +import { SessionWindowRoot, SessionWindowBinding, SessionWindowEmptyState, type SessionWindowProjection } from './features/session-windows/index.js'; import { AppUpdateProvider } from './features/app-update/index.js'; import * as Goals from './features/goals'; import * as ModuleHub from './features/module-hub'; import { SessionNavigationProvider, + SessionNavigationCommands, createSessionOpenCommand, sessionRailLayoutStore, useSessionNavigationReads, @@ -97,7 +97,7 @@ import * as Diagnostics from './features/diagnostics/index.js'; import { getDesktopConversationCopy } from './application/contracts/conversation-copy'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; -import { SessionSettingsProvider, useSessionSettingIntent } from './features/session-settings'; +import { SessionSettingsProvider, useSessionSettingIntent, createSessionModeCommands, deriveSessionSettingsPresentation } from './features/session-settings'; import { pendingSessionView } from './pending-session-view'; import { readScrollMotionBehavior } from './scroll-motion-policy'; import { readNavigationState, selectNavigation } from './nav-selection'; @@ -136,12 +136,6 @@ import { useShellConnections } from './use-shell-connections'; import { useSystemUiLocale } from './use-system-ui-locale'; import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; -type ComposerImportOwner = { - sessionId: string | undefined; - navSection: NavSelection['section']; - newTaskDraftKey?: string; -}; - export function AppShell() { const [uiLocalePreference, setUiLocalePreference] = useState('auto'); const [uiLocaleOverride, setUiLocaleOverride] = useState(null); @@ -159,8 +153,10 @@ export function AppShell() { - - + + {(sessionWindow) => <> + {!sessionWindow.standalone && } + {(taskEntry) => ( {(overlays) => ( @@ -168,13 +164,13 @@ export function AppShell() { {(sharedSessionDialog) => ( {(workbar) => ( - + {(onboarding) => ( {(copyManualDiagnosticReport) => ( )} @@ -188,7 +184,8 @@ export function AppShell() { )} )} - + } + @@ -209,6 +206,7 @@ export function AppShell() { const SESSION_RAIL = ; function AppShellContent({ + sessionWindow, taskEntry, overlays, sharedSessionDialog, @@ -219,6 +217,7 @@ function AppShellContent({ setUiLocaleOverride, setUiLocalePreference, }: { + sessionWindow: SessionWindowProjection; taskEntry: TaskEntryShellProjection; overlays: OverlaysShellProjection; sharedSessionDialog: SessionCollaborationDialogProjection; @@ -241,7 +240,7 @@ function AppShellContent({ refreshChangedSession, activeId, activeIdRef, - bootstrapSelectionLease, + readSelectionRevision, setActiveId, startNewSession, clearOwnedSessionState, @@ -264,18 +263,21 @@ function AppShellContent({ const sessionCount = authoritativeSessionIds?.size ?? 0; const { - openHelp, + openHelp: openLocalHelp, closePalette, openSearch, setSearchScrollTarget, - openSettings, - openSettingsSection, + openSettings: openLocalSettings, + openSettingsSection: openLocalSettingsSection, openProjectSettings, openProviderCatalog, openConnectionDetail, openProviderCreate, setSettingsProfileId, } = overlays.commands; + const openHelp = sessionWindow.standalone ? () => sessionWindow.commandMain('openHelp') : openLocalHelp; + const openSettings = sessionWindow.standalone ? () => sessionWindow.commandMain('openSettings') : openLocalSettings; + const openSettingsSection = sessionWindow.standalone ? openSettings : openLocalSettingsSection; const { searchScrollTarget } = overlays.selectors; const settingsOpen = overlays.selectors.settings.open; @@ -297,16 +299,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); @@ -422,20 +425,8 @@ function AppShellContent({ const composerEditing = queueSurface.editing; const rendererMountedRef = useRef(true); const { setPermissionMode, setSessionModel, setSessionThinkingLevel, setSessionExecutor } = sessionSettingIntent.commands; - const modelConfigurationOverlay = sessionSettingIntent.overlay.modelConfiguration; - const activeSessionForModelControls = activeSession - ? { - ...activeSession, - ...(modelConfigurationOverlay - ? { - llmConnectionId: modelConfigurationOverlay.modelTarget.llmConnectionId, - llmConnectionSlug: modelConfigurationOverlay.modelTarget.llmConnectionSlug, - model: modelConfigurationOverlay.modelTarget.model, - thinkingLevel: modelConfigurationOverlay.thinkingLevel ?? undefined, - } - : {}), - } - : undefined; + const { activeSessionForModelControls, activePlanMode, activeOrchestrationMode } = + deriveSessionSettingsPresentation(activeSession, activeId, sessionSettingIntent); // Surface a credential-lifecycle alert directly in the chat header when // the active session's connection is in `needs_reauth` / `error` or has // been deleted entirely with no usable default. Main resolves credential @@ -516,43 +507,10 @@ function AppShellContent({ sessionSettingIntent.commands.clear(sessionId); } - // Stable: the rail's row actions are built from it, and it only reaches - // registries and refs that are themselves stable (#4109). - function setPlanMode(active: boolean): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - sessionSettingIntent.commands.setNewTaskPlanMode(active); - return Promise.resolve(true); - } - if (active === activePlanMode) return Promise.resolve(true); - return sessionSettingIntent.commands.setPlanMode(sessionId, active); - } - - /** - * The + menu's orchestration choice and the `/swarm` and `/graph` commands - * all land here, so every entry point spells the field the same way. - * - * `/swarm off` means "leave swarm", not "go to default": a Session already - * in Graph has nothing for it to do. - */ - function setOrchestrationMode(mode: OrchestrationMode): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - sessionSettingIntent.commands.setNewTaskOrchestrationMode(mode); - return Promise.resolve(true); - } - if (mode === activeOrchestrationMode) return Promise.resolve(true); - return sessionSettingIntent.commands.setOrchestrationMode(sessionId, mode); - } - - function setOrchestrationModeActive( - mode: Exclude, - active: boolean, - ): Promise { - if (active) return setOrchestrationMode(mode); - if (activeOrchestrationMode !== mode) return Promise.resolve(true); - return setOrchestrationMode('default'); - } + const { setPlanMode, setOrchestrationMode, setOrchestrationModeActive } = createSessionModeCommands({ + sessionId: () => activeIdRef.current, planMode: () => activePlanMode, + orchestrationMode: () => activeOrchestrationMode, commands: sessionSettingIntent.commands, + }); const openSessionInChatRef = useRef< (sessionId: string, turnId?: string, sequence?: number) => void @@ -597,18 +555,6 @@ function AppShellContent({ permissionMode: newSessionPermissionMode, }) : undefined); - // Each control reads its own field. There is nothing to project and nothing - // to keep in sync: a Session in Plan with Swarm as its orchestration default - // says both, because it is both. - const activePlanMode = activeId - ? sessionSettingIntent.overlay.planMode - ?? ((activeSessionForView?.collaborationMode ?? 'agent') === 'plan') - : newTaskSettings.planMode; - const activeOrchestrationMode: OrchestrationMode = activeId - ? sessionSettingIntent.overlay.orchestrationMode - ?? activeSessionForView?.orchestrationMode - ?? 'default' - : newTaskSettings.orchestrationMode; const { boundary: activeExecutionBoundary, unreadable: activeExecutionBoundaryUnreadable, @@ -741,8 +687,9 @@ function AppShellContent({ }, [composerEditing, composerStaging, sessionSettingIntent.commands, setNavSelection, setSearchScrollTarget, startNewSession]); const createSession = useCallback(async () => { - openNewTaskSurface(); - }, [openNewTaskSurface]); + if (sessionWindow.standalone) sessionWindow.commandMain('newTask'); + else openNewTaskSurface(); + }, [openNewTaskSurface, sessionWindow.standalone, sessionWindow.commandMain]); // Stable, because the rail's Project rows carry it: a fresh identity here // rebuilt the whole list on every AppShell commit (#4109). @@ -782,7 +729,7 @@ function AppShellContent({ const hasModalOpen = overlays.selectors.anyModalOpen || sharedSessionDialog.isOpen; const shellObscured = hasModalOpen || settingsOpen; const exitWorkHub = useCallback(() => setWorkHubActive(false), []); - const openSession = useMemo( + const openLocalSession = useMemo( () => createSessionOpenCommand({ activateSession: setActiveId, @@ -792,6 +739,9 @@ function AppShellContent({ }), [exitWorkHub, setNavSelection, setActiveId, setSearchScrollTarget], ); + const openSession = useCallback((sessionId: string, turnId?: string, sequence?: number) => + sessionWindow.openSession({ sessionId, turnId, sequence }, () => openLocalSession(sessionId, turnId, sequence)), + [sessionWindow.openSession, openLocalSession]); useLayoutEffect(() => { openSessionInChatRef.current = openSession; }, [openSession]); @@ -800,6 +750,7 @@ function AppShellContent({ // their identity carries no information and this object never has to be // held still by hand (#4109). const sessionNavigationPorts: SessionNavigationPorts = { + openInNewWindow: sessionWindow.openInNewWindow, sessionsRef, acquireAutomaticQueryBlock: sessionCatalogController.acquireAutomaticQueryBlock, clearSessionRendererState, @@ -808,20 +759,14 @@ function AppShellContent({ }; const { revisionNavigation, - activeParentSession, + titlebarParentSession, layout: { collapsed: sessionListCollapsed, width: sessionListWidth }, } = useSessionNavigationReads({ catalog: sessionCatalogController, activeSessionId: activeId, + openSession: openSessionInChat, }); const sessionSideNavHandleRef = sessionRailLayoutStore.collapseHandleRef; - const titlebarParentSession = useMemo(() => { - if (!activeParentSession) return undefined; - return { - name: activeParentSession.name, - onOpen: () => openSessionInChatRef.current(activeParentSession.id), - }; - }, [activeParentSession]); const archivedTasksBridge = useMemo( () => ({ catalog: sessionCatalogController, @@ -879,54 +824,14 @@ function AppShellContent({ toastApi, }); useAppShellPersistenceEffects({ - navigationState, + navigationState: sessionWindow.standalone ? undefined : navigationState, themePalette, themePref, }); - function captureComposerImportOwner(): ComposerImportOwner { - return { - sessionId: activeIdRef.current, - navSection: navSelectionRef.current.section, - ...(activeIdRef.current === undefined - ? { newTaskDraftKey: currentNewTaskDraftKey } - : {}), - }; - } + const { captureComposerImportOwner, isShellSurfaceOwnerActive, isComposerImportOwnerActive, isNewChatSendSurfaceActive } = + Conversation.createComposerSurfaceOwner({ activeIdRef, navSelectionRef, newTaskDraftKey: currentNewTaskDraftKey, isSessionSelected }); - /** - * "Is this owner still the surface the user is looking at." One rule, both - * halves: an async result that lands after the user moved on must not toast, - * navigate or steal focus, and `selectNavigation` never clears `activeId` - * (nav-selection.ts) — so the session id alone answers yes long after the - * user left for 扩展 or 设置. - * - * The two below are this same question with a precondition on what KIND of - * owner the caller wants, not second opinions about the question. They were - * three independent spellings once, and the one that re-derived it from an - * id drifted: it lost the section half, which is exactly what let a failed - * send pull a user out of 技能 and into 设置 · 模型. - */ - function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean { - return navSelectionRef.current.section === owner.navSection && - isSessionSelected(owner.sessionId) && - (owner.sessionId !== undefined || owner.newTaskDraftKey === currentNewTaskDraftKey); - } - - /** …and the owner was captured on the chat surface. */ - function isComposerImportOwnerActive(owner: ComposerImportOwner): boolean { - return owner.navSection === 'sessions' && isShellSurfaceOwnerActive(owner); - } - - /** …and it was the new-chat surface, which by definition has no session. */ - function isNewChatSendSurfaceActive(owner: ComposerImportOwner): boolean { - return owner.sessionId === undefined && isComposerImportOwnerActive(owner); - } - - async function bootstrapSessions() { - const next = await refreshSessions(); - bootstrapSelectionLease.reconcile(collapseSessionRevisions(next)); - bootstrapSelectionLease.release(); - } + function bootstrapSessions() { return sessionWindow.bootstrap(refreshSessions, readSelectionRevision, openSession); } const dispatchMakaUri = createInternalUriDispatcher(ownerActiveId, openSettingsSection, composerEditing); @@ -993,6 +898,7 @@ function AppShellContent({ // `ConversationHomeSurface` reads for the displayed Session. const homeSurfaceEligible = sessionsSelected && transcriptEmpty; const commandOptions: AppShellCommandListOptions = { + standalone: sessionWindow.standalone, uiLocale, activeId, activePermissionMode, @@ -1141,9 +1047,10 @@ function AppShellContent({ - + {(conversation) => openLocalSession(navigation.sessionId, navigation.turnId, navigation.sequence)} + clearLocal={() => { setNavSelection({ section: 'sessions' }); setSearchScrollTarget(null); }} /> + } + {sessionWindow.standalone && } + {!sessionWindow.standalone && } void defaultHostConnections.refreshConnections()} /> - { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} /> + {!sessionWindow.standalone && { setWorkHubActive(true); setNavSelection({ section: 'sessions' }); }} onDisabled={exitWorkHub} />} sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} onOpenSearchModal={openSearch} workbar={{ togglePosition: workbarTogglePosition }} @@ -1244,7 +1162,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 && }