diff --git a/desktop/src/main/__tests__/commands.test.ts b/desktop/src/main/__tests__/commands.test.ts index ac6cbb60..5568ee25 100644 --- a/desktop/src/main/__tests__/commands.test.ts +++ b/desktop/src/main/__tests__/commands.test.ts @@ -57,7 +57,12 @@ function dispatcher() { const shell = { openPath: vi.fn(async () => ''), } as unknown as Shell; - const sendRendererCommand = vi.fn(); + const sendRendererCommand = vi.fn(async (request) => ({ + command: request.command, + accepted: true, + handledBy: 'renderer' as const, + message: undefined, + })); const checkForUpdates = vi.fn(async () => updateStatus('idle')); const downloadUpdate = vi.fn(async () => updateStatus('ready')); const installUpdate = vi.fn(() => updateStatus('ready')); diff --git a/desktop/src/main/__tests__/renderer-commands.test.ts b/desktop/src/main/__tests__/renderer-commands.test.ts new file mode 100644 index 00000000..307a5928 --- /dev/null +++ b/desktop/src/main/__tests__/renderer-commands.test.ts @@ -0,0 +1,73 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { IpcMain, WebContents } from 'electron'; +import { sendAcknowledgedRendererCommand } from '../renderer-commands.js'; + +function harness() { + const ipc = new EventEmitter(); + const target = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + mainFrame: {}, + send: vi.fn(), + }); + const request = { command: 'new-task' as const, source: 'menu' as const }; + const dispatch = () => + sendAcknowledgedRendererCommand(ipc as IpcMain, target as unknown as WebContents, request, 100); + const receipt = (accepted = true) => ({ + requestId: target.send.mock.calls[0][1].requestId, + accepted, + }); + const event = { sender: target, senderFrame: target.mainFrame }; + return { ipc, target, request, dispatch, receipt, event }; +} +afterEach(() => vi.useRealTimers()); +describe('renderer command receipts', () => { + it('waits for the matching window and request and removes listeners', async () => { + const h = harness(); + const finished = vi.fn(); + const result = h.dispatch().then(finished); + h.ipc.emit('desktop:menu-command-result', { sender: {} }, h.receipt()); + h.ipc.emit('desktop:menu-command-result', h.event, { requestId: 'another', accepted: true }); + await Promise.resolve(); + expect(finished).not.toHaveBeenCalled(); + h.ipc.emit('desktop:menu-command-result', h.event, h.receipt()); + await result; + expect(finished).toHaveBeenCalledWith( + expect.objectContaining({ accepted: true, handledBy: 'renderer' }) + ); + expect(h.ipc.listenerCount('desktop:menu-command-result')).toBe(0); + expect(h.target.listenerCount('destroyed')).toBe(0); + }); + it('returns renderer permission failures without claiming success', async () => { + const h = harness(); + const result = h.dispatch(); + h.ipc.emit('desktop:menu-command-result', h.event, { + ...h.receipt(false), + message: 'Permission required', + }); + await expect(result).resolves.toMatchObject({ + accepted: false, + message: 'Permission required', + }); + }); + it('fails closed when setup or a locked renderer has no listener', async () => { + vi.useFakeTimers(); + const h = harness(); + const result = h.dispatch(); + await vi.advanceTimersByTimeAsync(100); + await expect(result).resolves.toMatchObject({ + accepted: false, + message: expect.stringContaining('unlock'), + }); + expect(h.ipc.listenerCount('desktop:menu-command-result')).toBe(0); + }); + it('settles a destroyed or missing window', async () => { + const h = harness(); + const result = h.dispatch(); + h.target.emit('destroyed'); + await expect(result).resolves.toMatchObject({ accepted: false }); + await expect( + sendAcknowledgedRendererCommand(h.ipc as IpcMain, undefined, h.request) + ).resolves.toMatchObject({ accepted: false }); + }); +}); diff --git a/desktop/src/main/commands.ts b/desktop/src/main/commands.ts index f18da7aa..8aac4abb 100644 --- a/desktop/src/main/commands.ts +++ b/desktop/src/main/commands.ts @@ -151,7 +151,9 @@ export interface DesktopCommandDispatcherOptions { runtime: DesktopRuntime; shell: Shell; quit(): void; - sendRendererCommand(command: DesktopCommandDispatchRequest): void; + sendRendererCommand( + command: DesktopCommandDispatchRequest + ): Promise; checkForUpdates(): Promise; downloadUpdate(): Promise; installUpdate(): DesktopUpdateStatus; @@ -168,19 +170,16 @@ export class DesktopCommandDispatcher { switch (definition.nativeAction) { case 'renderer': - this.options.sendRendererCommand(request); - return accepted(request, 'renderer'); + return this.options.sendRendererCommand(request); case 'restart-server': await this.options.runtime.restartLocalServer(); - this.options.sendRendererCommand(request); return accepted(request, 'desktop'); case 'open-logs': await this.options.shell.openPath(this.options.runtime.snapshot().logsDir); return accepted(request, 'desktop'); case 'show-diagnostics': case 'create-debug-bundle': - this.options.sendRendererCommand(request); - return accepted(request, 'renderer'); + return this.options.sendRendererCommand(request); case 'check-updates': await this.options.checkForUpdates(); return accepted(request, 'desktop'); @@ -194,12 +193,7 @@ export class DesktopCommandDispatcher { this.options.showTestNotification(); return accepted(request, 'desktop'); case 'test-external-delivery': - this.options.sendRendererCommand(request); - return accepted( - request, - 'renderer', - 'External delivery test requires configured delivery.' - ); + return this.options.sendRendererCommand(request); case 'copy-diagnostics': this.options.copyRedactedDiagnostics(this.options.runtime.snapshot()); return accepted(request, 'desktop'); diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 46fe35ce..c5cd7157 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -6,6 +6,7 @@ import { createRequire } from 'node:module'; import { DESKTOP_APP_ID, DESKTOP_APP_NAME, DESKTOP_MIN_WINDOW } from './app-metadata.js'; import { registerDesktopBridge } from './bridge.js'; import { DesktopCommandDispatcher } from './commands.js'; +import { sendAcknowledgedRendererCommand } from './renderer-commands.js'; import { extractDeepLinkFromArgv, parseDesktopDeepLink } from './deep-links.js'; import { configureDesktopMenu, dispatchDesktopMenuCommand } from './menu.js'; import { hasSameOriginNavigation, openValidatedExternalUrl } from './navigation.js'; @@ -301,9 +302,8 @@ async function boot(): Promise { runtime, shell, quit: () => app.quit(), - sendRendererCommand: (command) => { - activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.menuCommand.channel, command); - }, + sendRendererCommand: (command) => + sendAcknowledgedRendererCommand(ipcMain, activeMainWindow()?.webContents, command), checkForUpdates: () => updateService?.checkForUpdates() ?? Promise.resolve(updateServiceFallback(packaged)), downloadUpdate: () => diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 16048116..c45037b5 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -1,4 +1,4 @@ -import { Menu, type MenuItemConstructorOptions } from 'electron'; +import { Menu, dialog, type MenuItemConstructorOptions } from 'electron'; import { createDesktopCommandRequest, @@ -120,7 +120,23 @@ export function dispatchDesktopMenuCommand( dispatcher: DesktopCommandDispatcher, command: DesktopCommandName ): void { - void dispatcher.dispatch(createDesktopCommandRequest(command, 'menu')); + void dispatcher + .dispatch(createDesktopCommandRequest(command, 'menu')) + .then((result) => { + if (!result.accepted) + return dialog.showMessageBox({ + type: 'info', + message: DESKTOP_COMMAND_REGISTRY[command].label, + detail: result.message ?? 'This action is unavailable. Open the workspace and try again.', + }); + }) + .catch(() => + dialog.showMessageBox({ + type: 'error', + message: 'Unable to complete the command', + detail: 'Check Setup & Diagnostics, then try again.', + }) + ); } function isCommandEnabled( diff --git a/desktop/src/main/renderer-commands.ts b/desktop/src/main/renderer-commands.ts new file mode 100644 index 00000000..ded5952f --- /dev/null +++ b/desktop/src/main/renderer-commands.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto'; +import type { IpcMain, WebContents } from 'electron'; +import type { + DesktopCommandDispatchRequest, + DesktopCommandDispatchResult, +} from '../shared/desktop-bridge-contracts.js'; + +/** A receipt is tied to the exact renderer and request, never just a command name. */ +export function sendAcknowledgedRendererCommand( + ipc: IpcMain, + target: WebContents | undefined, + request: DesktopCommandDispatchRequest, + timeoutMs = 3000 +): Promise { + const unavailable = (message: string): DesktopCommandDispatchResult => ({ + command: request.command, + accepted: false, + handledBy: 'unsupported', + message, + }); + if (!target || target.isDestroyed()) { + return Promise.resolve( + unavailable('Open the main window, finish setup and unlock the workspace, then try again.') + ); + } + return new Promise((resolve) => { + const requestId = randomUUID(); + const finish = (result: DesktopCommandDispatchResult) => { + clearTimeout(timer); + ipc.off('desktop:menu-command-result', acknowledge); + target.off('destroyed', closed); + resolve(result); + }; + const closed = () => + finish(unavailable('The window closed before handling the command. Open it and try again.')); + const acknowledge = (event: Electron.IpcMainEvent, receipt: unknown) => { + if ( + event.sender !== target || + event.senderFrame !== target.mainFrame || + !receipt || + typeof receipt !== 'object' + ) + return; + const value = receipt as Record; + if (value.requestId !== requestId || typeof value.accepted !== 'boolean') return; + finish({ + command: request.command, + accepted: value.accepted, + handledBy: value.accepted ? 'renderer' : 'unsupported', + message: typeof value.message === 'string' ? value.message.slice(0, 500) : undefined, + }); + }; + const timer = setTimeout( + () => + finish( + unavailable( + 'The workspace did not handle this command. Finish setup or unlock it, then try again.' + ) + ), + timeoutMs + ); + ipc.on('desktop:menu-command-result', acknowledge); + target.once('destroyed', closed); + try { + target.send('desktop:menu-command', { ...request, requestId }); + } catch { + finish(unavailable('The window is unavailable. Open it and try again.')); + } + }); +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index 7c6c68d5..24598b81 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -97,7 +97,9 @@ export interface VeritasDesktopApi { onRunProgress(listener: BridgeEventListener<'runProgress'>): () => void; onUpdateStatus(listener: BridgeEventListener<'updateStatus'>): () => void; onNotificationAction(listener: BridgeEventListener<'notificationAction'>): () => void; - onMenuCommand(listener: BridgeEventListener<'menuCommand'>): () => void; + onMenuCommand( + listener: (request: DesktopCommandDispatchRequest) => { accepted: boolean; message?: string } + ): () => void; onUploadProgress(listener: BridgeEventListener<'uploadProgress'>): () => void; onWorkProductExportProgress( listener: BridgeEventListener<'workProductExportProgress'> @@ -185,7 +187,30 @@ const api: VeritasDesktopApi = { onRunProgress: (listener) => onDesktopEvent('runProgress', listener), onUpdateStatus: (listener) => onDesktopEvent('updateStatus', listener), onNotificationAction: (listener) => onDesktopEvent('notificationAction', listener), - onMenuCommand: (listener) => onDesktopEvent('menuCommand', listener), + onMenuCommand: (listener) => { + const handler = ( + _event: Electron.IpcRendererEvent, + request: DesktopCommandDispatchRequest & { requestId?: string } + ) => { + if (!request.requestId) return; + try { + const result = listener(request); + ipcRenderer.send('desktop:menu-command-result', { + requestId: request.requestId, + accepted: result?.accepted === true, + message: result?.message, + }); + } catch { + ipcRenderer.send('desktop:menu-command-result', { + requestId: request.requestId, + accepted: false, + message: 'Unable to open this action. Close the current dialog and try again.', + }); + } + }; + ipcRenderer.on('desktop:menu-command', handler); + return () => ipcRenderer.off('desktop:menu-command', handler); + }, onUploadProgress: (listener) => onDesktopEvent('uploadProgress', listener), onWorkProductExportProgress: (listener) => onDesktopEvent('workProductExportProgress', listener), onExternalDeliveryVerification: (listener) => diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index 77cab749..7f2fe4b6 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -308,3 +308,12 @@ Windows release and update support stay blocked until code signing and signed-installer smoke coverage are in place. Linux release and updater support are deferred until the project has clear checksum, provenance, install, and AppImage/deb/rpm update policies. + +### Native menu verification + +The packaged native UI gate exercises New Task, Settings, Search, Command Center, +Import, Export, Create Backup, and Create Debug Bundle through Electron's installed +menu and the mounted renderer. File data commands and Debug Bundle open the existing +Maintenance flow; they do not automatically export, restore, or create files. +Renderer commands wait for an acknowledgement. Finish setup and unlock the workspace +before using them; unavailable actions display a reason instead of reporting success. diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs new file mode 100644 index 00000000..65d8b6f5 --- /dev/null +++ b/scripts/native-ui/menu-commands.mjs @@ -0,0 +1,63 @@ +/* global window */ +import assert from 'node:assert/strict'; +import { expect } from '@playwright/test'; + +/** Uses the installed native menu, preload, and real mounted application. */ +export async function verifyNativeMenuCommands(app, page) { + const cases = [ + ['New Task', 'Create Task'], + ['Settings', 'Settings'], + ['Search', 'Search'], + ['Command Center', 'Command Center'], + ['Import', 'Settings'], + ['Export', 'Settings'], + ['Create Backup', 'Settings'], + ['Create Debug Bundle', 'Settings'], + ]; + const results = []; + for (const [label, surface] of cases) { + await app.evaluate(({ Menu }, label) => { + const find = (menu) => { + for (const item of menu.items) { + if (item.label === label) return item; + const nested = item.submenu && find(item.submenu); + if (nested) return nested; + } + }; + const item = find(Menu.getApplicationMenu()); + if (!item || !item.enabled) throw new Error(`Native command unavailable: ${label}`); + item.click(); + }, label); + const dialog = page.getByRole('dialog'); + await expect(dialog).toHaveCount(1); + await expect(dialog).toBeVisible(); + if (surface === 'Settings') { + await expect( + dialog.getByRole('button', { name: 'Close settings', exact: true }) + ).toBeVisible(); + if (['Import', 'Export', 'Create Backup', 'Create Debug Bundle'].includes(label)) { + await expect( + dialog.getByRole('heading', { name: 'Maintenance', exact: true }) + ).toBeVisible(); + await expect( + dialog.getByRole('button', { name: 'Debug Bundle', exact: true }) + ).toBeVisible(); + } + } else if (surface === 'Command Center') { + await expect(dialog.getByRole('textbox', { name: 'Search commands' })).toBeVisible(); + } else if (surface === 'Search') { + await expect(dialog.getByRole('textbox', { name: 'Search Veritas' })).toBeVisible(); + } else { + await expect(dialog.getByRole('textbox', { name: /title/i }).first()).toBeVisible(); + } + results.push({ label, surface, dialogs: await dialog.count() }); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + } + const unsupported = await page.evaluate(() => + window.veritasDesktop.dispatchCommand({ command: 'export-work-product', source: 'menu' }) + ); + assert.equal(unsupported.accepted, false); + assert(unsupported.message); + return results; +} diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index 2dcb68d5..b34d9f34 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -6,6 +6,7 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect } from '@playwright/test'; import { createNativeSession } from './session.mjs'; +import { verifyNativeMenuCommands } from './menu-commands.mjs'; import { fileDigest, evidenceFailures, @@ -652,6 +653,8 @@ async function checkSeededRendererFailures() { } try { await launch(); + report.menuCommands = await verifyNativeMenuCommands(app, page); + await persist(); for (const mode of modes) { for (const state of states) { const entry = { id: `${mode.id}/${state}`, status: 'running' }; diff --git a/web/src/App.tsx b/web/src/App.tsx index 2723f51d..c9178a4d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -134,11 +134,15 @@ function DesktopAwareAppShell({ setShowDesktopOnboarding: (open: boolean) => void; }) { const { isDesktopClient, bottomPanel } = useDesktopShell(); + const [commandCenterOpen, setCommandCenterOpen] = useState(false); return ( -
+
setShowDesktopOnboarding(true)} + onOpenCommandCenter={() => setCommandCenterOpen(true)} + /> }> @@ -171,7 +175,7 @@ function DesktopAwareAppShell({ - + {!isDesktopClient && } @@ -212,28 +216,9 @@ function AppContent() { useEffect(() => { const openDiagnostics = () => setShowDesktopOnboarding(true); - const desktop = ( - window as Window & { - veritasDesktop?: { - onMenuCommand(listener: (payload: { command: string }) => void): () => void; - }; - } - ).veritasDesktop; - - const unsubscribe = desktop?.onMenuCommand?.((payload) => { - if ( - payload.command === 'open-onboarding' || - payload.command === 'show-diagnostics' || - payload.command === 'communication-health' - ) { - setShowDesktopOnboarding(true); - } - }); - window.addEventListener('veritas:open-diagnostics', openDiagnostics); return () => { - unsubscribe?.(); window.removeEventListener('veritas:open-diagnostics', openDiagnostics); }; }, []); diff --git a/web/src/__tests__/desktop-shell-context.test.tsx b/web/src/__tests__/desktop-shell-context.test.tsx index 4e3d8106..a28a38b8 100644 --- a/web/src/__tests__/desktop-shell-context.test.tsx +++ b/web/src/__tests__/desktop-shell-context.test.tsx @@ -13,6 +13,7 @@ function ShellProbe() { return (
+ {String(shell.leftRailOpen)} {String(shell.rightRailOpen)} {shell.bottomPanel ?? 'closed'} @@ -34,8 +35,6 @@ function ShellProbe() { } describe('desktop shell recovery', () => { - let menuListener: ((payload: { command: string }) => void) | undefined; - beforeEach(() => { const storage = new Map(); Object.defineProperty(window, 'localStorage', { @@ -59,7 +58,7 @@ describe('desktop shell recovery', () => { configurable: true, value: { onMenuCommand: vi.fn((listener: (payload: { command: string }) => void) => { - menuListener = listener; + void listener; return vi.fn(); }), }, @@ -70,7 +69,6 @@ describe('desktop shell recovery', () => { cleanup(); delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop; delete document.documentElement.dataset.client; - menuListener = undefined; }); it('retires legacy bottom-dock state and defaults to a bounded right dock', () => { @@ -190,7 +188,7 @@ describe('desktop shell recovery', () => { expect(screen.getByLabelText('right rail').textContent).toBe('true'); }); - it('resets the complete desktop layout from the native recovery command', async () => { + it('resets the complete desktop layout through the shell action', async () => { const user = userEvent.setup(); render( @@ -203,7 +201,7 @@ describe('desktop shell recovery', () => { await user.click(screen.getByRole('button', { name: 'Open right rail' })); await user.click(screen.getByRole('button', { name: 'Open board chat' })); - act(() => menuListener?.({ command: 'reset-layout' })); + await user.click(screen.getByRole('button', { name: 'Reset layout' })); expect(screen.getByLabelText('left rail').textContent).toBe('true'); expect(screen.getByLabelText('right rail').textContent).toBe('false'); diff --git a/web/src/components/layout/CommandPalette.tsx b/web/src/components/layout/CommandPalette.tsx index 1733cf61..dae9bff1 100644 --- a/web/src/components/layout/CommandPalette.tsx +++ b/web/src/components/layout/CommandPalette.tsx @@ -103,8 +103,21 @@ function nextEnabledIndex( return -1; } -export function CommandPalette() { - const [open, setOpen] = useState(false); +export function CommandPalette({ + nativeOpen = false, + onNativeOpenChange, +}: { nativeOpen?: boolean; onNativeOpenChange?: (open: boolean) => void } = {}) { + const [open, setOpenState] = useState(false); + const setOpen = useCallback( + (next: boolean | ((current: boolean) => boolean)) => { + setOpenState(next); + onNativeOpenChange?.(false); + }, + [onNativeOpenChange] + ); + useEffect(() => { + if (nativeOpen) setOpenState(true); + }, [nativeOpen]); const [searchOpen, setSearchOpen] = useState(false); const [searchMounted, setSearchMounted] = useState(false); const [query, setQuery] = useState(''); @@ -232,7 +245,7 @@ export function CommandPalette() { }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, []); + }, [setOpen]); const handoff = useOverlayHandoff(open, executeCommand); const runCommand = (cmd: CommandItem) => { diff --git a/web/src/components/layout/DesktopShellContext.tsx b/web/src/components/layout/DesktopShellContext.tsx index bc141d87..44d23c1c 100644 --- a/web/src/components/layout/DesktopShellContext.tsx +++ b/web/src/components/layout/DesktopShellContext.tsx @@ -12,6 +12,7 @@ import { export type DesktopBottomPanel = 'board-chat' | 'squad-chat'; interface DesktopShellContextValue { + resetDesktopLayout: () => void; isDesktopClient: boolean; leftRailOpen: boolean; rightRailOpen: boolean; @@ -40,6 +41,7 @@ export const MIN_RIGHT_PANEL_WIDTH = 320; export const MAX_RIGHT_PANEL_WIDTH = 640; const DEFAULT_CONTEXT: DesktopShellContextValue = { + resetDesktopLayout: () => undefined, isDesktopClient: false, leftRailOpen: false, rightRailOpen: false, @@ -327,21 +329,9 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) { }; }, [bottomPanel, closeBottomPanel, desktopClient, restorePanelFocus]); - useEffect(() => { - const desktop = ( - window as Window & { - veritasDesktop?: { - onMenuCommand?: (listener: (payload: { command: string }) => void) => () => void; - }; - } - ).veritasDesktop; - return desktop?.onMenuCommand?.((payload) => { - if (payload.command === 'reset-layout') resetDesktopLayout(); - }); - }, [resetDesktopLayout]); - const value = useMemo( () => ({ + resetDesktopLayout, isDesktopClient: desktopClient, leftRailOpen: desktopClient ? leftRailOpen : false, rightRailOpen: desktopClient ? rightRailOpen : false, @@ -356,6 +346,7 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) { }), [ bottomPanel, + resetDesktopLayout, closeBottomPanel, desktopClient, leftRailOpen, diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 8c52f633..2c34fb00 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,3 +1,4 @@ +import { flushSync } from 'react-dom'; import { ActionIcon, Badge, @@ -133,7 +134,10 @@ function VeritasMark({ className }: { className?: string }) { ); } -export function Header() { +export function Header({ + onOpenDiagnostics, + onOpenCommandCenter, +}: { onOpenDiagnostics?: () => void; onOpenCommandCenter?: () => void } = {}) { const [createOpen, setCreateOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsTab, setSettingsTab] = useState(); @@ -154,6 +158,7 @@ export function Header() { const canOpenSettings = hasPermission('settings:read') || hasPermission('admin:manage'); const { isDesktopClient, + resetDesktopLayout, leftRailOpen, rightRailOpen, bottomPanel, @@ -339,6 +344,72 @@ export function Header() { return () => window.removeEventListener('veritas:open-search', handleOpenSearch); }, [openSearchDialog]); + // The only native command subscriber lives in the authenticated, mounted shell. + useEffect(() => { + const desktop = ( + window as Window & { + veritasDesktop?: { + onMenuCommand?: ( + listener: (request: { command: string }) => { accepted: boolean; message?: string } + ) => () => void; + }; + } + ).veritasDesktop; + return desktop?.onMenuCommand?.(({ command }) => { + let action: (() => void) | undefined; + let message = 'This command is not available in this workspace.'; + switch (command) { + case 'new-task': + if (canCreateTask) action = openCreateDialog; + else message = 'Task write permission is required to create a task.'; + break; + case 'open-settings': + if (canOpenSettings) action = () => openSettingsDialog(); + else message = 'Settings read permission is required to open Settings.'; + break; + case 'open-search': + action = () => openSearchDialog(); + break; + case 'open-command-center': + action = onOpenCommandCenter; + break; + case 'reset-layout': + action = resetDesktopLayout; + break; + case 'open-onboarding': + case 'show-diagnostics': + case 'communication-health': + action = onOpenDiagnostics; + break; + case 'import-data': + case 'export-data': + case 'create-backup': + case 'create-debug-bundle': + if (canOpenSettings && hasPermission('backup:read')) + action = () => openSettingsDialog('maintenance'); + else message = 'Settings and backup read permission are required to open Maintenance.'; + break; + case 'test-squad-webhook': + if (canOpenSettings) action = () => openSettingsDialog('notifications'); + else message = 'Open Notifications in Settings to configure and test external delivery.'; + break; + } + if (!action) return { accepted: false, message }; + flushSync(action); + return { accepted: true }; + }); + }, [ + canCreateTask, + canOpenSettings, + hasPermission, + openCreateDialog, + openSettingsDialog, + openSearchDialog, + onOpenCommandCenter, + onOpenDiagnostics, + resetDesktopLayout, + ]); + const handleChromeDoubleClick = useCallback( (event: MouseEvent) => { if (!isDesktopClient) return;