mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-08 20:57:51 +00:00
Merge pull request #1553 from BradGroux/fix/native-command-delivery-1524
fix: deliver native menu commands to the mounted renderer
This commit is contained in:
commit
42dc7ec41d
15 changed files with 380 additions and 64 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
73
desktop/src/main/__tests__/renderer-commands.test.ts
Normal file
73
desktop/src/main/__tests__/renderer-commands.test.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -151,7 +151,9 @@ export interface DesktopCommandDispatcherOptions {
|
|||
runtime: DesktopRuntime;
|
||||
shell: Shell;
|
||||
quit(): void;
|
||||
sendRendererCommand(command: DesktopCommandDispatchRequest): void;
|
||||
sendRendererCommand(
|
||||
command: DesktopCommandDispatchRequest
|
||||
): Promise<DesktopCommandDispatchResult>;
|
||||
checkForUpdates(): Promise<DesktopUpdateStatus>;
|
||||
downloadUpdate(): Promise<DesktopUpdateStatus>;
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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: () =>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
70
desktop/src/main/renderer-commands.ts
Normal file
70
desktop/src/main/renderer-commands.ts
Normal file
|
|
@ -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<DesktopCommandDispatchResult> {
|
||||
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<string, unknown>;
|
||||
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.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
63
scripts/native-ui/menu-commands.mjs
Normal file
63
scripts/native-ui/menu-commands.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -134,11 +134,15 @@ function DesktopAwareAppShell({
|
|||
setShowDesktopOnboarding: (open: boolean) => void;
|
||||
}) {
|
||||
const { isDesktopClient, bottomPanel } = useDesktopShell();
|
||||
const [commandCenterOpen, setCommandCenterOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Box className="desktop-app-shell min-h-screen bg-background">
|
||||
<SkipToContent />
|
||||
<Header />
|
||||
<Header
|
||||
onOpenDiagnostics={() => setShowDesktopOnboarding(true)}
|
||||
onOpenCommandCenter={() => setCommandCenterOpen(true)}
|
||||
/>
|
||||
<Suspense fallback={<div className="h-7 border-b border-border bg-muted/30" aria-hidden />}>
|
||||
<SystemHealthBar />
|
||||
</Suspense>
|
||||
|
|
@ -171,7 +175,7 @@ function DesktopAwareAppShell({
|
|||
<DesktopBottomPanel />
|
||||
</div>
|
||||
<Toaster />
|
||||
<CommandPalette />
|
||||
<CommandPalette nativeOpen={commandCenterOpen} onNativeOpenChange={setCommandCenterOpen} />
|
||||
<KeyboardShortcutsDialog />
|
||||
<Suspense fallback={null}>
|
||||
{!isDesktopClient && <MobileShell showChat={!bottomPanel} />}
|
||||
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ function ShellProbe() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={shell.resetDesktopLayout}>Reset layout</button>
|
||||
<output aria-label="left rail">{String(shell.leftRailOpen)}</output>
|
||||
<output aria-label="right rail">{String(shell.rightRailOpen)}</output>
|
||||
<output aria-label="bottom panel">{shell.bottomPanel ?? 'closed'}</output>
|
||||
|
|
@ -34,8 +35,6 @@ function ShellProbe() {
|
|||
}
|
||||
|
||||
describe('desktop shell recovery', () => {
|
||||
let menuListener: ((payload: { command: string }) => void) | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
const storage = new Map<string, string>();
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<DesktopShellContextValue>(
|
||||
() => ({
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>();
|
||||
|
|
@ -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<HTMLElement>) => {
|
||||
if (!isDesktopClient) return;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue