diff --git a/.github/assets/workhub-progress/card-dark.png b/.github/assets/workhub-progress/card-dark.png new file mode 100644 index 0000000000..69f3e17626 Binary files /dev/null and b/.github/assets/workhub-progress/card-dark.png differ diff --git a/.github/assets/workhub-progress/card-light.png b/.github/assets/workhub-progress/card-light.png new file mode 100644 index 0000000000..2d907e5fe6 Binary files /dev/null and b/.github/assets/workhub-progress/card-light.png differ diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 8fbc4f5394..b6fce56bdc 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -152,7 +152,21 @@ test('WorkHub uses its coordination model and shared attachment composer', async const close = await workhub.getByRole('button', { name: /^(隐藏|Hide)$/ }).boundingBox(); const input = await editor.boundingBox(); expect(close!.y).toBeLessThan(input!.y); - await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).click(); + await expect(workhub.locator('.workHubHistory')).toHaveCSS('opacity', '1'); + const collapseFrames = await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).evaluate((button) => new Promise<{ opacity: number; visible: boolean }[]>((resolve) => { + const history = document.querySelector('.workHubHistory')!; + const frames: { opacity: number; visible: boolean }[] = []; + const started = performance.now(); + const sample = () => { + const style = getComputedStyle(history); + frames.push({ opacity: Number(style.opacity), visible: style.visibility === 'visible' }); + if (performance.now() - started < 220) requestAnimationFrame(sample); + else resolve(frames); + }; + (button as HTMLButtonElement).click(); + requestAnimationFrame(sample); + })); + expect(collapseFrames.some((frame) => frame.visible && frame.opacity > 0 && frame.opacity < 1)).toBe(true); await expect(workhub.locator('.workHubHistory')).toBeHidden(); await expect(workhub.getByRole('button', { name: /滚动到底部|Scroll to bottom/ })).toHaveCount(0); await expect.poll(() => workhub.evaluate(() => window.innerHeight)).toBeLessThan(expandedHeight / 2); diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 3f0bc523bf..e0fe89deb6 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -61,6 +61,23 @@ test('WorkHub moves the same renderer and draft between the main window and floa await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'floating', floatingVisible: true }); await expect(editor).toHaveText('Keep this unsent WorkHub draft — typed during opening'); await expect(editor).toHaveAttribute('data-test-instance', marker); + // A reused native window is already painted when focus arrives. Replaying + // the intro here would visibly fade that frame out after it appeared. + await workhub.evaluate(() => { + const samples: string[] = []; + (window as Window & { workHubFocusSamples?: string[] }).workHubFocusSamples = samples; + window.maka.workHubPresentation.onFocusComposer(() => { + requestAnimationFrame(() => samples.push(getComputedStyle(document.querySelector('.workHubComposerSurface')!).opacity)); + }); + }); + for (let index = 0; index < 3; index++) { + await workhub.evaluate(() => window.maka.workHubPresentation.hide()); + await expect(page.locator('.workHubDockPlaceholder')).toBeHidden(); + await expect(workhub.locator('.workHubHistory')).toHaveCSS('pointer-events', 'auto'); + await expect(editor).toHaveAttribute('data-test-instance', marker); + await page.evaluate(() => window.maka.workHubPresentation.detach()); + await expect.poll(() => workhub.evaluate(() => (window as Window & { workHubFocusSamples?: string[] }).workHubFocusSamples)).toEqual(Array.from({ length: index + 1 }, () => '1')); + } await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: false } })); await expect(page.locator('.workHubDock')).toBeHidden(); await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ floatingVisible: false, shortcutRegistered: false }); @@ -85,8 +102,7 @@ test('WorkHub moves the same renderer and draft between the main window and floa await workhub.getByRole('button', { name: /展开对话|Expand conversation/ }).click(); await expect(workhub.locator('.workHubHistory')).toBeVisible(); await workhub.getByRole('button', { name: /^(Hide|隐藏|隱藏)$/ }).click(); - await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'floating', floatingVisible: false }); - await page.getByRole('button', { name: /^(Bring WorkHub back|收回工作台)$/ }).click(); + await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'docked', floatingVisible: false }); await expect(page.locator('.workHubDockPlaceholder')).toBeHidden(); await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'docked' }); await expect(editor).toHaveText('Keep this unsent WorkHub draft — typed during opening'); @@ -107,4 +123,36 @@ test('WorkHub moves the same renderer and draft between the main window and floa await workhub.getByRole('button', { name: '发送', exact: true }).click(); await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'true'); await expect(workhub.locator('article').filter({ hasText: 'Keep this unsent WorkHub draft' }).first()).toBeVisible(); + // WorkHub owns foreground focus while native input targets Desktop behind it. + // Playwright otherwise forces document.hasFocus() true for every page. + const main = await app.browserWindow(page); + const cdp = await app.context().newCDPSession(page); + await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await main.evaluate(window => window.showInactive()); + await page.evaluate(() => window.maka.workHubPresentation.detach()); + await expect.poll(() => main.evaluate(window => window.isFocused())).toBe(false); + expect(await page.evaluate(() => document.hasFocus())).toBe(false); + const result = await app.evaluate(async ({ BrowserWindow }, mainId) => { + const require = process.getBuiltinModule('module').createRequire(process.cwd() + '/package.json'); + const { WorkHubUi } = require('./dist/main/workhub-ui.js'); + const main = BrowserWindow.fromId(mainId)!; + let focused = 0; + const onFocus = () => focused++; + main.on('focus', onFocus); + const ui = new WorkHubUi(() => main.webContents, () => main.webContents.executeJavaScript('window.maka.settings.getClient()'), () => {}, async () => ''); + try { + const signal = new AbortController().signal; + await ui.begin(signal); + await ui.execute({ kind: 'open', area: 'newTask' }, signal); + const observation = await ui.observe(); + const editor = observation.controls.find((item: any) => item.editable && item.name.match(/消息输入框|Message/)); + if (!editor) throw new Error('Background navigation did not expose the composer'); + await ui.execute({ kind: 'type', ref: editor.ref, text: '后台输入验证' }, signal); + await main.webContents.executeJavaScript("window.__backgroundKey = undefined; document.addEventListener('keydown', event => { window.__backgroundKey = { key: event.key, trusted: event.isTrusted }; }, { once: true })"); + await ui.execute({ kind: 'key', ref: editor.ref, key: 'ArrowLeft' }, signal); + return { focused, mainFocused: main.isFocused(), key: await main.webContents.executeJavaScript('window.__backgroundKey'), value: await main.webContents.executeJavaScript('document.querySelector("[contenteditable=true]")?.textContent') }; + } finally { main.removeListener('focus', onFocus); } + }, await main.evaluate(window => window.id)); + expect(result).toEqual({ focused: 0, mainFocused: false, key: { key: 'ArrowLeft', trusted: true }, value: '后台输入验证' }); + await expect(workhub.locator('[contenteditable=true]')).toBeEmpty(); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 3ef2c9e555..d24cb05c29 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -710,7 +710,7 @@ "nonTriviaTokens": 1395 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 71, + "importDeclarations": 70, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -863,7 +863,6 @@ "./use-turn-action-registry": 1, "./workspace-readiness-recovery": 1, "@astryxdesign/core/AppShell": 1, - "@astryxdesign/core/Button": 1, "@maka/core/onboarding-milestone": 1, "@maka/core/session": 1, "@maka/core/session-revisions": 1, @@ -872,8 +871,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 109, - "nonTriviaTokens": 13746 + "importSpecifiers": 108, + "nonTriviaTokens": 13697 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -4657,7 +4656,7 @@ "react-dom/client": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 268 + "nonTriviaTokens": 260 } }, "rootDebtClosure": { diff --git a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts index 28c3f157d1..0dcdeda897 100644 --- a/apps/desktop/src/main/__tests__/client-settings-effects.test.ts +++ b/apps/desktop/src/main/__tests__/client-settings-effects.test.ts @@ -183,3 +183,22 @@ test('an explicit dark preference ignores what the OS reports', async () => { await effects.refresh(false); assert.deepEqual(applied, ['ink']); }); + + +test('applies WorkHub enable state from the supplied snapshot without another storage read', async () => { + const applied: boolean[] = []; + const effects = createClientSettingsEffects({ + settingsStore: { get: async () => { throw new Error('unexpected storage read'); } }, + applyWorkHub: async (enabled) => { applied.push(enabled); }, + applyKeepSystemAwake: async () => undefined, + applyBotSettings: async () => undefined, + applyAppIcon: async () => undefined, + systemPrefersDark: () => false, + observeLocale: () => undefined, + emitExternalChanged: () => undefined, + }); + const settings = createDefaultSettings(); + await effects.apply({ ...settings, workHub: { enabled: true } }, true); + await effects.apply({ ...settings, workHub: { enabled: false } }, true); + assert.deepEqual(applied, [true, false]); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-control.test.ts b/apps/desktop/src/main/__tests__/workhub-control.test.ts index 2b720fb6b1..407916ef4a 100644 --- a/apps/desktop/src/main/__tests__/workhub-control.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-control.test.ts @@ -106,18 +106,18 @@ test("control rejects ordinary Sessions, stale turns and switched Host epochs be await assert.rejects( async () => h.tool.impl( - { request: { operation: "observe" } }, + { status: "Checking Maka", request: { operation: "observe" } }, { ...h.ctx(), sessionId: "ordinary" }, ), /Only the active WorkHub/, ); await assert.rejects( - async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx("stale")), + async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe" } }, h.ctx("stale")), /Inactive turn/, ); h.setCurrent(false); await assert.rejects( - async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx()), + async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe" } }, h.ctx()), /Host changed/, ); }); @@ -148,7 +148,7 @@ test("partial batches never replay input and the failure budget belongs to the r const act = async (refs = ["current"]) => (await h.tool.impl( { - request: { + status: "Checking Maka", request: { operation: "act", actions: refs.map((ref) => ({ kind: "click", ref })), }, @@ -157,6 +157,7 @@ test("partial batches never replay input and the failure budget belongs to the r )) as { completed: unknown[]; recoverable: boolean; + error: string; inputDispatched: boolean; requiresNewTurn?: boolean; }; @@ -164,11 +165,15 @@ test("partial batches never replay input and the failure budget belongs to the r assert.equal(partial.completed.length, 1); assert.equal(attempts, 2); assert.equal(partial.inputDispatched, false); - assert.equal((await h.command("snapshot")).error, "Control changed"); + assert.equal(partial.error, "Control changed", "the model still receives the recoverable error"); + assert.equal((await h.command("snapshot")).error, undefined, "recoverable tool failures are not conversation errors"); failAfterInput = true; assert.equal((await act()).inputDispatched, true); assert.equal((await act()).recoverable, false); - assert.equal((await act()).requiresNewTurn, true); + const blocked = await act(); + assert.equal(blocked.requiresNewTurn, true); + assert.equal(blocked.error, "Control changed"); + assert.equal((await h.command("snapshot")).phase, "error"); assert.equal(attempts, 4); h.control.complete( desktopSessionResourceKey({ @@ -178,13 +183,14 @@ test("partial batches never replay input and the failure budget belongs to the r ); h.setTurn("new-turn"); assert.equal((await act()).recoverable, true); + assert.equal((await h.command("snapshot")).error, undefined, "a new turn does not inherit the previous terminal error"); }); test("takeover cancels waiting and interrupts the exact owning turn", async (t) => { const h = harness(); t.after(() => h.control.close()); const pending = assert.rejects( - async () => h.tool.impl({ request: { operation: "observe", waitMs: 5000 } }, h.ctx()), + async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe", waitMs: 5000 } }, h.ctx()), /abort|User took control/i, ); await new Promise((resolve) => setImmediate(resolve)); @@ -192,7 +198,7 @@ test("takeover cancels waiting and interrupts the exact owning turn", async (t) await pending; assert.deepEqual(h.interrupted, ["turn"]); await assert.rejects( - async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx()), + async () => h.tool.impl({ status: "Checking Maka", request: { operation: "observe" } }, h.ctx()), /User took control/, ); }); @@ -408,7 +414,7 @@ test("window preparation cannot admit input after takeover or a Host switch", as async () => h.tool.impl( { - request: { + status: "Checking Maka", request: { operation: "act", actions: [{ kind: "navigate", section: "general" }], }, @@ -456,7 +462,7 @@ test("takeover stops an action without exposing its internal abort reason as a c signal.addEventListener('abort', () => reject(signal.reason), { once: true }); }); }); - const action = h.tool.impl({ request: { operation: "act", actions: [{ kind: "navigate", section: "general" }] } }, h.ctx()); + const action = h.tool.impl({ status: "Checking Maka", request: { operation: "act", actions: [{ kind: "navigate", section: "general" }] } }, h.ctx()); await executing; await h.command('stop'); const result = await action as { interrupted: boolean; error: string }; @@ -484,7 +490,7 @@ test("cancelling undo is a normal completion for the renderer", async (t) => { signal.addEventListener('abort', () => reject(signal.reason), { once: true }); }); }); - await h.tool.impl({ request: { operation: 'act', actions: [{ kind: 'set', target: 'theme', value: createDefaultSettings().appearance.theme }] } }, h.ctx()); + await h.tool.impl({ status: "Checking Maka", request: { operation: 'act', actions: [{ kind: 'set', target: 'theme', value: createDefaultSettings().appearance.theme }] } }, h.ctx()); h.control.complete(desktopSessionResourceKey({ ...scope, sessionId: WORKHUB_COORDINATION_SESSION_ID })); const undoing = h.command('undo'); await executing; @@ -493,3 +499,20 @@ test("cancelling undo is a normal completion for the renderer", async (t) => { assert.equal((await h.command('snapshot')).error, undefined); assert.equal((await h.command('snapshot')).phase, 'paused'); }); + + +test('control requires a short status before preparing the window and publishes the same text', async (t) => { + let prepared = 0; + const h = harness(async () => { prepared++; }); + t.after(() => h.control.close()); + t.mock.method(WorkHubUi.prototype, 'observe', async () => ({ section: null, language: 'en', theme: 'light', accessibility: '', controls: [] })); + for (const status of [undefined, '', ' ', 'a'.repeat(81), 'first\nsecond']) { + await assert.rejects(async () => h.tool.impl({ status, request: { operation: 'observe' } }, h.ctx())); + } + assert.equal(prepared, 0); + await h.tool.impl({ status: ' 正在检查项目设置 ', request: { operation: 'observe' } }, h.ctx()); + assert.equal((await h.command('snapshot')).status, '正在检查项目设置'); + assert.equal(prepared, 1); + h.control.complete(desktopSessionResourceKey({ ...scope, sessionId: WORKHUB_COORDINATION_SESSION_ID })); + assert.equal((await h.command('snapshot')).status, undefined); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts index 9ae830ec80..c5e7968e2b 100644 --- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -30,9 +30,10 @@ import type { createWorkHubPresentation } from '../workhub-presentation.js'; const source = fileURLToPath(new URL('../../../src/main/workhub-presentation.ts', import.meta.url)); -async function harness(animate = false) { +async function harness(animate = false, displayFrequency = 60) { let enabled = true; let mainRequests = 0; + let mainAvailable = true; let opening: Promise | undefined; const openingStarted = deferred(); let now = 0; @@ -54,6 +55,7 @@ async function harness(animate = false) { const errors: unknown[] = []; let handler: ((event: unknown, command: string, payload?: unknown) => Promise) | undefined; let unregistered = false; + let shortcut: (() => void) | undefined; let registeredViews = 0; let releasedViews = 0; let pointerDisplay = { x: 0, y: 0, width: 1200, height: 900 }; @@ -65,6 +67,8 @@ async function harness(animate = false) { isDestroyed() { return this.destroyed; } send(channel: string, ...args: unknown[]) { this.sent.push([channel, ...args]); } getZoomFactor() { return 1; } + backgroundThrottling = true; + setBackgroundThrottling(allowed: boolean) { this.backgroundThrottling = allowed; } captures = 0; async capturePage() { this.captures++; @@ -97,11 +101,14 @@ async function harness(animate = false) { isMinimized() { return false; } getContentBounds() { return this.bounds; } getBounds() { return this.bounds; } - setBounds(bounds: typeof this.bounds) { this.bounds = bounds; } + setBounds(bounds: typeof this.bounds) { this.bounds = bounds; this.emit('resize'); } setVisibleOnAllWorkspaces() {} setMaximizable() {} - show() { this.visible = true; } + show() { this.visible = true; this.emit('show'); } + showInactive() { this.visible = true; } hide() { this.visible = false; } + resizable = true; + setResizable(value: boolean) { this.resizable = value; } focused = 0; focus() { this.focused++; } restore() {} @@ -114,29 +121,30 @@ async function harness(animate = false) { setVisible(value: boolean) { this.visible = value; } getVisible() { return this.visible; } setBackgroundColor() {} - setBounds() {} + boundsUpdates: Electron.Rectangle[] = []; + setBounds(bounds: Electron.Rectangle) { this.boundsUpdates.push(bounds); } } const output = await build({ entryPoints: [source], bundle: true, write: false, format: 'cjs', platform: 'node', external: ['electron'] }); const module = { exports: {} as { createWorkHubPresentation: typeof createWorkHubPresentation } }; const nodeRequire = createRequire(import.meta.url); runInNewContext(output.outputFiles[0]!.text, { module, exports: module.exports, console, process, URL, Error, - Date: class extends Date { static now() { return now; } }, + performance: { now: () => now }, setTimeout: (callback: () => void, delay: number) => { timers.set(++timerId, { at: now + delay, callback }); return timerId; }, clearTimeout: (id: number) => timers.delete(id), require: (name: string) => name === 'electron' ? { BrowserWindow: FakeWindow, WebContentsView: FakeView, systemPreferences: { getAnimationSettings: () => ({ prefersReducedMotion: !animate }) }, - globalShortcut: { register: () => true, unregister: () => { unregistered = true; } }, + globalShortcut: { register: (_accelerator: string, callback: () => void) => { shortcut = callback; return true; }, unregister: () => { unregistered = true; } }, ipcMain: { handle: (_channel: string, callback: typeof handler) => { handler = callback; }, removeHandler: () => { handler = undefined; } }, - screen: { getCursorScreenPoint: () => ({ x: pointerDisplay.x, y: pointerDisplay.y }), getDisplayNearestPoint: () => ({ workArea: pointerDisplay }), getDisplayMatching: () => ({ workArea: { x: 0, y: 0, width: 1200, height: 900 } }) }, + screen: { getCursorScreenPoint: () => ({ x: pointerDisplay.x, y: pointerDisplay.y }), getDisplayNearestPoint: () => ({ workArea: pointerDisplay }), getDisplayMatching: () => ({ displayFrequency, workArea: { x: 0, y: 0, width: 1200, height: 900 } }) }, } : nodeRequire(name), }); const main = new FakeWindow(); const controller = module.exports.createWorkHubPresentation({ - mainWindow: () => main as unknown as Electron.BrowserWindow, - isEnabled: async () => enabled, - ensureMainWindow: async () => { mainRequests++; openingStarted.resolve(); await opening; return main as unknown as Electron.BrowserWindow; }, + mainWindow: () => mainAvailable ? main as unknown as Electron.BrowserWindow : undefined, + isEnabled: () => enabled, + ensureMainWindow: async () => { mainRequests++; openingStarted.resolve(); await opening; mainAvailable = true; return main as unknown as Electron.BrowserWindow; }, mainModuleDirectory: '/app/dist/main', preloadPath: '/app/dist/preload/preload.cjs', onError: (error) => errors.push(error), onViewCreated: () => { registeredViews++; return () => { releasedViews++; }; }, @@ -144,7 +152,7 @@ async function harness(animate = false) { controller.attachMainWindow(main as unknown as Electron.BrowserWindow); controller.registerIpc(); const command = (sender: Contents, name: string, payload?: unknown) => handler!({ sender, senderFrame: sender.mainFrame }, name, payload); - return { get mainRequests() { return mainRequests; }, controller, main, windows, views, errors, command, advance, setEnabled: (value: boolean) => { enabled = value; }, deferOpening: (value: Promise) => { opening = value; return openingStarted.promise; }, movePointer: (display: typeof pointerDisplay) => { pointerDisplay = display; }, registrations: () => [registeredViews, releasedViews], handler: () => handler, unregistered: () => unregistered }; + return { setMainAvailable: (value: boolean) => { mainAvailable = value; }, shortcut: () => shortcut!(), get mainRequests() { return mainRequests; }, controller, main, windows, views, errors, command, advance, setEnabled: (value: boolean) => { enabled = value; }, deferOpening: (value: Promise) => { opening = value; return openingStarted.promise; }, movePointer: (display: typeof pointerDisplay) => { pointerDisplay = display; }, registrations: () => [registeredViews, releasedViews], handler: () => handler, unregistered: () => unregistered }; } test('yields the docked native view to main-window overlays without replacing the conversation', async () => { @@ -272,7 +280,9 @@ test('animates from the current height, keeps the bottom anchored and survives r h.advance(160); assert.equal(floating.bounds.height, 720); await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 }); - h.advance(80); + h.advance(18); + assert.ok(floating.bounds.height > 700, 'collapse starts gently while the visible conversation fades'); + h.advance(62); const intermediate = floating.bounds.height; assert.ok(intermediate > 110 && intermediate < 720); await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 110 }); @@ -291,6 +301,7 @@ test('animates from the current height, keeps the bottom anchored and survives r test('reparents one live conversation across docking, floating, hide and main-window close', async () => { const h = await harness(); + h.main.show(); await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 100, y: 40, width: 900, height: 760 } }); const view = h.views[0]!; await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 96 }); @@ -312,6 +323,8 @@ test('reparents one live conversation across docking, floating, hide and main-wi await assert.rejects(h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: Number.NaN }), /Invalid WorkHub conversation layout/); await h.command(view.webContents, 'hide'); assert.equal(floating.visible, false); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.ok(h.main.children.has(view)); assert.equal(view.webContents.destroyed, false); await h.command(view.webContents, 'dock'); assert.ok(h.main.children.has(view) && !floating.children.has(view)); @@ -330,7 +343,8 @@ test('reparents one live conversation across docking, floating, hide and main-wi assert.equal(floating.visible, false); assert.equal(h.main.visible, false, 'hiding the floating window must not show Desktop'); assert.equal(h.main.focused, mainFocusCount, 'the shortcut never focuses Desktop'); - assert.ok(floating.children.has(view)); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.ok(floating.children.has(view), 'a hidden Desktop defers native reparenting'); await h.controller.toggle(); assert.equal(floating.visible, true); h.movePointer({ x: 1600, y: -900, width: 1000, height: 800 }); @@ -404,30 +418,45 @@ test('application broadcasts reach registered auxiliaries once and stop after re assert.deepEqual(messages, ['settings:changed']); }); -test('control preparation floats the live conversation and focuses the main window without resetting an existing float', async () => { +test('control shows a passive card only after it is painted and preserves manual conversation geometry', async () => { const h = await harness(); await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 0, width: 1000, height: 800 } }); const view = h.views[0]!; - await h.controller.prepareControl(); + await h.controller.prepareControl('turn-one'); const floating = h.windows[1]!; - assert.equal(h.controller.getSnapshot().placement, 'floating'); - assert.ok(floating.visible && floating.children.has(view)); - assert.equal(h.main.children.has(view), false); - assert.equal(h.main.focused, 1); + const request = h.controller.getSnapshot().progressRequest!; + assert.equal(typeof request, 'number'); + assert.equal(floating.visible, false, 'the old chat cannot flash before the progress card is painted'); + assert.equal(view.webContents.backgroundThrottling, false, 'hidden card preparation must be able to produce animation frames'); + assert.equal(floating.bounds.width, 360); + assert.equal(floating.bounds.height, 112); + assert.equal(floating.resizable, false); + assert.equal(floating.bounds.x + floating.bounds.width / 2, 600); + assert.equal(floating.bounds.y + floating.bounds.height, 804); + await h.command(view.webContents, 'progress-ready', request); + assert.equal(floating.visible, true); + assert.equal(view.webContents.backgroundThrottling, true, 'restore normal throttling after showing the card'); + assert.equal(floating.focused, 0); + assert.equal(h.main.focused, 0, 'control does not activate the main window'); + assert.equal(h.mainRequests, 0, 'the focus-or-create fallback must not run for an existing window'); + assert.equal(h.main.visible, false, 'control does not reveal a hidden main window'); + assert.equal(view.webContents.sent.some(([channel]) => channel.endsWith('focus-composer')), false); + await h.command(view.webContents, 'ready'); + await h.command(view.webContents, 'show-conversation'); + assert.equal(h.controller.getSnapshot().progressRequest, undefined); + assert.equal(floating.bounds.width, 520); + assert.equal(floating.bounds.height, 720); + assert.equal(floating.resizable, true); + assert.ok(view.webContents.sent.some(([channel, expand]) => channel.endsWith('focus-composer') && expand === true)); floating.setBounds({ x: 120, y: 130, width: 520, height: 650 }); - const floatingFocus = floating.focused; - await h.controller.prepareControl(); + const focused = floating.focused; + await h.controller.prepareControl('turn-one'); assert.deepEqual(floating.bounds, { x: 120, y: 130, width: 520, height: 650 }); - assert.equal(floating.focused, floatingFocus, 'a later control call must not refocus the composer'); - assert.equal(h.main.focused, 2); + assert.equal(floating.focused, focused, 'control leaves a manually opened chat alone'); assert.equal(h.views.length, 1); - await h.command(view.webContents, 'hide'); - await h.controller.prepareControl(); - assert.equal(floating.visible, true); h.controller.dispose(); }); - test('control preparation refuses disabled presentation before opening and rechecks a pending open', async () => { const h = await harness(); h.setEnabled(false); @@ -438,6 +467,7 @@ test('control preparation refuses disabled presentation before opening and reche h.setEnabled(true); const opened = deferred(); const opening = h.deferOpening(opened.promise); + h.setMainAvailable(false); const control = h.controller.prepareControl(); await opening; h.setEnabled(false); @@ -504,3 +534,224 @@ test('all WorkHub entries obey the client enable setting and disabling retains t assert.deepEqual(h.registrations(), [1, 0]); h.controller.dispose(); }); + + +test('prewarms once and the shortcut shows and hides synchronously', async () => { + const h = await harness(); + await h.controller.refreshSettings(); + const floating = h.windows[1]!; + const view = h.views[0]!; + assert.equal(floating.visible, false); + assert.equal(view.visible, false); + assert.ok(floating.children.has(view)); + await h.controller.refreshSettings(); + assert.equal(h.windows.length, 2); + assert.equal(h.views.length, 1); + h.shortcut(); + assert.equal(floating.visible, true, 'show happens in the shortcut callback, without an async queue'); + h.shortcut(); + assert.equal(floating.visible, false); + assert.equal(h.mainRequests, 0); + assert.equal(h.main.focused, 0); + h.controller.dispose(); +}); + +test('a pending backdrop capture and older hide cannot delay or undo the shortcut', async () => { + const h = await harness(); + await h.controller.refreshSettings(); + const host = { visible: true, rect: { x: 200, y: 40, width: 800, height: 760 } }; + await h.command(h.main.webContents, 'host', host); + h.main.show(); + const view = h.views[0]!; + await h.command(view.webContents, 'ready'); + const started = deferred(); + const capture = deferred<{ toDataURL(): string }>(); + view.webContents.capturePage = () => { started.resolve(); return capture.promise; }; + const occlude = h.command(h.main.webContents, 'host', { ...host, occluded: true }); + await started.promise; + const olderHide = h.command(view.webContents, 'hide'); + h.shortcut(); + const floating = h.windows[1]!; + assert.equal(floating.visible, true); + assert.ok(floating.children.has(view)); + capture.resolve({ toDataURL: () => 'data:image/png;base64,frame' }); + await Promise.all([occlude, olderHide]); + assert.equal(floating.visible, true, 'an older queued intent cannot hide the newer summon'); + assert.equal(view.visible, true); + h.controller.dispose(); +}); + +test('the shortcut supersedes a pending dock without waiting for the main window', async () => { + const h = await harness(); + await h.controller.refreshSettings(); + h.shortcut(); + const view = h.views[0]!; + const opened = deferred(); + const opening = h.deferOpening(opened.promise); + const docking = h.command(view.webContents, 'dock'); + await opening; + h.shortcut(); + h.shortcut(); + const floating = h.windows[1]!; + assert.equal(floating.visible, true); + opened.resolve(); + await docking; + assert.equal(h.controller.getSnapshot().placement, 'floating'); + assert.ok(floating.children.has(view)); + assert.equal(floating.visible, true); + assert.equal(h.main.focused, 0); + h.controller.dispose(); +}); + + +test('native resize callbacks do not submit duplicate view bounds and follow display cadence', async () => { + const h = await harness(true, 120); + await h.controller.show(); + const view = h.views[0]!; + await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 96 }); + const before = view.boundsUpdates.length; + h.advance(9); + assert.ok(view.boundsUpdates.length > before, 'a 120Hz display gets its next animation frame before 16ms'); + h.advance(231); + assert.equal(h.windows[1]!.bounds.height, 720); + for (let index = 1; index < view.boundsUpdates.length; index++) { + assert.notDeepEqual(view.boundsUpdates[index], view.boundsUpdates[index - 1]); + } + h.controller.dispose(); +}); + + +test('hiding returns the live view to Desktop and preserves floating geometry for the next summon', async () => { + const h = await harness(); + h.main.show(); + await h.controller.refreshSettings(); + const host = { visible: true, rect: { x: 200, y: 40, width: 800, height: 760 } }; + await h.command(h.main.webContents, 'host', host); + h.shortcut(); + const floating = h.windows[1]!; + const view = h.views[0]!; + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 144 }); + h.shortcut(); + assert.equal(floating.visible, false); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.ok(h.main.children.has(view)); + assert.equal(view.visible, true); + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 96 }); + h.shortcut(); + assert.equal(floating.visible, true); + assert.equal(floating.bounds.height, 144, 'Desktop geometry does not shrink the floating composer'); + assert.ok(floating.children.has(view)); + assert.equal(h.mainRequests, 0); + assert.equal(h.main.focused, 0); + assert.equal(h.views.length, 1); + assert.equal(h.windows.length, 2); + h.controller.dispose(); +}); + +test('hiding with Desktop closed keeps the conversation alive without reopening Desktop', async () => { + const h = await harness(); + await h.controller.show(); + const view = h.views[0]!; + h.main.destroy(); + await h.controller.toggle(); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.equal(h.windows[1]!.visible, false); + assert.ok(h.windows[1]!.children.has(view)); + assert.equal(view.webContents.destroyed, false); + await h.controller.toggle(); + assert.equal(h.windows[1]!.visible, true); + assert.equal(h.mainRequests, 0); + assert.equal(h.views.length, 1); + h.controller.dispose(); +}); + + +test('a hidden Desktop defers native docking until it shows', async () => { + const h = await harness(); + await h.controller.refreshSettings(); + const host = { visible: true, rect: { x: 200, y: 40, width: 800, height: 760 } }; + await h.command(h.main.webContents, 'host', host); + h.shortcut(); + const view = h.views[0]!; + const floating = h.windows[1]!; + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 144 }); + const before = view.boundsUpdates.length; + h.shortcut(); + await h.command(h.main.webContents, 'host', host); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.equal(floating.visible, false); + assert.ok(floating.children.has(view)); + assert.equal(view.boundsUpdates.length, before, 'hiding does not resize an invisible conversation'); + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 96 }); + h.shortcut(); + assert.equal(floating.bounds.height, 144); + h.shortcut(); + h.main.show(); + assert.ok(h.main.children.has(view), 'Desktop receives the conversation in its show callback'); + assert.equal(view.visible, true); + assert.equal(h.main.focused, 0); + assert.equal(h.mainRequests, 0); + assert.equal(h.views.length, 1); + h.controller.dispose(); +}); + + +test('control keeps a visible Desktop conversation docked and floats it when the host is hidden or occluded', async () => { + for (const hidden of [{ visible: false }, { occluded: true }]) { + const h = await harness(); + h.main.show(); + const host = { visible: true, rect: { x: 200, y: 40, width: 800, height: 760 } }; + await h.command(h.main.webContents, 'host', host); + const view = h.views[0]!; + await h.controller.prepareControl('turn'); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.equal(h.windows.length, 1, 'control does not create an unnecessary floating window'); + assert.ok(h.main.children.has(view)); + assert.equal(view.visible, true); + await h.command(h.main.webContents, 'host', { ...host, ...hidden }); + await h.controller.prepareControl('turn'); + assert.equal(h.controller.getSnapshot().placement, 'floating'); + assert.equal(typeof h.controller.getSnapshot().progressRequest, 'number'); + await h.command(view.webContents, 'progress-ready', h.controller.getSnapshot().progressRequest); + assert.ok(h.windows[1]!.visible && h.windows[1]!.children.has(view)); + assert.equal(h.views.length, 1); + h.controller.dispose(); + } +}); + + +test('closing progress suppresses the current turn and old paint acknowledgements cannot reopen it', async () => { + const h = await harness(); + await h.controller.prepareControl('turn-one'); + const view = h.views[0]!; + const first = h.controller.getSnapshot().progressRequest!; + assert.equal(view.webContents.backgroundThrottling, false); + await h.command(view.webContents, 'hide'); + assert.equal(view.webContents.backgroundThrottling, true, 'cancelling preparation restores normal background behavior'); + await h.command(view.webContents, 'progress-ready', first); + await h.controller.prepareControl('turn-one'); + assert.equal(h.controller.getSnapshot().progressRequest, undefined); + assert.equal(h.windows[1]!.visible, false); + await h.controller.prepareControl('turn-two'); + assert.notEqual(h.controller.getSnapshot().progressRequest, undefined); + h.controller.finishControl(); + await h.command(view.webContents, 'hide'); + await h.command(h.main.webContents, 'host', { visible: false, rect: { x: 0, y: 0, width: 0, height: 0 } }); + assert.equal(h.windows[1]!.visible, false); + h.controller.dispose(); +}); + +test('the shortcut opens the normal composer from progress without waiting for its paint', async () => { + const h = await harness(); + await h.controller.prepareControl('turn'); + const request = h.controller.getSnapshot().progressRequest!; + await h.controller.toggle(true); + const floating = h.windows[1]!; + assert.equal(h.controller.getSnapshot().progressRequest, undefined); + assert.equal(floating.visible, true); + assert.equal(floating.bounds.width, 520); + assert.equal(h.views[0]!.webContents.backgroundThrottling, true); + await h.command(h.views[0]!.webContents, 'progress-ready', request); + assert.equal(floating.bounds.width, 520); + h.controller.dispose(); +}); diff --git a/apps/desktop/src/main/client-settings-effects.ts b/apps/desktop/src/main/client-settings-effects.ts index 355b8831a3..82a1795f80 100644 --- a/apps/desktop/src/main/client-settings-effects.ts +++ b/apps/desktop/src/main/client-settings-effects.ts @@ -35,7 +35,7 @@ interface ClientSettingsEffectDependencies { readonly applyKeepSystemAwake: (enabled: boolean) => Promise; readonly applyBotSettings: (settings: AppSettings['botChat']) => Promise; readonly applyAppIcon: (icon: AppIconChoice) => Promise; - readonly applyWorkHub: () => Promise; + readonly applyWorkHub: (enabled: boolean) => Promise; /** * What the OS currently reports, read fresh on every pass. Injected * rather than imported so this module stays free of electron and keeps @@ -86,7 +86,7 @@ export function createClientSettingsEffects( ); const appIconChanged = nextAppIcon !== appIcon; dependencies.observeLocale(settings); - await dependencies.applyWorkHub(); + await dependencies.applyWorkHub(settings.workHub.enabled); if (keepAwakeChanged) { await dependencies.applyKeepSystemAwake(settings.system.keepSystemAwake); keepSystemAwake = settings.system.keepSystemAwake; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 85db56710b..df1792febf 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -901,7 +901,8 @@ const workHubRuntime = createWorkHubRuntime({ }); const workHubControl = createWorkHubControl({ ipcMain, - prepareWindow: () => workHubPresentation.prepareControl(), + prepareWindow: (turnId) => workHubPresentation.prepareControl(turnId), + finishControl: () => workHubPresentation.finishControl(), window: () => { const window = mainWindowController.browserWindow(); if (!window) throw new Error('Maka window is unavailable'); @@ -914,8 +915,9 @@ const workHubControl = createWorkHubControl({ isCurrent: isCurrentWorkHubTarget, ...workHubRuntime, }); +let workHubEnabled = false; const workHubPresentation = createWorkHubPresentation({ - isEnabled: async () => (await settingsStore.get()).workHub.enabled, + isEnabled: () => workHubEnabled, mainWindow: () => mainWindowController.browserWindow(), ensureMainWindow: async () => { await quitCoordinator.focusOrCreateWindow(); @@ -965,7 +967,10 @@ const botRegistry = new BotRegistry({ }); const clientSettingsEffects = createClientSettingsEffects({ settingsStore, - applyWorkHub: () => workHubPresentation.refreshSettings(), + applyWorkHub: async (enabled) => { + workHubEnabled = enabled; + await workHubPresentation.refreshSettings(); + }, applyKeepSystemAwake: async (enabled) => { keepSystemAwake.apply(enabled); }, diff --git a/apps/desktop/src/main/workhub-control.ts b/apps/desktop/src/main/workhub-control.ts index ac971e7776..37f33c8122 100644 --- a/apps/desktop/src/main/workhub-control.ts +++ b/apps/desktop/src/main/workhub-control.ts @@ -20,6 +20,7 @@ import { setTimeout as wait } from "node:timers/promises"; import { z } from "zod"; import type { IpcMain, WebContents } from "electron"; +import { redactSecrets } from '@maka/core/redaction'; import type { AppSettings } from "@maka/core/settings"; import { WORKHUB_COORDINATION_SESSION_ID } from "@maka/core/session"; import type { MakaTool } from "@maka/runtime/tool-runtime"; @@ -40,13 +41,17 @@ import type { WorkHubControlSnapshot } from '../shared/workhub-control.js'; // Tool protocols require an object root; keep each operation's exact shape // inside it so models cannot combine arguments from incompatible operations. -const controlParameters = z.object({ request: workHubControlSchema }).strict(); +const controlParameters = z.object({ + status: z.string().trim().min(1).max(80).regex(/^[^\r\n]+$/).describe('A short user-facing description of the current action, in the user\'s language. Match the language of the user\'s current request: Chinese for Chinese requests, English for English requests. Do not default to the language of these tool instructions. For example: Opening project settings. Describe the action, not reasoning or a claim of completion.'), + request: workHubControlSchema, +}).strict(); const tasksParameters = z.object({ request: workHubTasksSchema }).strict(); interface WorkHubControlDeps { ipcMain: Pick; window(): WebContents; - prepareWindow(): Promise; + prepareWindow(turnId?: string): Promise; + finishControl?(): void; authorizedRenderer(contents: WebContents): boolean; send(channel: string, payload: unknown): void; readSettings(): Promise; @@ -167,14 +172,15 @@ export function createWorkHubControl(deps: WorkHubControlDeps) { throw new Error("Another WorkHub control call is still running"); busy = true; try { + const { request: args, status } = controlParameters.parse(input); const active = await claim(scope, ctx); const signal = AbortSignal.any([ active.controller.signal, ctx.abortSignal, ]); signal.throwIfAborted(); - const args = controlParameters.parse(input).request; - await deps.prepareWindow(); + update({ status: redactSecrets(status), ...(active.failures < 3 ? { error: undefined, phase: "acting" as const } : {}) }); + await deps.prepareWindow(active.turnId); signal.throwIfAborted(); requireCurrent(scope); await deps.assertTurn(scope, active.turnId); @@ -228,8 +234,8 @@ export function createWorkHubControl(deps: WorkHubControlDeps) { !signal.aborted && deps.isCurrent(scope) && active.failures < 3; const inputDispatched = ui.dispatchedInputs > inputBeforeAction; update({ - error: signal.aborted ? undefined : message, - phase: signal.aborted ? "paused" : "error", + error: signal.aborted || recoverable ? undefined : message, + phase: signal.aborted ? "paused" : recoverable ? "acting" : "error", ...(!recoverable ? { cursor: undefined } : {}), }); return { @@ -306,9 +312,11 @@ export function createWorkHubControl(deps: WorkHubControlDeps) { if (owner?.resourceKey !== resourceKey) return; owner.controller.abort(new Error("WorkHub turn finished")); owner = undefined; + deps.finishControl?.(); update({ cursor: undefined, phase: snapshot.phase === "acting" ? "idle" : snapshot.phase, + status: undefined, }); }; deps.ipcMain.handle( @@ -383,8 +391,9 @@ export function createWorkHubControl(deps: WorkHubControlDeps) { undoController?.abort(new Error("WorkHub control closed")); owner?.controller.abort(new Error("WorkHub control closed")); owner = undefined; + deps.finishControl?.(); undo = undefined; - update({ cursor: undefined, phase: "idle", canUndo: false }); + update({ cursor: undefined, phase: "idle", canUndo: false, status: undefined }); deps.ipcMain.removeHandler("workhub-control:command"); }, }; diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts index 22fe93e237..bd70308e98 100644 --- a/apps/desktop/src/main/workhub-presentation.ts +++ b/apps/desktop/src/main/workhub-presentation.ts @@ -29,7 +29,8 @@ const SHORTCUT = 'CommandOrControl+Shift+K'; export interface WorkHubPresentationDeps { mainWindow(): BrowserWindow | undefined; ensureMainWindow(): Promise; - isEnabled(): Promise; + /** Applied client settings; showing the window must not wait for storage. */ + isEnabled(): boolean; mainModuleDirectory: string; viteDevServerUrl?: string; preloadPath: string; @@ -40,9 +41,16 @@ export interface WorkHubPresentationDeps { /** One renderer owns the conversation, draft and model selection for its entire lifetime. */ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { let view: WebContentsView | undefined; + let viewBounds: Electron.Rectangle | undefined; let floating: BrowserWindow | undefined; let parent: BrowserWindow | undefined; let host: WorkHubHost = { visible: false, rect: { x: 0, y: 0, width: 0, height: 0 } }; + let presentationRevision = 0; + let progressRequest: number | undefined; + let conversationBounds: Electron.Rectangle | undefined; + let controlTurnId: string | undefined; + let dismissedTurnId: string | undefined; + let expandOnFocus = false; let placement: 'docked' | 'floating' = 'docked'; let shortcutRegistered = false; let disposed = false; @@ -58,7 +66,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { let resizeTarget: Electron.Rectangle | undefined; let queue: Promise = Promise.resolve(); const mainReady = new WeakSet(); - const pendingNavigation = new WeakMap(); + const pendingNavigation = new WeakMap(); const mainListeners = new Map void>(); const entry = resolveMainRendererEntry(deps.mainModuleDirectory, deps.viteDevServerUrl); const reportError = deps.onError ?? ((error: unknown) => console.error('[workhub-presentation]', error)); @@ -73,7 +81,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { } function getSnapshot(): WorkHubPresentationSnapshot { - return { placement, floatingVisible: !!floating && !floating.isDestroyed() && floating.isVisible(), shortcutRegistered, rendererCrashed }; + return { placement, floatingVisible: !!floating && !floating.isDestroyed() && floating.isVisible(), shortcutRegistered, rendererCrashed, ...(progressRequest !== undefined ? { progressRequest } : {}) }; } function send(channel: string, ...args: unknown[]): void { @@ -89,7 +97,8 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { if (!view || view.webContents.isDestroyed() || !rendererReady || !parent || parent.isDestroyed() || !parent.isVisible()) return; if (placement === 'docked' && (!host.visible || host.occluded)) return; view.webContents.focus(); - view.webContents.send('workhub-presentation:focus-composer'); + view.webContents.send('workhub-presentation:focus-composer', expandOnFocus); + expandOnFocus = false; focusPending = false; } @@ -146,28 +155,50 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { return; } resizeTarget = bounds; - const started = Date.now(); + const started = performance.now(); + const frequency = screen.getDisplayMatching(bounds).displayFrequency; + const frameDuration = 1000 / (Number.isFinite(frequency) && frequency > 0 ? frequency : 60); + let previous = initial; const tick = () => { if (disposed || window.isDestroyed() || floating !== window || placement !== 'floating') { cancelFloatingAnimation(); return; } - const progress = Math.min(1, (Date.now() - started) / 240); - const eased = 1 - (1 - progress) ** 3; + const progress = Math.min(1, (performance.now() - started) / 240); + // Shrinking a fully visible conversation needs a gentle start as well + // as a gentle landing; expansion reveals its content after growing. + const eased = bounds.height < initial.height ? progress * progress * (3 - 2 * progress) : 1 - (1 - progress) ** 3; const height = Math.round(initial.height + (bounds.height - initial.height) * eased); const bottom = Math.round(initial.y + initial.height + (bounds.y + bounds.height - initial.y - initial.height) * eased); - window.setBounds({ ...bounds, height, y: bottom - height }); - fitFloating(); - if (progress < 1) resizeTimer = setTimeout(tick, 16); + const next = { ...bounds, height, y: bottom - height }; + if (next.x !== previous.x || next.y !== previous.y || next.width !== previous.width || next.height !== previous.height) { + window.setBounds(next); + fitFloating(); + previous = next; + } + if (progress < 1) { + // Keep frame deadlines independent of native resize work; do not add + // another full frame's delay after every setBounds/resize callback. + const elapsed = performance.now() - started; + const nextFrame = Math.min(240, (Math.floor(elapsed / frameDuration) + 1) * frameDuration); + resizeTimer = setTimeout(tick, Math.max(1, nextFrame - elapsed)); + } else cancelFloatingAnimation(); }; tick(); } + function setViewBounds(bounds: Electron.Rectangle): void { + if (!view || (viewBounds && bounds.x === viewBounds.x && bounds.y === viewBounds.y && + bounds.width === viewBounds.width && bounds.height === viewBounds.height)) return; + view.setBounds(bounds); + viewBounds = bounds; + } + function fitFloating(): void { - if (!floating || floating.isDestroyed() || parent !== floating || !view) return; + if (!floating || floating.isDestroyed() || parent !== floating || !view || progressRequest !== undefined) return; const { width, height } = floating.getContentBounds(); - view.setBounds({ x: 0, y: 0, width, height }); + setViewBounds({ x: 0, y: 0, width, height }); if (conversationExpanded && !resizeTarget) expandedHeight = height; } @@ -193,9 +224,8 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { floating.on('close', (event) => { if (disposed) return; event.preventDefault(); - cancelFloatingAnimation(); - floating?.hide(); - changed(); + ++presentationRevision; + hideFloating(); }); return floating; } @@ -203,6 +233,9 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { function updateDockedBounds(): void { const main = deps.mainWindow(); if (!view || placement !== 'docked' || !main || main.isDestroyed()) return; + // A hidden Desktop cannot display the conversation. Keep its native view + // parked for the next shortcut, and attach synchronously when Desktop shows. + if (floating && parent === floating && !main.isVisible()) return; attach(main); const zoom = main.webContents.getZoomFactor(); const size = main.getContentBounds(); @@ -210,72 +243,125 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { const y = Math.max(0, Math.min(size.height, Math.round(host.rect.y * zoom))); const width = Math.max(0, Math.min(size.width - x, Math.round(host.rect.width * zoom))); const height = Math.max(0, Math.min(size.height - y, Math.round(host.rect.height * zoom))); - view.setBounds({ x, y, width, height }); + setViewBounds({ x, y, width, height }); view.setVisible(host.visible && !host.occluded && width > 0 && height > 0); if (host.visible && width > 0 && height > 0 && focusPending) focusComposer(); } - async function detach(positionAtDefault = false): Promise { - if (!await deps.isEnabled() || disposed) return; + function clearProgressRequest(): void { + if (progressRequest !== undefined && view && !view.webContents.isDestroyed()) view.webContents.setBackgroundThrottling(true); + progressRequest = undefined; + } + + function hideFloating(): void { + cancelFloatingAnimation(); + floating?.hide(); + if (controlTurnId) dismissedTurnId = controlTurnId; + clearProgressRequest(); + expandOnFocus = false; + focusPending = false; + placement = 'docked'; + // Reparent the live view to an existing Desktop without opening/focusing it. + // If Desktop is closed, the hidden floating container keeps the view alive. + updateDockedBounds(); + changed(); + } + + function detach(positionAtDefault = false): void { + if (!deps.isEnabled() || disposed) return; cancelFloatingAnimation(); ensureView(); const target = ensureFloating(); + clearProgressRequest(); placement = 'floating'; attach(target); view!.setVisible(true); // Summoning follows the pointer's display, including an existing window // that was last used on another monitor. - const old = target.getBounds(); + const old = conversationBounds ?? target.getBounds(); + if (conversationBounds) target.setResizable(true); + conversationBounds = undefined; const area = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea; const width = Math.min(old.width, area.width); const height = Math.min(conversationExpanded ? expandedHeight : compactHeight, area.height); - target.setBounds({ + const bounds = { width, height, x: positionAtDefault ? area.x + Math.round((area.width - width) / 2) : Math.max(area.x, Math.min(old.x, area.x + area.width - width)), y: positionAtDefault ? Math.max(area.y, area.y + area.height - height - 96) : Math.max(area.y, Math.min(old.y, area.y + area.height - height)), - }); + }; + const current = target.getBounds(); + if (bounds.x !== current.x || bounds.y !== current.y || bounds.width !== current.width || bounds.height !== current.height) target.setBounds(bounds); fitFloating(); if (target.isMinimized()) target.restore(); target.show(); target.focus(); - target.setMaximizable(false); focusComposer(); changed(); } - async function prepareControl(): Promise { - if (!await deps.isEnabled()) throw new Error('WorkHub is disabled'); + function requestProgress(): void { + const main = deps.mainWindow(); + const dockVisible = placement === 'docked' && parent === main && main?.isVisible() && !main.isMinimized() + && host.visible && !host.occluded && view?.getVisible(); + if (!controlTurnId || dismissedTurnId === controlTurnId || progressRequest !== undefined || dockVisible + || (placement === 'floating' && floating?.isVisible()) || !deps.isEnabled() || disposed) return; + ensureView(); + const target = ensureFloating(); + cancelFloatingAnimation(); + conversationBounds ??= target.getBounds(); + target.setResizable(false); + progressRequest = ++presentationRevision; + // The card must paint while its native window is hidden. Hidden pages + // suspend RAF by default, which would deadlock the renderer-ready handshake. + view!.webContents.setBackgroundThrottling(false); + placement = 'floating'; + attach(target); + view!.setVisible(true); + // Preserve the parked conversation's viewport. The native window clips the + // small card; no transcript layout or composer state needs to be replaced. + setViewBounds({ x: 0, y: 0, width: Math.max(360, viewBounds?.width ?? 520), height: Math.max(112, viewBounds?.height ?? 720) }); + const area = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea; + const width = Math.min(360, area.width), height = Math.min(112, area.height); + target.setBounds({ width, height, x: area.x + Math.round((area.width - width) / 2), y: Math.max(area.y, area.y + area.height - height - 96) }); + // The renderer acknowledges its painted card before showInactive, avoiding + // one frame of the old full conversation in the compact native window. + changed(); + } + + async function prepareControl(turnId?: string): Promise { + if (!deps.isEnabled()) throw new Error('WorkHub is disabled'); if (disposed) throw new Error('WorkHub presentation is disposed'); - const main = await deps.ensureMainWindow(); - if (!await deps.isEnabled()) throw new Error('WorkHub is disabled'); + controlTurnId = turnId; + // The creation fallback activates Desktop. An existing window must retain + // its visibility, stacking order and focus while WorkHub operates it. + const existing = deps.mainWindow(); + const main = existing && !existing.isDestroyed() ? existing : await deps.ensureMainWindow(); + if (!deps.isEnabled()) throw new Error('WorkHub is disabled'); if (disposed) throw new Error('WorkHub presentation is disposed'); attachMainWindow(main); - if (placement !== 'floating' || !floating?.isVisible()) await detach(); - if (!await deps.isEnabled() || disposed) return; - if (main.isMinimized()) main.restore(); - main.show(); - main.focus(); + requestProgress(); } - async function navigateMain(navigation: WorkHubMainNavigation): Promise { - if (navigation.kind === 'workhub' && !await deps.isEnabled()) return; + async function navigateMain(navigation: WorkHubMainNavigation, revision = presentationRevision): Promise { + if (navigation.kind === 'workhub' && !deps.isEnabled()) return; const main = await deps.ensureMainWindow(); if (disposed) throw new Error('WorkHub presentation is disposed'); - if (navigation.kind === 'workhub' && !await deps.isEnabled()) return; + if (revision !== presentationRevision || (navigation.kind === 'workhub' && !deps.isEnabled())) return; attachMainWindow(main); if (main.isMinimized()) main.restore(); main.show(); main.focus(); if (mainReady.has(main.webContents)) main.webContents.send('workhub-presentation:open-main', navigation); - else pendingNavigation.set(main.webContents, navigation); + else pendingNavigation.set(main.webContents, { navigation, revision }); return main; } - async function dock(): Promise { + async function dock(revision: number): Promise { cancelFloatingAnimation(); - if (!await navigateMain({ kind: 'workhub' }) || !await deps.isEnabled() || disposed) return; + if (!await navigateMain({ kind: 'workhub' }, revision) || revision !== presentationRevision || !deps.isEnabled() || disposed) return; ensureView(); floating?.hide(); + clearProgressRequest(); placement = 'docked'; updateDockedBounds(); focusComposer(); @@ -291,7 +377,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { // BrowserWindow disposal must never own the conversation's lifetime. attach(ensureFloating()); floating!.hide(); - placement = 'floating'; + placement = 'docked'; host = { ...host, visible: false }; changed(); }; @@ -299,10 +385,12 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { contents.on('did-start-loading', onLoading); main.on('close', onClose); main.on('resize', updateDockedBounds); + main.on('show', updateDockedBounds); const cleanup = () => { if (!contents.isDestroyed()) contents.removeListener('did-start-loading', onLoading); main.removeListener('close', onClose); main.removeListener('resize', updateDockedBounds); + main.removeListener('show', updateDockedBounds); mainListeners.delete(main); }; main.once('closed', cleanup); @@ -315,22 +403,32 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { function registerIpc(): void { if (ipcRegistered) return; - ipcMain.handle(COMMAND, (event, command: unknown, payload: unknown) => { - return enqueue(async () => { + ipcMain.handle(COMMAND, async (event, command: unknown, payload: unknown) => { + const authorize = () => { const main = deps.mainWindow(); const isMain = !!main && !main.isDestroyed() && main.webContents === event.sender; if ((!isMain && !ownsWebContents(event.sender)) || event.senderFrame !== event.sender.mainFrame) { throw new Error('WorkHub presentation IPC requires an owned main frame'); } + return { main, isMain }; + }; + authorize(); + const changesPresentation = command === 'detach' || command === 'show-conversation' || command === 'dock' || command === 'hide' || command === 'session'; + const revision = changesPresentation ? ++presentationRevision : presentationRevision; + return enqueue(async () => { + const { main, isMain } = authorize(); + // A newer shortcut takes effect immediately, including during a pending dock. + if (changesPresentation && revision !== presentationRevision) return; switch (command) { case 'snapshot': return getSnapshot(); case 'ready': if (!isMain) { rendererReady = true; if (focusPending) focusComposer(); } else { mainReady.add(event.sender); - const navigation = pendingNavigation.get(event.sender); - if (navigation && (navigation.kind !== 'workhub' || await deps.isEnabled())) { - pendingNavigation.delete(event.sender); + const pending = pendingNavigation.get(event.sender); + pendingNavigation.delete(event.sender); + const navigation = pending?.revision === presentationRevision ? pending.navigation : undefined; + if (navigation && (navigation.kind !== 'workhub' || deps.isEnabled())) { event.sender.send('workhub-presentation:open-main', navigation); } } @@ -355,23 +453,41 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { } } if (disposed) return; - // Geometry updates need no settings read until they would reopen a hidden dock. - host = value.visible && !host.visible && !await deps.isEnabled() ? { ...value, visible: false } : value; + // Layout cannot reopen a disabled dock. + host = value.visible && !host.visible && !deps.isEnabled() ? { ...value, visible: false } : value; if (disposed) return; if (host.visible && placement === 'docked') { attachMainWindow(main!); // Layout notifications must not turn a crash into a reload loop. if (!rendererCrashed) ensureView(); } + if (!host.visible) requestProgress(); updateDockedBounds(); return backdrop; } - case 'detach': await detach(); return; + case 'progress-ready': + if (isMain) throw new Error('Only the WorkHub view can present its progress'); + if (typeof payload !== 'number' || !Number.isSafeInteger(payload)) throw new Error('Invalid progress request'); + if (payload === progressRequest && payload === presentationRevision && deps.isEnabled()) { + floating?.showInactive(); + view?.webContents.setBackgroundThrottling(true); + changed(); + } + return; + case 'show-conversation': + conversationExpanded = true; + expandOnFocus = true; + detach(true); + return; + case 'detach': detach(); return; case 'conversation-layout': { if (isMain) throw new Error('Only the WorkHub view can size its conversation'); const value = payload as { expanded?: unknown; compactHeight?: unknown } | null; if (!value || typeof value.expanded !== 'boolean' || typeof value.compactHeight !== 'number' || !Number.isFinite(value.compactHeight) || value.compactHeight <= 0) throw new Error('Invalid WorkHub conversation layout'); - compactHeight = Math.max(80, Math.ceil(value.compactHeight)); + if (progressRequest !== undefined) return; + // Desktop's wider composer must not overwrite the remembered floating + // height and force a second resize on the next shortcut summon. + if (!floating || placement === 'floating') compactHeight = Math.max(80, Math.ceil(value.compactHeight)); if (placement !== 'floating' || !floating || floating.isDestroyed()) { conversationExpanded = value.expanded; return; @@ -386,12 +502,12 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { } return; } - case 'dock': await dock(); return; - case 'hide': cancelFloatingAnimation(); floating?.hide(); changed(); return; + case 'dock': await dock(revision); return; + case 'hide': hideFloating(); return; case 'session': if (typeof payload !== 'string' || payload.length > 4096) throw new Error('Invalid session key'); parseDesktopSessionKey(payload); - await navigateMain({ kind: 'session', sessionKey: payload }); + await navigateMain({ kind: 'session', sessionKey: payload }, revision); return; default: throw new Error('Unknown WorkHub presentation command'); } @@ -400,29 +516,36 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { ipcRegistered = true; } - function toggle(positionAtDefault = false): Promise { - return enqueue(async () => { - if (placement === 'floating' && floating?.isVisible()) { - cancelFloatingAnimation(); - floating.hide(); - changed(); - } else await detach(positionAtDefault); - }); + async function toggle(positionAtDefault = false): Promise { + if (disposed) throw new Error('WorkHub presentation is disposed'); + ++presentationRevision; + if (progressRequest === undefined && placement === 'floating' && floating?.isVisible()) { + hideFloating(); + } else detach(positionAtDefault); } async function refreshSettings(): Promise { - const enabled = await deps.isEnabled(); + const enabled = deps.isEnabled(); if (disposed) return; if (enabled) { + // Prepare the reusable native window and renderer while enabling WorkHub, + // before a shortcut needs them. Never restart a crashed renderer implicitly. + const target = ensureFloating(); + if (!rendererCrashed) { + ensureView(); + if (!parent) { attach(target); fitFloating(); } + } if (!shortcutRegistered) shortcutRegistered = globalShortcut.register(SHORTCUT, () => { void toggle(true).catch(reportError); }); } else { if (shortcutRegistered) globalShortcut.unregister(SHORTCUT); shortcutRegistered = false; + ++presentationRevision; cancelFloatingAnimation(); focusPending = false; host = { ...host, visible: false }; view?.setVisible(false); - floating?.hide(); + hideFloating(); + return; } changed(); } @@ -437,6 +560,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { cancelFloatingAnimation(); const previous = view; view = undefined; + viewBounds = undefined; rendererReady = false; // Release this renderer's subscriptions and broadcasts before another view // can register. A delayed destroyed event must not release its replacement. @@ -458,5 +582,5 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) { floating = undefined; } - return { registerIpc, refreshSettings, attachMainWindow, getSnapshot, ownsWebContents, send, prepareControl: () => enqueue(prepareControl), show: () => enqueue(detach), toggle, dispose }; + return { registerIpc, refreshSettings, attachMainWindow, getSnapshot, ownsWebContents, send, prepareControl: (turnId?: string) => enqueue(() => prepareControl(turnId)), finishControl: () => { controlTurnId = undefined; }, show: async () => { if (disposed) throw new Error('WorkHub presentation is disposed'); ++presentationRevision; detach(); }, toggle, dispose }; } diff --git a/apps/desktop/src/main/workhub-ui.ts b/apps/desktop/src/main/workhub-ui.ts index d46f46e7a6..72279eaf62 100644 --- a/apps/desktop/src/main/workhub-ui.ts +++ b/apps/desktop/src/main/workhub-ui.ts @@ -123,7 +123,7 @@ export class WorkHubUi { })()`); if (rect.width < 1 || rect.height < 1 || rect.x < 0 || rect.y < 0) throw new Error("Preference control is not visible"); - return (await wc.capturePage(rect)).toPNG().toString("base64"); + return (await wc.capturePage(rect, { stayHidden: true })).toPNG().toString("base64"); } async execute( diff --git a/apps/desktop/src/preload/workhub-presentation.ts b/apps/desktop/src/preload/workhub-presentation.ts index 7c5e1bc0e6..a0eba54d67 100644 --- a/apps/desktop/src/preload/workhub-presentation.ts +++ b/apps/desktop/src/preload/workhub-presentation.ts @@ -31,6 +31,8 @@ export const workHubPresentationBridge: WorkHubPresentationBridge = { getSnapshot: () => ipcRenderer.invoke('workhub-presentation:command', 'snapshot'), setHost: (host) => ipcRenderer.invoke('workhub-presentation:command', 'host', host), setConversationLayout: (layout) => ipcRenderer.invoke('workhub-presentation:command', 'conversation-layout', layout), + progressReady: (request) => ipcRenderer.invoke('workhub-presentation:command', 'progress-ready', request), + showConversation: () => ipcRenderer.invoke('workhub-presentation:command', 'show-conversation'), detach: () => ipcRenderer.invoke('workhub-presentation:command', 'detach'), dock: () => ipcRenderer.invoke('workhub-presentation:command', 'dock'), hide: () => ipcRenderer.invoke('workhub-presentation:command', 'hide'), diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 135de12c0f..81cdfec668 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -66,7 +66,6 @@ import { reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; -import { Button } from '@astryxdesign/core/Button'; import { useKeyboardHelp } from './keyboard-help'; import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; @@ -448,11 +447,8 @@ function AppShellContent({ const becameEnabled = enabled && !workHubEnabledRef.current; workHubEnabledRef.current = enabled; setWorkHubEnabled(enabled); - if (!enabled) setWorkHubActive(false); - if (becameEnabled) { - setWorkHubActive(true); - setNavSelection({ section: 'sessions' }); - } + if (!enabled || becameEnabled) setWorkHubActive(enabled); + if (becameEnabled) setNavSelection({ section: 'sessions' }); } catch { // Keep the last known client-owned setting. A transient settings read // must not leave the shell half-switched between WorkHub and Session. @@ -2499,7 +2495,7 @@ function AppShellContent({
{ closeSettings(); openSession(sessionId); }} /> - + ) : null} {!sharedSessionActive && navSelection.section === 'sessions' ? : null} - {workHubEnabled && navSelection.section === 'sessions' && activeId ? ( -