Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2ed26ce
feat(desktop): open sessions in dedicated windows
YayoiNanoka Oct 13, 2026
061d827
fix(desktop): limit session state to in-memory window handoff
YayoiNanoka Oct 14, 2026
e6402d1
test(desktop): use the session window public entry
YayoiNanoka Oct 14, 2026
c718c45
fix(desktop): clarify the return-to-main window button
YayoiNanoka Oct 14, 2026
01ac9f8
fix(desktop): open session windows near the main window
YayoiNanoka Oct 14, 2026
73c72ba
fix(ci): register session window surfaces and TSX tests
YayoiNanoka Oct 14, 2026
18ce2a7
Merge upstream main and refresh UI inventory totals
YayoiNanoka Oct 14, 2026
015ac09
test(desktop): align window lifecycle and broadcast contracts
YayoiNanoka Oct 14, 2026
2b1f4ee
fix(desktop): keep auxiliary window file dialogs available
YayoiNanoka Oct 14, 2026
d14c0c5
test(desktop): wire workspace ownership in native browser smoke
YayoiNanoka Oct 14, 2026
3971ba2
fix(desktop): keep session workspace custody consistent across windows
YayoiNanoka Oct 15, 2026
56741b7
Merge upstream main into session window feature
YayoiNanoka Oct 15, 2026
bc60515
docs: regenerate surface inventory totals
YayoiNanoka Oct 16, 2026
f684aad
Merge upstream main into session windows
YayoiNanoka Oct 16, 2026
2ad4d8d
Merge upstream CI fixes into session windows
YayoiNanoka Oct 16, 2026
d459edc
refactor(desktop): move session shell rules into feature owners
YayoiNanoka Oct 17, 2026
61203ad
Merge session shell refactor prerequisite for window simplification
YayoiNanoka Oct 17, 2026
67bd90a
refactor(desktop): narrow session window handoff to text drafts
YayoiNanoka Oct 17, 2026
1dec541
refactor(desktop): remove unnecessary session shell extractions
YayoiNanoka Oct 17, 2026
50c04a0
refactor(desktop): separate window instances from app services
YayoiNanoka Oct 17, 2026
1184a62
test(desktop): remove trailing window-test whitespace
YayoiNanoka Oct 17, 2026
0569974
refactor(desktop): depend on standalone window management
YayoiNanoka Oct 17, 2026
97da192
chore(desktop): resolve session window conflicts with main
YayoiNanoka Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
"zh-tw-locale.spec.ts": {
"tests": 1,
"electron": "the locale is a persisted Host setting and only takes effect through a renderer reload"
},
"session-windows.spec.ts": {
"tests": 2,
"electron": "revealing an existing hidden BrowserWindow, native close and main recreation, real WebContentsView identity across renderer/main transfer, and restart with neither restored session windows nor persisted handoff drafts"
}
}
}
118 changes: 118 additions & 0 deletions apps/desktop/e2e/session-windows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { createServer } from 'node:http';
import type { ElectronApplication, Page } from '@playwright/test';
import { COMPOSER_INPUT, awaitSendReady, ensureSidebarExpanded, expect, test } from './fixtures';

async function createTask(page: Page, text: string): Promise<string> {
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<Page> {
const next = app.waitForEvent('window');
await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id);
const child = await next;
await expect(child.locator(COMPOSER_INPUT)).toBeVisible();
return child;
}

// Electron boundary: real WebContentsView identity and renderer/main handoff;
// a mock window cannot expose Chromium destruction during native reparenting.
test('detaching and returning keeps the same native browser and reveals a hidden owner', async ({ sessionLocalWindow }) => {
const { page, app } = sessionLocalWindow;
const id = await createTask(page, 'multi-window browser continuity');
await page.locator(COMPOSER_INPUT).fill('draft travelling with the session');
const server = createServer((_request, response) => response.end('<!doctype html><title>Window handoff</title><input id="state" value="initial">'));
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
if (!address || typeof address === 'string') throw new Error('No server address');
const url = `http://127.0.0.1:${address.port}/`;
await page.evaluate(async ({ id, url }) => { window.maka.browser.setActiveSession(id); await window.maka.browser.navigate(id, url); }, { id, url });
const nativePageId = await app.evaluate(async ({ webContents }, url) => {
const native = webContents.getAllWebContents().find((contents) => contents.getURL() === url);
if (!native) throw new Error('Native browser page not found');
await native.executeJavaScript('document.querySelector("input").value = "preserved native state"');
return native.id;
}, url);
const child = await detach(app, page, id);
await expect(child.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session');
await expect(page.locator(COMPOSER_INPUT)).toHaveText('');
const childNative = await app.browserWindow(child);
await childNative.evaluate((window) => window.hide());
await expect.poll(() => childNative.evaluate((window) => window.isVisible())).toBe(false);
await page.evaluate((sessionId) => window.maka.appWindow.openSessionWindow(sessionId), id);
// Inactive E2E fixtures reveal without taking OS focus; active-mode
// minimized restoration is covered by the production reveal-gate test.
await expect.poll(() => childNative.evaluate((window) => window.isVisible())).toBe(true);
await child.getByRole('button', { name: '在主窗口打开', exact: true }).click();
await expect.poll(() => child.isClosed()).toBe(true);
await expect(page.locator(COMPOSER_INPUT)).toHaveText('draft travelling with the session');
expect(await app.evaluate(async ({ webContents }, nativeId) => {
const native = webContents.fromId(nativeId);
return native?.executeJavaScript('document.querySelector("input").value');
}, nativePageId)).toBe('preserved native state');
} finally { await new Promise<void>((resolve) => server.close(() => resolve())); }
});

// Electron boundary: closing/recreating real BrowserWindows and app restart,
// including in-memory handoff between renderers and a fresh main-process lifetime.
test('closing main keeps the session window alive and restart opens only main', async ({ sessionLocalWindow }) => {
const { page, app, restart } = sessionLocalWindow;
const id = await createTask(page, 'multi-window independent lifetime');
const child = await detach(app, page, id);
await child.locator(COMPOSER_INPUT).fill('draft moving back to main');
const mainNative = await app.browserWindow(page);
await mainNative.evaluate((window) => window.close());
await expect.poll(() => page.isClosed()).toBe(true);
expect(child.isClosed()).toBe(false);
const reopened = app.waitForEvent('window');
await child.getByRole('button', { name: '在主窗口打开', exact: true }).click();
const main = await reopened;
await expect(main.locator(COMPOSER_INPUT)).toHaveText('draft moving back to main');
const secondChild = await detach(app, main, id);
await expect(secondChild.locator(COMPOSER_INPUT)).toHaveText('draft moving back to main');
// Closing a child discards its local drafts; retention is intentionally outside this version.
await secondChild.locator(COMPOSER_INPUT).fill('');
const childNative = await app.browserWindow(secondChild);
await childNative.evaluate((window) => window.close());
await expect.poll(() => secondChild.isClosed()).toBe(true);
await ensureSidebarExpanded(main);
await main.locator(`[data-session-id=${JSON.stringify(id)}]`).click();
await expect(main.locator(COMPOSER_INPUT)).toHaveText('');
await detach(app, main, id);
let restartedApp: ElectronApplication | undefined;
const restored = await restart(async (next) => { restartedApp = next; });
expect((await restored.evaluate(() => window.maka.appWindow.context())).kind).toBe('main');
await expect.poll(async () => {
const pages = restartedApp!.windows().filter((window) => !window.isClosed());
const contexts = await Promise.all(pages.map((window) => window.evaluate(() => window.maka?.appWindow?.context()).catch(() => undefined)));
return contexts.filter((context) => context?.kind === 'session').length;
}).toBe(0);
await ensureSidebarExpanded(restored);
await restored.locator(`[data-session-id=${JSON.stringify(id)}]`).click();
await expect(restored.locator(COMPOSER_INPUT)).toHaveText('');
});
33 changes: 22 additions & 11 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@
"ConversationMessageConsumer",
"ConversationProvider",
"ConversationTranscriptRegion",
"ConversationWindowState",
"NEW_TASK_PENDING_KEY",
"PlanExecutionSurface",
"PlanProvider",
Expand Down Expand Up @@ -456,8 +457,9 @@
},
"src/renderer/features/session-navigation/index.ts": {
"appShell": [
"SessionNavigationCommands",
"SessionNavigationProvider",
"createSessionOpenCommand",
"createSessionNavigationCommands",
"sessionRailLayoutStore",
"useSessionNavigationReads"
],
Expand All @@ -474,6 +476,16 @@
"SessionSettingsServicesProvider"
]
},
"src/renderer/features/session-windows/index.ts": {
"appShell": [
"SessionWindowBinding",
"SessionWindowEmptyState",
"SessionWindowRoot"
],
"composition": [
"SessionWindowServicesProvider"
]
},
"src/renderer/features/storage-usage/index.ts": {
"appShell": [
"ArchiveRetentionNotices"
Expand Down Expand Up @@ -535,7 +547,7 @@
"@maka/ui": 1
},
"importSpecifiers": 0,
"nonTriviaTokens": 122
"nonTriviaTokens": 117
},
"src/renderer/app-shell-command-actions.ts": {
"importDeclarations": 4,
Expand All @@ -559,7 +571,7 @@
"react": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 2143
"nonTriviaTokens": 2117
},
"src/renderer/app-shell-copy.ts": {
"importDeclarations": 1,
Expand Down Expand Up @@ -621,7 +633,7 @@
"react": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 843
"nonTriviaTokens": 841
},
"src/renderer/app-shell-overlays.tsx": {
"importDeclarations": 4,
Expand Down Expand Up @@ -650,10 +662,10 @@
"nonTriviaTokens": 780
},
"src/renderer/app-shell.tsx": {
"importDeclarations": 34,
"importDeclarations": 33,
"bridgePaths": {},
"environmentCapabilities": {
"window.requestAnimationFrame": 3
"window.requestAnimationFrame": 2
},
"hookCalls": {
"useActiveExecutionBoundary": 1,
Expand All @@ -663,8 +675,7 @@
"useAppShellPersistenceEffects": 1,
"useAppShellProjectContext": 1,
"useAppShellSessionWorkspace": 1,
"useLayoutEffect": 1,
"useRef": 4,
"useRef": 3,
"useSessionNavigationReads": 1,
"useSessionSettingIntent": 1,
"useShellAppearance": 1,
Expand Down Expand Up @@ -708,6 +719,7 @@
"./features/session-collaboration": 1,
"./features/session-navigation": 1,
"./features/session-settings": 1,
"./features/session-windows/index.js": 1,
"./features/storage-usage/index.js": 1,
"./features/task-entry": 1,
"./features/workbar": 1,
Expand All @@ -734,13 +746,12 @@
"@astryxdesign/core/AppShell": 1,
"@maka/core/onboarding-milestone": 1,
"@maka/core/session": 1,
"@maka/core/session-revisions": 1,
"@maka/core/ui-locale": 1,
"@maka/ui": 1,
"react": 1
},
"importSpecifiers": 43,
"nonTriviaTokens": 7274
"importSpecifiers": 41,
"nonTriviaTokens": 7244
},
"src/renderer/use-app-shell-session-list.ts": {
"importDeclarations": 3,
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/scripts/workhub-browser-presentation-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down
52 changes: 49 additions & 3 deletions apps/desktop/src/main/__tests__/browser-ipc-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,20 @@ test('browser IPC isolates owned renderer documents and their native parents', a
await controller?.dispose();
},
};
const workspaceRenderers = new Set<unknown>([main]);
let sessionOwner: unknown = main;
const mainWindowController = {
isWorkspaceRenderer: (contents: unknown) => workspaceRenderers.has(contents),
getBrowserViews: (resolve: typeof parentResolver) => {
parentResolver = resolve;
return manager;
},
isMainRenderer: (contents: Electron.WebContents) => contents === main as unknown as Electron.WebContents,
};
const parkingParent = { getVisible: () => false } as Electron.View;
const browserIpc = registerBrowserIpc({
parkingParent,
canUseSession: (contents, id) => !id.includes(WORKHUB_COORDINATION_SESSION_ID) && (!id.includes('main-session') || contents === sessionOwner),
mainWindowController: mainWindowController as never,
auxiliaryWindowRegistry: {
rendererParent: (contents: Electron.WebContents) =>
Expand Down Expand Up @@ -358,8 +364,7 @@ test('browser IPC isolates owned renderer documents and their native parents', a
assert.equal(controllers.get(mainKey), mainController, 'destroying WorkHub preserves the main browser session');
assert.equal(mainController.disposed, false);

// Closing Main destroys its WebContents before selection cleanup runs.
// Electron rejects fromWebContents on that destroyed native object.
// Collapsing the browser keeps the selected Session's background lease.
emit('browser:active-session', main, scope, 'main-session', 'main-document', 6);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 6);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: null }, 'main-document', 6);
Expand Down Expand Up @@ -389,10 +394,51 @@ test('browser IPC isolates owned renderer documents and their native parents', a
resolveNext({ scope, sessionId: 'replacement' });
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(await browserViewHost().canDrive(mainKey, 'observe'), false);

// Transfer the selected page after the pending-selection scenario settles.
emit('browser:active-session', main, scope, 'main-session', 'main-document', 13);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 13);
const child = new FakeRenderer('child-frame');
const childWindow = new FakeWindow();
const childParent = { getVisible: () => true } as Electron.View;
childWindow.contentView = childParent;
windows.set(child, childWindow);
workspaceRenderers.add(child);
emit('browser:document-ready', child, 'child-document');
emit('browser:active-session', child, scope, WORKHUB_COORDINATION_SESSION_ID, 'child-document', 1);
await invoke('browser:navigate', child, scope, WORKHUB_COORDINATION_SESSION_ID, 'https://forbidden-presenter.example/');
assert.equal(controllers.has(coordinationKey), false, 'a child cannot recreate or present the WorkHub Coordination browser');
emit('browser:document-ready', child, 'child-document-owning-session');
sessionOwner = child;
browserIpc.transferSessions([JSON.stringify([scope.hostId, 'main-session'])], main as never, child as never);
emit('browser:active-session', child, scope, 'main-session', 'child-document-owning-session', 1);
emit('browser:setViewport', child, scope, { sessionId: 'main-session', rect: mainRect }, 'child-document-owning-session', 1);
emit('browser:active-session', main, scope, 'main-session', 'main-document', 14);
emit('browser:setViewport', main, scope, { sessionId: 'main-session', rect: mainRect }, 'main-document', 14);
await invoke('browser:navigate', main, scope, 'main-session', 'https://late-source.example/');
assert.equal(mainController.parent, childParent, 'late source selection cannot steal the destination page');
assert.deepEqual(mainController.navigations, ['https://main.example/']);
childWindow.minimized = true;
browserIpc.refreshVisibility();
assert.equal(await browserViewHost().canDrive(mainKey, 'mutate'), true, 'the selected child follows the same background policy as Main');
emit('browser:hide-active-session', child, 'child-document-owning-session', 2);
assert.equal(await browserViewHost().canDrive(mainKey, 'mutate'), false, 'clearing the child selection revokes background access after transfer');
childWindow.minimized = false;
emit('browser:active-session', child, scope, 'main-session', 'child-document-owning-session', 3);
emit('browser:setViewport', child, scope, { sessionId: 'main-session', rect: mainRect }, 'child-document-owning-session', 3);
// Closing Main destroys its WebContents before selection cleanup runs.
// Electron rejects fromWebContents on that destroyed native object.
assert.doesNotThrow(() => main.destroy());
await new Promise<void>((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();
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/main/__tests__/command-palette-retired.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
Loading