mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 21:27:53 +00:00
Merge remote-tracking branch 'origin/main' into fix/browser-onboarding-1535
# Conflicts: # docs/FEATURES.md
This commit is contained in:
commit
54cb28141d
60 changed files with 1489 additions and 211 deletions
|
|
@ -2,7 +2,6 @@
|
|||
cli/src/__tests__/snapshot.test.ts:generic-api-key:220
|
||||
|
||||
# API documentation contains non-functional response examples.
|
||||
docs/API-REFERENCE.md:generic-api-key:1043
|
||||
docs/API-WORKFLOWS.md:generic-api-key:1460
|
||||
|
||||
# Operator documentation uses placeholders in curl authentication examples.
|
||||
|
|
|
|||
133
desktop/src/main/__tests__/connection-redirects.test.ts
Normal file
133
desktop/src/main/__tests__/connection-redirects.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createDesktopBridgeHandlers } from '../bridge.js';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(async () => [{ address: '93.184.216.34', family: 4 }]),
|
||||
}));
|
||||
const transport = globalThis.fetch;
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
const handlers = () => createDesktopBridgeHandlers({} as never, {} as never, true, '6.1.7');
|
||||
|
||||
async function fixture(
|
||||
run: (origin: string, hits: string[]) => Promise<void>,
|
||||
location?: string,
|
||||
statusCode = 302
|
||||
) {
|
||||
const hits: string[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
hits.push(`${request.method} ${request.url}`);
|
||||
if (request.url?.startsWith('/redirect')) {
|
||||
response.writeHead(statusCode, { Location: location ?? '/trap' });
|
||||
response.end();
|
||||
} else {
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify(
|
||||
request.url?.includes('exchange')
|
||||
? { secret: 'synthetic-session' }
|
||||
: { authenticated: true }
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
await run(`http://127.0.0.1:${(server.address() as AddressInfo).port}`, hits);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
describe('remote connection transport policy', () => {
|
||||
it.each([301, 302, 303, 307, 308])(
|
||||
'does not follow a %s redirect for status, credentials, or pairing',
|
||||
async (code) => {
|
||||
for (const auth of [
|
||||
{},
|
||||
{ serverToken: 'synthetic-token' },
|
||||
{ pairingPayload: 'synthetic-code' },
|
||||
]) {
|
||||
await fixture(
|
||||
async (origin, hits) => {
|
||||
// Map only the selected synthetic HTTPS origin to the local fixture.
|
||||
// Node fetch itself handles the response and redirect policy.
|
||||
vi.stubGlobal('fetch', (_url: URL, init: RequestInit) =>
|
||||
transport(`${origin}/redirect`, init)
|
||||
);
|
||||
const result = await handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example',
|
||||
...auth,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(hits).toEqual([`${'pairingPayload' in auth ? 'POST' : 'GET'} /redirect`]);
|
||||
},
|
||||
undefined,
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
it.each([
|
||||
'http://example.test/next',
|
||||
'http://127.0.0.1/next',
|
||||
'http://10.0.0.1/next',
|
||||
'http://169.254.169.254/next',
|
||||
])('rejects redirect target %s without issuing a second request', async (location) => {
|
||||
for (const auth of [{}, { pairingPayload: 'synthetic-code' }]) {
|
||||
await fixture(
|
||||
async (origin, hits) => {
|
||||
vi.stubGlobal('fetch', (_url: URL, init: RequestInit) => {
|
||||
// Fail before transport if the guard regresses: never contact a real target.
|
||||
expect(init.redirect).toBe('error');
|
||||
return transport(`${origin}/redirect`, init);
|
||||
});
|
||||
const result = await handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example',
|
||||
...auth,
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(hits).toHaveLength(1);
|
||||
},
|
||||
location,
|
||||
307
|
||||
);
|
||||
}
|
||||
});
|
||||
it('preserves direct status, bearer authentication, and pairing followed by auth', async () => {
|
||||
await fixture(async (origin, hits) => {
|
||||
const requests: RequestInit[] = [];
|
||||
vi.stubGlobal('fetch', (url: URL, init: RequestInit) => {
|
||||
requests.push(init);
|
||||
return transport(new URL(url.pathname, origin), init);
|
||||
});
|
||||
for (const auth of [
|
||||
{},
|
||||
{ serverToken: 'synthetic-token' },
|
||||
{ pairingPayload: 'synthetic-code' },
|
||||
]) {
|
||||
await expect(
|
||||
handlers().validateConnectionConfig({
|
||||
mode: 'remote',
|
||||
serverUrl: 'https://remote.example',
|
||||
...auth,
|
||||
})
|
||||
).resolves.toMatchObject({ valid: true, errors: [] });
|
||||
}
|
||||
expect(hits).toEqual([
|
||||
'GET /api/auth/status',
|
||||
'GET /api/auth/context',
|
||||
'POST /api/auth/device-pairing/exchange',
|
||||
'GET /api/auth/context',
|
||||
]);
|
||||
expect(requests[1].headers).toEqual({ Authorization: 'Bearer synthetic-token' });
|
||||
expect(requests[2].body).toBe(JSON.stringify({ code: 'synthetic-code' }));
|
||||
expect(requests[3].headers).toEqual({ Authorization: 'Bearer synthetic-session' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -141,6 +141,7 @@ describe('desktop native menu', () => {
|
|||
'Veritas Kanban',
|
||||
'File',
|
||||
'editMenu',
|
||||
'View',
|
||||
'Navigate',
|
||||
'Desktop',
|
||||
'windowMenu',
|
||||
|
|
@ -174,3 +175,40 @@ describe('desktop native menu', () => {
|
|||
expect(template.some((item) => item.role === 'help')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('standard platform menu roles', () => {
|
||||
const roles = (platform: NodeJS.Platform) =>
|
||||
createDesktopMenuTemplate({
|
||||
platform,
|
||||
status: status(),
|
||||
dispatch: vi.fn(),
|
||||
copyVersionInfo: vi.fn(),
|
||||
openHelp: vi.fn(),
|
||||
}).flatMap((item) => (Array.isArray(item.submenu) ? item.submenu : [item]));
|
||||
it('declares standard macOS roles and accelerators while retaining custom actions', () => {
|
||||
const items = roles('darwin');
|
||||
for (const [role, accelerator] of [
|
||||
['quit', 'CommandOrControl+Q'],
|
||||
['close', 'CommandOrControl+W'],
|
||||
['hide', 'Command+H'],
|
||||
['hideOthers', 'Command+Alt+H'],
|
||||
['resetZoom', 'CommandOrControl+0'],
|
||||
['zoomIn', 'CommandOrControl+Plus'],
|
||||
['zoomOut', 'CommandOrControl+-'],
|
||||
['togglefullscreen', 'Control+Command+F'],
|
||||
]) {
|
||||
expect(items.find((item) => item.role === role)).toMatchObject({ accelerator });
|
||||
}
|
||||
expect(items.some((item) => item.role === 'services')).toBe(true);
|
||||
expect(items.some((item) => item.role === 'unhide')).toBe(true);
|
||||
// The native quit role emits app.before-quit, which owns managed shutdown.
|
||||
expect(items.find((item) => item.role === 'quit')?.click).toBeUndefined();
|
||||
});
|
||||
it.each(['linux', 'win32'] as const)('does not install Mac-only roles on %s', (platform) => {
|
||||
const items = roles(platform);
|
||||
expect(
|
||||
items.some((item) => ['services', 'hide', 'hideOthers', 'unhide'].includes(item.role ?? ''))
|
||||
).toBe(false);
|
||||
expect(items.find((item) => item.role === 'togglefullscreen')?.accelerator).toBe('F11');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
40
desktop/src/main/__tests__/titlebar-action.test.ts
Normal file
40
desktop/src/main/__tests__/titlebar-action.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { applyTitlebarAction, resolveTitlebarAction } from '../titlebar-action.js';
|
||||
|
||||
describe('system titlebar action', () => {
|
||||
it.each([
|
||||
['Maximize', 'zoom'],
|
||||
['Fill', 'zoom'],
|
||||
['Minimize', 'minimize'],
|
||||
['None', 'none'],
|
||||
['', 'zoom'],
|
||||
['future-value', 'none'],
|
||||
] as const)('maps macOS preference %s to %s', (preference, action) => {
|
||||
expect(resolveTitlebarAction('darwin', preference)).toBe(action);
|
||||
});
|
||||
it.each(['linux', 'win32'] as const)('preserves maximize on %s', (platform) => {
|
||||
expect(resolveTitlebarAction(platform, 'Minimize')).toBe('zoom');
|
||||
});
|
||||
it('applies only the selected action and restores a zoomed window', () => {
|
||||
let maximized = false;
|
||||
const window = {
|
||||
isMaximized: () => maximized,
|
||||
maximize: vi.fn(() => {
|
||||
maximized = true;
|
||||
}),
|
||||
unmaximize: vi.fn(() => {
|
||||
maximized = false;
|
||||
}),
|
||||
minimize: vi.fn(),
|
||||
};
|
||||
applyTitlebarAction(window as never, 'none');
|
||||
expect(window.maximize).not.toHaveBeenCalled();
|
||||
expect(window.minimize).not.toHaveBeenCalled();
|
||||
expect(applyTitlebarAction(window as never, 'zoom')).toEqual({ maximized: true });
|
||||
expect(applyTitlebarAction(window as never, 'zoom')).toEqual({ maximized: false });
|
||||
applyTitlebarAction(window as never, 'minimize');
|
||||
expect(window.minimize).toHaveBeenCalledOnce();
|
||||
expect(window.maximize).toHaveBeenCalledOnce();
|
||||
expect(window.unmaximize).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
|||
import { createDesktopPaths } from '../paths.js';
|
||||
import {
|
||||
applyDesktopWindowState,
|
||||
captureDesktopWindowState,
|
||||
readDesktopWindowState,
|
||||
writeDesktopWindowState,
|
||||
writeDesktopWindowStateSync,
|
||||
|
|
@ -68,3 +69,52 @@ describe('desktop window state', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('visible display restoration', () => {
|
||||
const primary = { x: 0, y: 25, width: 1440, height: 875 };
|
||||
const left = { x: -1920, y: -200, width: 1920, height: 1080 };
|
||||
it('moves disconnected-monitor windows to the primary work area', () => {
|
||||
expect(
|
||||
applyDesktopWindowState({ x: 5000, y: 300, width: 1200, height: 800 }, undefined, [primary])
|
||||
).toEqual({ x: 120, y: 63, width: 1200, height: 800 });
|
||||
});
|
||||
it('preserves valid negative monitor coordinates', () => {
|
||||
expect(
|
||||
applyDesktopWindowState({ x: -1800, y: -100, width: 1200, height: 800 }, undefined, [
|
||||
primary,
|
||||
left,
|
||||
])
|
||||
).toEqual({ x: -1800, y: -100, width: 1200, height: 800 });
|
||||
});
|
||||
it('clamps dimensions and titlebar to a smaller scaled work area', () => {
|
||||
expect(
|
||||
applyDesktopWindowState({ x: -300, y: -200, width: 4000, height: 4000 }, undefined, [
|
||||
{ x: 0, y: 24, width: 1024, height: 700 },
|
||||
])
|
||||
).toEqual({ x: 0, y: 24, width: 1024, height: 700 });
|
||||
});
|
||||
it('recovers malformed coordinates on a single screen', () => {
|
||||
expect(
|
||||
applyDesktopWindowState(
|
||||
{ x: Number.NaN, y: Number.POSITIVE_INFINITY, width: 0, height: Number.NaN },
|
||||
undefined,
|
||||
[primary]
|
||||
)
|
||||
).toEqual({ x: 130, y: 25, width: 1180, height: 875 });
|
||||
});
|
||||
it('captures normal bounds independently of maximized bounds', () => {
|
||||
const window = {
|
||||
getNormalBounds: vi.fn(() => ({ x: 30, y: 40, width: 1200, height: 800 })),
|
||||
getBounds: vi.fn(() => primary),
|
||||
isMaximized: () => true,
|
||||
};
|
||||
expect(captureDesktopWindowState(window as never)).toEqual({
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
maximized: true,
|
||||
});
|
||||
expect(window.getBounds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
type DesktopBridgeResponse,
|
||||
type DesktopConnectionConfigRequest,
|
||||
type DesktopConnectionValidationResult,
|
||||
type DesktopWindowToggleMaximizeResult,
|
||||
type DesktopWindowTitlebarActionResult,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
|
@ -38,7 +38,7 @@ export type DesktopBridgeHandlerMap = {
|
|||
};
|
||||
|
||||
export interface DesktopWindowControls {
|
||||
toggleMaximize(): DesktopWindowToggleMaximizeResult;
|
||||
performTitlebarAction(): DesktopWindowTitlebarActionResult;
|
||||
}
|
||||
|
||||
async function remoteConnectionDestinationError(serverUrl: string): Promise<string | null> {
|
||||
|
|
@ -114,6 +114,7 @@ async function validateRemoteConnection(
|
|||
try {
|
||||
const response = await fetch(statusUrl, {
|
||||
method: 'GET',
|
||||
redirect: 'error',
|
||||
headers: serverToken ? { Authorization: `Bearer ${serverToken}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -161,6 +162,7 @@ async function exchangeRemotePairingPayload(
|
|||
try {
|
||||
const response = await fetch(exchangeUrl, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(parseRemotePairingPayload(pairingPayload)),
|
||||
signal: controller.signal,
|
||||
|
|
@ -289,7 +291,7 @@ export function createDesktopBridgeHandlers(
|
|||
await shell.openExternal(url);
|
||||
return undefined;
|
||||
},
|
||||
toggleWindowMaximize: () => windowControls?.toggleMaximize() ?? { maximized: false },
|
||||
performTitlebarAction: () => windowControls?.performTitlebarAction() ?? { maximized: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { app, BrowserWindow, clipboard, ipcMain, Notification, safeStorage, shell } from 'electron';
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
ipcMain,
|
||||
Notification,
|
||||
safeStorage,
|
||||
shell,
|
||||
screen,
|
||||
systemPreferences,
|
||||
} from 'electron';
|
||||
import path from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
|
|
@ -6,6 +16,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 { applyTitlebarAction, resolveTitlebarAction } from './titlebar-action.js';
|
||||
import { sendAcknowledgedRendererCommand } from './renderer-commands.js';
|
||||
import { extractDeepLinkFromArgv, parseDesktopDeepLink } from './deep-links.js';
|
||||
import { configureDesktopMenu, dispatchDesktopMenuCommand } from './menu.js';
|
||||
|
|
@ -97,12 +108,17 @@ if (!app.requestSingleInstanceLock()) {
|
|||
|
||||
function createMainWindow(savedState: DesktopWindowState): BrowserWindow {
|
||||
const preloadPath = path.join(__dirname, '../preload/index.cjs');
|
||||
const windowBounds = applyDesktopWindowState(savedState);
|
||||
const primary = screen.getPrimaryDisplay();
|
||||
const workAreas = [
|
||||
primary,
|
||||
...screen.getAllDisplays().filter((display) => display.id !== primary.id),
|
||||
].map((display) => display.workArea);
|
||||
const windowBounds = applyDesktopWindowState(savedState, undefined, workAreas);
|
||||
|
||||
const window = new BrowserWindow({
|
||||
title: DESKTOP_APP_NAME,
|
||||
minWidth: DESKTOP_MIN_WINDOW.width,
|
||||
minHeight: DESKTOP_MIN_WINDOW.height,
|
||||
minWidth: Math.min(DESKTOP_MIN_WINDOW.width, windowBounds.width),
|
||||
minHeight: Math.min(DESKTOP_MIN_WINDOW.height, windowBounds.height),
|
||||
...windowBounds,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 18 } : undefined,
|
||||
|
|
@ -333,18 +349,16 @@ async function boot(): Promise<void> {
|
|||
commandDispatcher,
|
||||
updateService,
|
||||
{
|
||||
toggleMaximize: () => {
|
||||
const window = activeMainWindow();
|
||||
if (!window) {
|
||||
return { maximized: false };
|
||||
}
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
return { maximized: window.isMaximized() };
|
||||
},
|
||||
performTitlebarAction: () =>
|
||||
applyTitlebarAction(
|
||||
activeMainWindow(),
|
||||
resolveTitlebarAction(
|
||||
process.platform,
|
||||
process.platform === 'darwin'
|
||||
? systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string')
|
||||
: ''
|
||||
)
|
||||
),
|
||||
}
|
||||
);
|
||||
refreshDesktopMenu();
|
||||
|
|
@ -402,13 +416,31 @@ app.on('before-quit', (event) => {
|
|||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
let reopeningWindow = false;
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
void boot();
|
||||
}
|
||||
if (activeMainWindow() || reopeningWindow || quitting) return;
|
||||
reopeningWindow = true;
|
||||
void (async () => {
|
||||
// Closing the last Mac window keeps its managed server and IPC handlers alive.
|
||||
if (runtime && windowStatePaths) {
|
||||
const savedState = await readDesktopWindowState(windowStatePaths);
|
||||
if (quitting) return;
|
||||
mainWindow = createMainWindow(savedState);
|
||||
await mainWindow.loadURL(runtime.getRendererOrigin());
|
||||
flushPendingDeepLinks();
|
||||
} else {
|
||||
await boot();
|
||||
}
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
showDesktopError(error instanceof Error ? error.message : 'Unable to reopen the window.');
|
||||
})
|
||||
.finally(() => {
|
||||
reopeningWindow = false;
|
||||
});
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export function configureDesktopMenu(options: ConfigureDesktopMenuOptions): void
|
|||
export function createDesktopMenuTemplate(
|
||||
options: ConfigureDesktopMenuOptions
|
||||
): MenuItemConstructorOptions[] {
|
||||
const isMac = (options.platform ?? process.platform) === 'darwin';
|
||||
const command = (name: DesktopCommandName): MenuItemConstructorOptions => {
|
||||
const definition = DESKTOP_COMMAND_REGISTRY[name];
|
||||
return {
|
||||
|
|
@ -37,25 +38,24 @@ export function createDesktopMenuTemplate(
|
|||
};
|
||||
};
|
||||
|
||||
const macosMenus: MenuItemConstructorOptions[] =
|
||||
(options.platform ?? process.platform) === 'darwin'
|
||||
? [
|
||||
{ role: 'windowMenu' },
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Veritas Kanban Help',
|
||||
click: () => options.openHelp(),
|
||||
},
|
||||
{
|
||||
...command('open-onboarding'),
|
||||
label: 'Show Setup && Diagnostics',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const macosMenus: MenuItemConstructorOptions[] = isMac
|
||||
? [
|
||||
{ role: 'windowMenu' },
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Veritas Kanban Help',
|
||||
click: () => options.openHelp(),
|
||||
},
|
||||
{
|
||||
...command('open-onboarding'),
|
||||
label: 'Show Setup && Diagnostics',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{
|
||||
|
|
@ -76,7 +76,17 @@ export function createDesktopMenuTemplate(
|
|||
command('download-update'),
|
||||
command('install-update'),
|
||||
{ type: 'separator' },
|
||||
command('quit'),
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'services' as const },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'hide' as const, accelerator: 'Command+H' },
|
||||
{ role: 'hideOthers' as const, accelerator: 'Command+Alt+H' },
|
||||
{ role: 'unhide' as const },
|
||||
{ type: 'separator' as const },
|
||||
]
|
||||
: []),
|
||||
{ role: 'quit', accelerator: 'CommandOrControl+Q' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -86,9 +96,21 @@ export function createDesktopMenuTemplate(
|
|||
command('import-data'),
|
||||
command('export-data'),
|
||||
command('create-backup'),
|
||||
{ type: 'separator' },
|
||||
{ role: 'close', accelerator: 'CommandOrControl+W' },
|
||||
],
|
||||
},
|
||||
{ role: 'editMenu' },
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'resetZoom', accelerator: 'CommandOrControl+0' },
|
||||
{ role: 'zoomIn', accelerator: 'CommandOrControl+Plus' },
|
||||
{ role: 'zoomOut', accelerator: 'CommandOrControl+-' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen', accelerator: isMac ? 'Control+Command+F' : 'F11' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Navigate',
|
||||
submenu: [
|
||||
|
|
|
|||
33
desktop/src/main/titlebar-action.ts
Normal file
33
desktop/src/main/titlebar-action.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
export type TitlebarAction = 'zoom' | 'minimize' | 'none';
|
||||
|
||||
export function resolveTitlebarAction(
|
||||
platform: NodeJS.Platform,
|
||||
preference: string
|
||||
): TitlebarAction {
|
||||
if (platform !== 'darwin') return 'zoom';
|
||||
switch (preference) {
|
||||
case '': // Unset macOS preference uses the standard zoom behavior.
|
||||
case 'Maximize':
|
||||
case 'Fill':
|
||||
return 'zoom';
|
||||
case 'Minimize':
|
||||
return 'minimize';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTitlebarAction(
|
||||
window: BrowserWindow | null,
|
||||
action: TitlebarAction
|
||||
): { maximized: boolean } {
|
||||
if (!window) return { maximized: false };
|
||||
if (action === 'minimize') window.minimize();
|
||||
if (action === 'zoom') {
|
||||
if (window.isMaximized()) window.unmaximize();
|
||||
else window.maximize();
|
||||
}
|
||||
return { maximized: window.isMaximized() };
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
|
||||
import type { DesktopPaths } from './types.js';
|
||||
import { DESKTOP_MIN_WINDOW } from './app-metadata.js';
|
||||
|
||||
export interface DesktopWindowState {
|
||||
width: number;
|
||||
|
|
@ -45,7 +46,7 @@ export function writeDesktopWindowStateSync(paths: DesktopPaths, state: DesktopW
|
|||
}
|
||||
|
||||
export function captureDesktopWindowState(window: BrowserWindow): DesktopWindowState {
|
||||
const bounds = window.getBounds();
|
||||
const bounds = window.getNormalBounds();
|
||||
return {
|
||||
...boundsToWindowState(bounds),
|
||||
maximized: window.isMaximized(),
|
||||
|
|
@ -54,15 +55,57 @@ export function captureDesktopWindowState(window: BrowserWindow): DesktopWindowS
|
|||
|
||||
export function applyDesktopWindowState(
|
||||
state: DesktopWindowState,
|
||||
fallback = DEFAULT_DESKTOP_WINDOW_STATE
|
||||
fallback = DEFAULT_DESKTOP_WINDOW_STATE,
|
||||
workAreas: readonly Rectangle[] = []
|
||||
): Required<Pick<DesktopWindowState, 'width' | 'height'>> & Pick<DesktopWindowState, 'x' | 'y'> {
|
||||
const sanitized = sanitizeWindowState(state);
|
||||
return {
|
||||
const bounds = {
|
||||
width: sanitized.width || fallback.width,
|
||||
height: sanitized.height || fallback.height,
|
||||
x: sanitized.x,
|
||||
y: sanitized.y,
|
||||
};
|
||||
const areas = workAreas.filter(
|
||||
(area) =>
|
||||
[area.x, area.y, area.width, area.height].every(Number.isFinite) &&
|
||||
area.width > 0 &&
|
||||
area.height > 0
|
||||
);
|
||||
if (!areas.length) return bounds;
|
||||
// Electron's bounds and display work areas both use device-independent pixels.
|
||||
// Keep the monitor containing the largest part of the saved window. With no
|
||||
// intersection (disconnected display), use the first, primary work area.
|
||||
let area = areas[0];
|
||||
let largest = 0;
|
||||
if (bounds.x !== undefined && bounds.y !== undefined) {
|
||||
for (const candidate of areas) {
|
||||
const overlap =
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(bounds.x + bounds.width, candidate.x + candidate.width) -
|
||||
Math.max(bounds.x, candidate.x)
|
||||
) *
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(bounds.y + bounds.height, candidate.y + candidate.height) -
|
||||
Math.max(bounds.y, candidate.y)
|
||||
);
|
||||
if (overlap > largest) {
|
||||
largest = overlap;
|
||||
area = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
const width = Math.min(area.width, Math.max(DESKTOP_MIN_WINDOW.width, bounds.width));
|
||||
const height = Math.min(area.height, Math.max(DESKTOP_MIN_WINDOW.height, bounds.height));
|
||||
const x = largest > 0 && bounds.x !== undefined ? bounds.x : area.x + (area.width - width) / 2;
|
||||
const y = largest > 0 && bounds.y !== undefined ? bounds.y : area.y + (area.height - height) / 2;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
x: Math.round(Math.max(area.x, Math.min(x, area.x + area.width - width))),
|
||||
y: Math.round(Math.max(area.y, Math.min(y, area.y + area.height - height))),
|
||||
};
|
||||
}
|
||||
|
||||
function boundsToWindowState(bounds: Rectangle): DesktopWindowState {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
DesktopSetupDiagnostics,
|
||||
DesktopSupportSnapshot,
|
||||
DesktopUpdateStatus,
|
||||
DesktopWindowToggleMaximizeResult,
|
||||
DesktopWindowTitlebarActionResult,
|
||||
DesktopWorkProductExportRequest,
|
||||
DesktopWorkProductExportResult,
|
||||
} from '../shared/desktop-bridge-contracts.js';
|
||||
|
|
@ -37,7 +37,7 @@ const DESKTOP_BRIDGE_METHODS = {
|
|||
performNotificationAction: { channel: 'desktop:perform-notification-action' },
|
||||
exportWorkProduct: { channel: 'desktop:export-work-product' },
|
||||
openExternal: { channel: 'desktop:open-external' },
|
||||
toggleWindowMaximize: { channel: 'desktop:toggle-window-maximize' },
|
||||
performTitlebarAction: { channel: 'desktop:perform-titlebar-action' },
|
||||
} as const;
|
||||
|
||||
const DESKTOP_BRIDGE_EVENTS = {
|
||||
|
|
@ -90,7 +90,7 @@ export interface VeritasDesktopApi {
|
|||
request: DesktopWorkProductExportRequest
|
||||
): Promise<DesktopWorkProductExportResult>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
toggleWindowMaximize(): Promise<DesktopWindowToggleMaximizeResult>;
|
||||
performTitlebarAction(): Promise<DesktopWindowTitlebarActionResult>;
|
||||
onSetupProgress(listener: BridgeEventListener<'setupProgress'>): () => void;
|
||||
onCommunicationCheck(listener: BridgeEventListener<'communicationCheck'>): () => void;
|
||||
onServerStatus(listener: (status: DesktopStatusSnapshot) => void): () => void;
|
||||
|
|
@ -177,9 +177,9 @@ const api: VeritasDesktopApi = {
|
|||
),
|
||||
openExternal: (url: string) =>
|
||||
invokeDesktop<void>(DESKTOP_BRIDGE_METHODS.openExternal.channel, { url }),
|
||||
toggleWindowMaximize: () =>
|
||||
invokeDesktop<DesktopWindowToggleMaximizeResult>(
|
||||
DESKTOP_BRIDGE_METHODS.toggleWindowMaximize.channel
|
||||
performTitlebarAction: () =>
|
||||
invokeDesktop<DesktopWindowTitlebarActionResult>(
|
||||
DESKTOP_BRIDGE_METHODS.performTitlebarAction.channel
|
||||
),
|
||||
onSetupProgress: (listener) => onDesktopEvent('setupProgress', listener),
|
||||
onCommunicationCheck: (listener) => onDesktopEvent('communicationCheck', listener),
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export interface DesktopWorkProductExportResult {
|
|||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DesktopWindowToggleMaximizeResult {
|
||||
export interface DesktopWindowTitlebarActionResult {
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -295,9 +295,9 @@ export const DESKTOP_BRIDGE_METHODS = {
|
|||
dangerous: true,
|
||||
validator: 'openExternal',
|
||||
},
|
||||
toggleWindowMaximize: {
|
||||
performTitlebarAction: {
|
||||
capability: 'shell',
|
||||
channel: 'desktop:toggle-window-maximize',
|
||||
channel: 'desktop:perform-titlebar-action',
|
||||
desktopOnly: true,
|
||||
dangerous: false,
|
||||
},
|
||||
|
|
@ -317,7 +317,7 @@ export const DESKTOP_BRIDGE_METHOD_NAMES = [
|
|||
'performNotificationAction',
|
||||
'exportWorkProduct',
|
||||
'openExternal',
|
||||
'toggleWindowMaximize',
|
||||
'performTitlebarAction',
|
||||
] as const;
|
||||
|
||||
export type DesktopBridgeMethod = (typeof DESKTOP_BRIDGE_METHOD_NAMES)[number];
|
||||
|
|
@ -425,7 +425,7 @@ export interface DesktopBridgeRequestMap {
|
|||
performNotificationAction: DesktopNotificationActionRequest;
|
||||
exportWorkProduct: DesktopWorkProductExportRequest;
|
||||
openExternal: OpenExternalRequest;
|
||||
toggleWindowMaximize: undefined;
|
||||
performTitlebarAction: undefined;
|
||||
}
|
||||
|
||||
export interface DesktopBridgeResponseMap {
|
||||
|
|
@ -442,7 +442,7 @@ export interface DesktopBridgeResponseMap {
|
|||
performNotificationAction: DesktopNotificationActionResult;
|
||||
exportWorkProduct: DesktopWorkProductExportResult;
|
||||
openExternal: undefined;
|
||||
toggleWindowMaximize: DesktopWindowToggleMaximizeResult;
|
||||
performTitlebarAction: DesktopWindowTitlebarActionResult;
|
||||
}
|
||||
|
||||
export interface DesktopBridgeEventPayloadMap {
|
||||
|
|
|
|||
|
|
@ -316,6 +316,8 @@ PATCH /api/tasks/:id
|
|||
|
||||
**Body**: Partial task fields to update (title, description, status, priority, assignee, etc.).
|
||||
|
||||
Managed attempt state is owned by the run lifecycle APIs. Generic task updates reject an `attempt` replacement when the current attempt contains runtime, launch, admission, supervision, or other server-owned evidence. Ordinary task fields remain editable. Historical attempts containing only the legacy editor fields remain editable through this endpoint.
|
||||
|
||||
**Headers**:
|
||||
|
||||
```http
|
||||
|
|
@ -1040,7 +1042,7 @@ POST /api/auth/login
|
|||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token": "<jwt-token>",
|
||||
"role": "admin",
|
||||
"expiresIn": "24h"
|
||||
}
|
||||
|
|
@ -2405,7 +2407,7 @@ Mounted at `/api/agents`.
|
|||
POST /api/agents/route
|
||||
```
|
||||
|
||||
Accepts either a task ID or ad-hoc metadata:
|
||||
Accepts either a task ID or ad-hoc metadata: Metadata `subtaskCount` must be an integer from 0 through 500; it is evaluated as a count without creating task records.
|
||||
|
||||
**By task ID**:
|
||||
|
||||
|
|
@ -3900,6 +3902,8 @@ Mounted at `/api/cost-prediction`.
|
|||
POST /api/cost-prediction/predict
|
||||
```
|
||||
|
||||
Metadata `subtaskCount` accepts integers from 0 through 500. Existing tasks are evaluated using their stored subtask count.
|
||||
|
||||
**By task ID**:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -317,3 +317,17 @@ menu and the mounted renderer. File data commands and Debug Bundle open the exis
|
|||
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.
|
||||
|
||||
On macOS, Command-W closes the window while the managed server stays running.
|
||||
Reopen the window from the Dock. Command-Q quits and stops the managed server.
|
||||
Standard Hide, Hide Others, Show All and Services commands are available in the
|
||||
application menu; View provides text zoom and full screen.
|
||||
|
||||
Saved window bounds are fitted to the current display work areas at launch or
|
||||
reopen. If a monitor was disconnected, the window returns to the primary display.
|
||||
Maximized windows retain their previous normal bounds for unmaximizing.
|
||||
|
||||
The custom header follows the macOS title-bar double-click preference (zoom/fill,
|
||||
minimize, or no action). Configure it in [Desktop & Dock settings](https://support.apple.com/guide/mac-help/change-desktop-dock-settings-mchlp1119/mac).
|
||||
The native gate records the current preference and verifies its action without
|
||||
changing the operator's system preferences.
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ The Kanban board is the central interface — a drag-and-drop workspace that ref
|
|||
- **Resizable Workbench** — Board Chat and Squad Chat open in one bounded right-side dock, preserve the active conversation when switching channels, and clamp their width to keep the application shell recoverable
|
||||
- **Bulk operations** — Select multiple tasks to move, archive, or delete in batch; select-all toggle
|
||||
- **Client-aware first setup** — Browsers secure the connected server without native readiness checks or promises of local storage creation. The desktop app retains its native setup paths. Existing server or desktop data requires review before password creation.
|
||||
- **Keyboard shortcuts** — Navigate tasks (j/k, arrows), open (Enter), close (Esc), create (c), move to column (1-4), help (?)
|
||||
- **Keyboard shortcuts** — Navigate visible tasks in saved board order (j/k, arrows), focus and reveal the selected card, open (Enter), close (Esc), create (c), move to configured column (1-9), help (?)
|
||||
- **Loading skeleton** — Shimmer placeholders while the board loads
|
||||
- **Blocked column** — Dedicated column for blocked tasks with categorized reasons (waiting on feedback, technical snag, prerequisite, other)
|
||||
- **Comments** — Add, edit, and delete comments on tasks with author attribution and relative timestamps
|
||||
|
|
|
|||
|
|
@ -39,3 +39,13 @@ Identity tokens are `neutral`, `violet`, `cyan`, `orange`, `emerald`, `rose`, `a
|
|||
## Responsive and accessibility behavior
|
||||
|
||||
The plate and stamp stay compact in both board densities. Columns retain the board's existing responsive layout, while card metadata continues to wrap on narrow surfaces. Status glyphs, labels, counts, signal text, focus rings, and border changes preserve meaning in grayscale and common color-vision-deficiency conditions. Essential text and controls continue to use the established foreground and focus tokens; semantic color is supplemental.
|
||||
|
||||
### Filled action contrast
|
||||
|
||||
Filled controls use `--primary-action` with `--primary-foreground`, and
|
||||
`--primary-action-hover` for hover. These are separate from the brighter `--primary`
|
||||
accent used for text and focus in dark mode. Mantine filled Veritas controls and
|
||||
Tailwind filled selections share this pair. Do not use opacity to lighten a
|
||||
filled control with small white text. The native route gate measures normal,
|
||||
hover and focus text contrast on shared actions, Drift filters and populated
|
||||
Operations task identifiers in both themes.
|
||||
|
|
|
|||
72
scripts/native-ui/contrast.mjs
Normal file
72
scripts/native-ui/contrast.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/* global document, getComputedStyle */
|
||||
import assert from 'node:assert/strict';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export async function measureTextContrast(locator) {
|
||||
return locator.evaluate((element) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const rgba = (color) => {
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
return [...context.getImageData(0, 0, 1, 1).data];
|
||||
};
|
||||
const blend = (front, back) =>
|
||||
front
|
||||
.slice(0, 3)
|
||||
.map((value, index) => (value * front[3]) / 255 + back[index] * (1 - front[3] / 255));
|
||||
const ancestors = [];
|
||||
for (let node = element; node; node = node.parentElement) ancestors.unshift(node);
|
||||
let background = [255, 255, 255];
|
||||
for (const node of ancestors)
|
||||
background = blend(rgba(getComputedStyle(node).backgroundColor), background);
|
||||
const style = getComputedStyle(element);
|
||||
const foreground = blend(rgba(style.color), background);
|
||||
const luminance = (rgb) =>
|
||||
rgb
|
||||
.map((value) => {
|
||||
const v = value / 255;
|
||||
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index], 0);
|
||||
const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
|
||||
return {
|
||||
text: element.textContent.trim().slice(0, 80),
|
||||
foreground,
|
||||
background,
|
||||
ratio: (values[0] + 0.05) / (values[1] + 0.05),
|
||||
outline: style.outline,
|
||||
shadow: style.boxShadow,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyRouteContrast(page, route) {
|
||||
const targets = [];
|
||||
const primary = page.getByRole('button', { name: 'New Task', exact: true });
|
||||
targets.push(['primary-action', primary]);
|
||||
if (route === 'drift')
|
||||
targets.push(['selected-filter', page.getByRole('button', { name: 'all', exact: true })]);
|
||||
if (route === 'operations') {
|
||||
const code = page.locator('main code').first();
|
||||
await expect(code).toBeVisible(); // Requires the real seeded blocked task, never an empty-state pass.
|
||||
targets.push(['task-id', code]);
|
||||
}
|
||||
const results = [];
|
||||
for (const [label, target] of targets) {
|
||||
await expect(target).toBeVisible();
|
||||
for (const state of label === 'task-id' ? ['normal'] : ['normal', 'hover', 'focus']) {
|
||||
if (state === 'hover') await target.hover();
|
||||
if (state === 'focus') {
|
||||
await page.mouse.move(0, 0);
|
||||
await target.focus();
|
||||
}
|
||||
const measured = await measureTextContrast(target);
|
||||
assert(measured.ratio >= 4.5, `${route}/${label}/${state}: ${measured.ratio.toFixed(2)}:1`);
|
||||
results.push({ label, state, ...measured });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
|
@ -61,3 +61,131 @@ export async function verifyNativeMenuCommands(app, page) {
|
|||
assert(unsupported.message);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Native roles must be wired in Electron, including a live managed-server lifecycle. */
|
||||
export async function verifyNativeWindowMenu(app, page) {
|
||||
const items = await app.evaluate(({ Menu }) => {
|
||||
const flatten = (menu) =>
|
||||
menu.items.flatMap((item) => [
|
||||
{ role: item.role, accelerator: item.accelerator },
|
||||
...(item.submenu ? flatten(item.submenu) : []),
|
||||
]);
|
||||
return flatten(Menu.getApplicationMenu());
|
||||
});
|
||||
for (const role of [
|
||||
'quit',
|
||||
'close',
|
||||
'hide',
|
||||
'hideOthers',
|
||||
'unhide',
|
||||
'services',
|
||||
'resetZoom',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'togglefullscreen',
|
||||
]) {
|
||||
assert(
|
||||
items.some((item) => item.role === role),
|
||||
`Missing native role: ${role}`
|
||||
);
|
||||
}
|
||||
const clickRole = (role) =>
|
||||
app.evaluate(({ Menu }, role) => {
|
||||
const find = (menu) => {
|
||||
for (const item of menu.items) {
|
||||
if (item.role === role) return item;
|
||||
const nested = item.submenu && find(item.submenu);
|
||||
if (nested) return nested;
|
||||
}
|
||||
};
|
||||
const item = find(Menu.getApplicationMenu());
|
||||
if (!item?.enabled) throw new Error(`Role unavailable: ${role}`);
|
||||
item.click();
|
||||
}, role);
|
||||
const zoom = () =>
|
||||
app.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows()[0].webContents.getZoomFactor()
|
||||
);
|
||||
await clickRole('zoomIn');
|
||||
await expect.poll(zoom).toBeGreaterThan(1);
|
||||
await clickRole('resetZoom');
|
||||
await expect.poll(zoom).toBe(1);
|
||||
await clickRole('zoomOut');
|
||||
await expect.poll(zoom).toBeLessThan(1);
|
||||
await clickRole('resetZoom');
|
||||
await expect.poll(zoom).toBe(1);
|
||||
await clickRole('togglefullscreen');
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen())
|
||||
)
|
||||
.toBe(true);
|
||||
await clickRole('togglefullscreen');
|
||||
await expect
|
||||
.poll(() =>
|
||||
app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFullScreen())
|
||||
)
|
||||
.toBe(false);
|
||||
const normalBounds = await app.evaluate(({ BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
const bounds = window.getNormalBounds();
|
||||
window.maximize();
|
||||
return bounds;
|
||||
});
|
||||
await expect
|
||||
.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized()))
|
||||
.toBe(true);
|
||||
const before = await page.evaluate(() => window.veritasDesktop.getConnectionStatus());
|
||||
const closed = page.waitForEvent('close');
|
||||
await clickRole('close');
|
||||
await closed;
|
||||
assert.equal(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length), 0);
|
||||
const opened = app.waitForEvent('window');
|
||||
await app.evaluate(({ app }) => app.emit('activate'));
|
||||
const reopened = await opened;
|
||||
await expect(reopened.getByRole('button', { name: 'Settings', exact: true })).toBeVisible();
|
||||
const after = await reopened.evaluate(() => window.veritasDesktop.getConnectionStatus());
|
||||
assert.equal(
|
||||
after.server.pid,
|
||||
before.server.pid,
|
||||
'Closing the window restarted the managed server'
|
||||
);
|
||||
await expect
|
||||
.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isMaximized()))
|
||||
.toBe(true);
|
||||
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].unmaximize());
|
||||
await expect
|
||||
.poll(() => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].getBounds()))
|
||||
.toEqual(normalBounds);
|
||||
return { page: reopened, roles: items, normalBounds };
|
||||
}
|
||||
|
||||
export async function verifyConfiguredTitlebarAction(app, page) {
|
||||
const preference = await app.evaluate(({ systemPreferences }) =>
|
||||
systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string')
|
||||
);
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
window.restore();
|
||||
window.unmaximize();
|
||||
});
|
||||
const state = () =>
|
||||
app.evaluate(({ BrowserWindow }) => ({
|
||||
maximized: BrowserWindow.getAllWindows()[0].isMaximized(),
|
||||
minimized: BrowserWindow.getAllWindows()[0].isMinimized(),
|
||||
}));
|
||||
await expect.poll(state).toEqual({ maximized: false, minimized: false });
|
||||
await page.getByRole('navigation', { name: 'Main navigation' }).dispatchEvent('dblclick');
|
||||
const expected = {
|
||||
maximized: ['', 'Maximize', 'Fill'].includes(preference),
|
||||
minimized: preference === 'Minimize',
|
||||
};
|
||||
await expect.poll(state).toEqual(expected);
|
||||
await app.evaluate(({ BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
window.restore();
|
||||
window.unmaximize();
|
||||
window.focus();
|
||||
});
|
||||
return { preference: preference || 'system default', expected };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ 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 { verifyRouteContrast } from './contrast.mjs';
|
||||
import {
|
||||
verifyNativeMenuCommands,
|
||||
verifyNativeWindowMenu,
|
||||
verifyConfiguredTitlebarAction,
|
||||
} from './menu-commands.mjs';
|
||||
import {
|
||||
fileDigest,
|
||||
evidenceFailures,
|
||||
|
|
@ -210,6 +215,9 @@ async function capture(entry) {
|
|||
entry.screenshot = { path: name, sha256: await fileDigest(path.join(output, name)) };
|
||||
assert.deepEqual(geometryFailures(entry.geometry), [], entry.id);
|
||||
const route = routes.find(([name]) => entry.id.endsWith(`/route-${name}`));
|
||||
if (route && ['board', 'drift', 'operations'].includes(route[0])) {
|
||||
entry.contrast = await verifyRouteContrast(page, route[0]);
|
||||
}
|
||||
if (route)
|
||||
assert.deepEqual(
|
||||
pageHeaderFailures(
|
||||
|
|
@ -653,7 +661,20 @@ async function checkSeededRendererFailures() {
|
|||
}
|
||||
try {
|
||||
await launch();
|
||||
report.titlebarAction = await verifyConfiguredTitlebarAction(app, page);
|
||||
report.menuCommands = await verifyNativeMenuCommands(app, page);
|
||||
const windowMenu = await verifyNativeWindowMenu(app, page);
|
||||
page = windowMenu.page;
|
||||
report.menuRoles = windowMenu.roles;
|
||||
fixtureTask = await createTask('Native public-safe fixture');
|
||||
await page.evaluate(async (id) => {
|
||||
const response = await fetch(`/api/tasks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'blocked' }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Fixture status failed: ${response.status}`);
|
||||
}, fixtureTask.id);
|
||||
await persist();
|
||||
for (const mode of modes) {
|
||||
for (const state of states) {
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ describe('AgentRoutingService', () => {
|
|||
id: 'high-code',
|
||||
name: 'High-priority code owner',
|
||||
enabled: true,
|
||||
match: { type: 'code', priority: 'high' },
|
||||
match: { type: 'code', priority: 'high', minSubtasks: 5 },
|
||||
memberId: 'ops-lead',
|
||||
},
|
||||
],
|
||||
|
|
@ -472,6 +472,7 @@ describe('AgentRoutingService', () => {
|
|||
const result = await service.resolveAgentWithTrace({
|
||||
type: 'code',
|
||||
priority: 'high',
|
||||
subtaskCount: 5,
|
||||
});
|
||||
|
||||
expect(result.result.agent).toBe('amp');
|
||||
|
|
@ -695,6 +696,36 @@ describe('AgentRoutingService', () => {
|
|||
expect(result.rule).toBe('complex');
|
||||
});
|
||||
|
||||
it('routes scalar counts like real subtask collections', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
requireRouting(config).rules = [
|
||||
{
|
||||
id: 'complex',
|
||||
name: 'Complex tasks',
|
||||
match: { minSubtasks: 5 },
|
||||
agent: 'amp',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
mockGetConfig.mockResolvedValue(config);
|
||||
expect(
|
||||
(await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 5 })).rule
|
||||
).toBe('complex');
|
||||
expect(
|
||||
(await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 4 })).rule
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
await service.resolveAgent({
|
||||
type: 'feature',
|
||||
priority: 'medium',
|
||||
subtasks: [],
|
||||
subtaskCount: 5,
|
||||
})
|
||||
).rule
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT match when subtasks below threshold', async () => {
|
||||
const config = structuredClone(BASE_CONFIG);
|
||||
requireRouting(config).rules = [
|
||||
|
|
|
|||
23
server/src/__tests__/cost-prediction-count.test.ts
Normal file
23
server/src/__tests__/cost-prediction-count.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('../services/telemetry-service.js', () => ({
|
||||
getTelemetryService: () => ({ getEvents: async () => [] }),
|
||||
}));
|
||||
vi.mock('../services/task-service.js', () => ({ getTaskService: () => ({}) }));
|
||||
import { getCostPredictionService } from '../services/cost-prediction-service.js';
|
||||
|
||||
describe('cost prediction subtask counts', () => {
|
||||
it.each([0, 1, 2, 3, 5, 6, 500])('preserves prediction factors for count %i', async (count) => {
|
||||
const service = getCostPredictionService();
|
||||
const scalar = await service.predict({ subtaskCount: count });
|
||||
const stored = await service.predict({ subtasks: Array.from({ length: count }) });
|
||||
expect(scalar.factors).toEqual(stored.factors);
|
||||
expect(scalar.estimatedCost).toBe(stored.estimatedCost);
|
||||
});
|
||||
|
||||
it('uses actual task subtasks when both representations are present', async () => {
|
||||
const service = getCostPredictionService();
|
||||
const actual = await service.predict({ subtasks: [], subtaskCount: 500 });
|
||||
const empty = await service.predict({ subtasks: [] });
|
||||
expect(actual.factors).toEqual(empty.factors);
|
||||
});
|
||||
});
|
||||
|
|
@ -383,7 +383,7 @@ describe('admin-only governance routes', () => {
|
|||
type: 'feature',
|
||||
priority: 'medium',
|
||||
project: undefined,
|
||||
subtasks: undefined,
|
||||
subtaskCount: undefined,
|
||||
},
|
||||
{ requiredRuntimeCapabilities: undefined }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import type { AuthPermission, AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { diffAccess } from '../../routes/v1/permissions.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
|
||||
const { mockDiffService, mockCodexReviewService } = vi.hoisted(() => ({
|
||||
|
|
@ -73,3 +75,39 @@ describe('Codex review route', () => {
|
|||
expect(mockCodexReviewService.reviewTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mounted review permissions', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function appFor(permissions: AuthPermission[]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = { role: 'agent', isLocalhost: false, permissions };
|
||||
next();
|
||||
});
|
||||
app.use(['/api/diff', '/api/v1/diff'], diffAccess, diffRoutes);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
for (const prefix of ['/api/diff', '/api/v1/diff']) {
|
||||
for (const suffix of ['codex-review', 'CODEX-REVIEW', 'CoDeX-ReViEw/']) {
|
||||
it(`enforces execution authority at ${prefix}/:taskId/${suffix}`, async () => {
|
||||
const taskId = 'task_MixedCase';
|
||||
const path = `${prefix}/${taskId}/${suffix}`;
|
||||
const denied = await request(appFor(['task:write']))
|
||||
.post(path)
|
||||
.send({});
|
||||
expect(denied.status).toBe(403);
|
||||
expect(mockCodexReviewService.reviewTask).not.toHaveBeenCalled();
|
||||
mockCodexReviewService.reviewTask.mockResolvedValue({ taskId, decision: 'approved' });
|
||||
const allowed = await request(appFor(['workflow:execute']))
|
||||
.post(path)
|
||||
.send({});
|
||||
expect(allowed.status).toBe(201);
|
||||
expect(mockCodexReviewService.reviewTask).toHaveBeenCalledExactlyOnceWith({ taskId });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
90
server/src/__tests__/routes/metadata-preview-bounds.test.ts
Normal file
90
server/src/__tests__/routes/metadata-preview-bounds.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { authorizeWrite } from '../../middleware/auth.js';
|
||||
import { agentRoutingAccess, costPredictionAccess } from '../../routes/v1/permissions.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: vi.fn(),
|
||||
predict: vi.fn(),
|
||||
record: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/agent-routing-service.js', () => ({
|
||||
getAgentRoutingService: () => ({ resolveAgentWithTrace: mocks.route }),
|
||||
}));
|
||||
vi.mock('../../services/cost-prediction-service.js', () => ({
|
||||
getCostPredictionService: () => ({ predict: mocks.predict }),
|
||||
}));
|
||||
vi.mock('../../services/governance-trace-service.js', () => ({
|
||||
getGovernanceTraceService: () => ({ record: mocks.record }),
|
||||
}));
|
||||
vi.mock('../../services/task-service.js', () => ({
|
||||
getTaskService: () => ({ getTask: mocks.getTask }),
|
||||
}));
|
||||
import { agentRoutingRoutes } from '../../routes/agent-routing.js';
|
||||
import { costPredictionRoutes } from '../../routes/cost-prediction.js';
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = {
|
||||
role: 'agent',
|
||||
isLocalhost: false,
|
||||
permissions: ['agent:read', 'task:write'],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(authorizeWrite);
|
||||
app.use(['/api/agents', '/api/v1/agents'], agentRoutingAccess, agentRoutingRoutes);
|
||||
app.use(
|
||||
['/api/cost-prediction', '/api/v1/cost-prediction'],
|
||||
costPredictionAccess,
|
||||
costPredictionRoutes
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('bounded metadata previews', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.route.mockResolvedValue({ result: { agent: 'fixture' }, trace: {} });
|
||||
mocks.predict.mockResolvedValue({ estimatedCost: 1 });
|
||||
mocks.record.mockResolvedValue({ id: 'trace_fixture' });
|
||||
});
|
||||
|
||||
for (const prefix of ['/api', '/api/v1']) {
|
||||
for (const endpoint of ['/agents/route', '/cost-prediction/predict']) {
|
||||
it(`rejects invalid counts before evaluation at ${prefix}${endpoint}`, async () => {
|
||||
const app = createApp();
|
||||
// 501 proves the bound without risking a large allocation on a regressed build.
|
||||
for (const subtaskCount of [-1, 0.5, 501, '5', null]) {
|
||||
const response = await request(app)
|
||||
.post(prefix + endpoint)
|
||||
.send({ subtaskCount });
|
||||
expect(response.status).toBe(400);
|
||||
expect(mocks.route).not.toHaveBeenCalled();
|
||||
expect(mocks.predict).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it(`passes valid counts without materializing subtasks at ${prefix}${endpoint}`, async () => {
|
||||
const app = createApp();
|
||||
for (const subtaskCount of [0, 1, 500]) {
|
||||
await request(app)
|
||||
.post(prefix + endpoint)
|
||||
.send({ subtaskCount })
|
||||
.expect(200);
|
||||
const call =
|
||||
endpoint === '/agents/route' ? mocks.route.mock.lastCall : mocks.predict.mock.lastCall;
|
||||
expect(call?.[0].subtaskCount).toBe(subtaskCount);
|
||||
expect(call?.[0]).not.toHaveProperty('subtasks');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -116,6 +116,7 @@ vi.mock('../../middleware/cache-control.js', async () => {
|
|||
// Import after mocking
|
||||
import { taskRoutes } from '../../routes/tasks.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
import { taskAccess } from '../../routes/v1/permissions.js';
|
||||
|
||||
describe('Tasks Routes (actual module)', () => {
|
||||
let app: express.Express;
|
||||
|
|
@ -635,7 +636,8 @@ describe('Tasks Routes (actual module)', () => {
|
|||
model: 'llama3.2',
|
||||
threadId: 'thread_docs_refresh',
|
||||
}),
|
||||
})
|
||||
}),
|
||||
{ protectManagedAttempt: true }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -691,7 +693,7 @@ describe('Tasks Routes (actual module)', () => {
|
|||
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves authoritative run contracts when patching the same attempt', async () => {
|
||||
it('rejects generic same-ID changes to managed attempt authority and status', async () => {
|
||||
const taskEnvelope = { digest: 'immutable-envelope' };
|
||||
const completionResult = { status: 'success' };
|
||||
mockTaskService.getTask.mockResolvedValue({
|
||||
|
|
@ -708,17 +710,26 @@ describe('Tasks Routes (actual module)', () => {
|
|||
});
|
||||
mockTaskService.updateTask.mockImplementation(async (_id, input) => input);
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/t1')
|
||||
.send({ attempt: { id: 'attempt_1', agent: 'codex', status: 'complete' } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockTaskService.updateTask).toHaveBeenCalledWith(
|
||||
't1',
|
||||
expect.objectContaining({
|
||||
attempt: expect.objectContaining({ taskEnvelope, completionResult }),
|
||||
})
|
||||
);
|
||||
const scopedApp = express();
|
||||
scopedApp.use(express.json());
|
||||
scopedApp.use((req, _res, next) => {
|
||||
(req as import('../../middleware/auth.js').AuthenticatedRequest).auth = {
|
||||
role: 'agent',
|
||||
isLocalhost: false,
|
||||
permissions: ['task:write'],
|
||||
};
|
||||
next();
|
||||
});
|
||||
scopedApp.use(['/api/tasks', '/api/v1/tasks'], taskAccess, taskRoutes);
|
||||
scopedApp.use(errorHandler);
|
||||
for (const prefix of ['/api/tasks', '/api/v1/tasks']) {
|
||||
const res = await request(scopedApp)
|
||||
.patch(`${prefix}/t1`)
|
||||
.send({ attempt: { id: 'attempt_1', agent: 'codex', status: 'complete' } });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.message).toContain('run lifecycle APIs');
|
||||
}
|
||||
expect(mockTaskService.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects replacing an attempt that owns authoritative run contracts', async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import type { AuthPermission, AuthenticatedRequest } from '../../middleware/auth.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
import { workProductAccess } from '../../routes/v1/permissions.js';
|
||||
import { workProductRoutes } from '../../routes/work-products.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
|
|
@ -302,3 +303,55 @@ describe('work product artifact routes', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mounted artifact permissions', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function appFor(permissions: AuthPermission[]) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as AuthenticatedRequest).auth = {
|
||||
role: 'agent',
|
||||
isLocalhost: false,
|
||||
workspaceId: 'local',
|
||||
permissions,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use(['/api/work-products', '/api/v1/work-products'], workProductAccess, workProductRoutes);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it('preserves read-scoped preview audit access for mixed-case routes', async () => {
|
||||
mocks.listVersions.mockResolvedValue([
|
||||
{ id: 'wpa_html', version: 1, mediaType: 'text/html', state: 'available' },
|
||||
]);
|
||||
const response = await request(appFor(['work_product:read']))
|
||||
.post('/api/v1/work-products/wp_MixedCase/ARTIFACT/Preview/Audit/')
|
||||
.send({ action: 'close', version: 1 });
|
||||
expect(response.status).toBe(204);
|
||||
expect(mocks.auditLog).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
for (const prefix of ['/api/work-products', '/api/v1/work-products']) {
|
||||
for (const suffix of ['artifact', 'ARTIFACT', 'ArTiFaCt/']) {
|
||||
it(`enforces purge authority at ${prefix}/:id/${suffix}`, async () => {
|
||||
const productId = `wp_${'MixedCase'.repeat(3)}`;
|
||||
const path = `${prefix}/${productId}/${suffix}?confirm=${productId}`;
|
||||
const denied = await request(appFor(['work_product:write'])).delete(path);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(mocks.purge).not.toHaveBeenCalled();
|
||||
mocks.purge.mockResolvedValue({ productId, artifactsDeleted: 1, bytesDeleted: 14 });
|
||||
const allowed = await request(appFor(['admin:manage'])).delete(path);
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(mocks.purge).toHaveBeenCalledExactlyOnceWith({
|
||||
workspaceId: 'local',
|
||||
productId,
|
||||
confirmation: productId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -203,3 +203,17 @@ describe('shared API permission metadata', () => {
|
|||
).toEqual(['agent:write']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('case-insensitive API permission metadata', () => {
|
||||
it.each([
|
||||
['/API/V1/WORK-PRODUCTS/wp_MixedCase/ARTIFACT', 'DELETE', 'admin:manage'],
|
||||
['/api/work-products/wp_MixedCase/ArTiFaCt/', 'DELETE', 'admin:manage'],
|
||||
['/API/DIFF/task_MixedCase/CODEX-REVIEW', 'POST', 'workflow:execute'],
|
||||
['/api/v1/diff/task_MixedCase/CoDeX-ReViEw/', 'POST', 'workflow:execute'],
|
||||
['/api/work-products/wp_MixedCase/ARTIFACT/PREVIEW/AUDIT', 'POST', 'work_product:read'],
|
||||
])('matches server permissions for %s', (path, method, permission) => {
|
||||
const requirement = getApiPermissionRequirement(path, { method });
|
||||
expect(requirement.permissions).toEqual([permission]);
|
||||
expect(requirement.path).toContain('MixedCase');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
113
server/src/__tests__/task-attempt-edit.test.ts
Normal file
113
server/src/__tests__/task-attempt-edit.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_FEATURE_SETTINGS, type TaskAttempt } from '@veritas-kanban/shared';
|
||||
import { TaskService } from '../services/task-service.js';
|
||||
import { TelemetryService } from '../services/telemetry-service.js';
|
||||
import {
|
||||
createTestSqliteDatabase,
|
||||
type TestSqliteDatabase,
|
||||
} from '../storage/sqlite/test-helpers.js';
|
||||
|
||||
for (const storageType of ['file', 'sqlite'] as const) {
|
||||
describe(`generic attempt edits (${storageType})`, () => {
|
||||
let root: string;
|
||||
let database: TestSqliteDatabase | undefined;
|
||||
let service: TaskService;
|
||||
const legacy: TaskAttempt = { id: 'attempt_legacy', agent: 'codex', status: 'running' };
|
||||
|
||||
function openService() {
|
||||
return new TaskService({
|
||||
storageType,
|
||||
sqliteDatabase: database?.database,
|
||||
tasksDir: path.join(root, 'active'),
|
||||
archiveDir: path.join(root, 'archive'),
|
||||
telemetryService: new TelemetryService({
|
||||
telemetryDir: path.join(root, 'telemetry'),
|
||||
config: { enabled: false },
|
||||
}),
|
||||
configService: { getFeatureSettings: async () => DEFAULT_FEATURE_SETTINGS },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'veritas-attempt-edit-'));
|
||||
database = storageType === 'sqlite' ? createTestSqliteDatabase() : undefined;
|
||||
service = openService();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
service.dispose();
|
||||
database?.cleanup();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('denies same-ID status changes and replacements without changing durable evidence', async () => {
|
||||
const task = await service.createTask({ title: 'Managed attempt' });
|
||||
const managed: TaskAttempt = {
|
||||
...legacy,
|
||||
runSupervisorId: 'supervisor_test',
|
||||
admissionReservationId: 'reservation_test',
|
||||
};
|
||||
await service.updateTask(task.id, { attempt: managed, attempts: [managed] });
|
||||
const before = await service.getTask(task.id);
|
||||
for (const id of [managed.id, 'attempt_replacement']) {
|
||||
for (const status of ['running', 'complete', 'failed'] as const) {
|
||||
await expect(
|
||||
service.updateTask(
|
||||
task.id,
|
||||
{
|
||||
title: 'Must not be written',
|
||||
attempt: { ...legacy, id, status },
|
||||
},
|
||||
{ protectManagedAttempt: true }
|
||||
)
|
||||
).rejects.toThrow('run lifecycle APIs');
|
||||
}
|
||||
}
|
||||
service.dispose();
|
||||
service = openService();
|
||||
expect(await service.getTask(task.id)).toEqual(before);
|
||||
const renamed = await service.updateTask(task.id, { title: 'Ordinary edit' });
|
||||
expect(renamed?.attempt).toEqual(managed);
|
||||
expect(renamed?.attempts).toEqual([managed]);
|
||||
});
|
||||
|
||||
it('checks the current stored attempt after a launch replaces the route snapshot', async () => {
|
||||
const task = await service.createTask({ title: 'Concurrent launch' });
|
||||
await service.updateTask(task.id, { attempt: legacy });
|
||||
const routeSnapshot = await service.getTask(task.id);
|
||||
await service.updateTask(task.id, {
|
||||
attempt: { ...legacy, runSupervisorId: 'supervisor_new' },
|
||||
});
|
||||
await expect(
|
||||
service.updateTask(
|
||||
task.id,
|
||||
{
|
||||
attempt: { ...routeSnapshot!.attempt!, status: 'complete' },
|
||||
},
|
||||
{ protectManagedAttempt: true }
|
||||
)
|
||||
).rejects.toThrow('run lifecycle APIs');
|
||||
expect((await service.getTask(task.id))?.attempt?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('preserves legacy edits and dedicated lifecycle updates', async () => {
|
||||
const task = await service.createTask({ title: 'Legacy attempt' });
|
||||
await service.updateTask(task.id, { attempt: legacy }, { protectManagedAttempt: true });
|
||||
const completed = await service.updateTask(
|
||||
task.id,
|
||||
{
|
||||
attempt: { ...legacy, status: 'complete' },
|
||||
},
|
||||
{ protectManagedAttempt: true }
|
||||
);
|
||||
expect(completed?.attempt?.status).toBe('complete');
|
||||
const managed = { ...legacy, runSupervisorId: 'supervisor_lifecycle' };
|
||||
await service.updateTask(task.id, { attempt: managed });
|
||||
await service.patchTaskAttempt(task.id, managed.id, { status: 'complete' });
|
||||
expect((await service.getTask(task.id))?.attempt).toEqual({ ...managed, status: 'complete' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -724,7 +724,15 @@ function normalizePermissions(permissions: PermissionInput): AuthPermission[] {
|
|||
export function authorizePermissionByMethod(config: MethodPermissionConfig) {
|
||||
const readPermissions = normalizePermissions(config.read);
|
||||
const writePermissions = normalizePermissions(config.write ?? config.read);
|
||||
const overrides = config.overrides ?? [];
|
||||
// Express routers match route literals case-insensitively by default. Match
|
||||
// their permission overrides the same way without changing parameter values.
|
||||
// Stateful regex flags must not make authorization depend on earlier requests.
|
||||
const overrides = (config.overrides ?? []).map((override) => ({
|
||||
...override,
|
||||
path: override.path
|
||||
? new RegExp(override.path.source, override.path.flags.replace(/[giy]/g, '') + 'i')
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
|
||||
const override = overrides.find((candidate) => {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const routeByMetadataSchema = z
|
|||
type: z.string().optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
subtaskCount: z.number().int().min(0).max(500).optional(),
|
||||
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
|
@ -129,14 +129,7 @@ router.post(
|
|||
type: type || 'feature',
|
||||
priority: priority || 'medium',
|
||||
project,
|
||||
subtasks: subtaskCount
|
||||
? Array.from({ length: subtaskCount }, (_, i) => ({
|
||||
id: `stub_${i}`,
|
||||
title: '',
|
||||
completed: false,
|
||||
created: new Date().toISOString(),
|
||||
}))
|
||||
: undefined,
|
||||
subtaskCount,
|
||||
},
|
||||
{ requiredRuntimeCapabilities }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const predictByMetadataSchema = z.object({
|
|||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
subtaskCount: z.number().int().nonnegative().optional(),
|
||||
subtaskCount: z.number().int().min(0).max(500).optional(),
|
||||
});
|
||||
|
||||
// ─── Routes ──────────────────────────────────────────────────────
|
||||
|
|
@ -77,7 +77,7 @@ router.post(
|
|||
priority,
|
||||
project,
|
||||
description,
|
||||
subtasks: subtaskCount ? Array.from({ length: subtaskCount }) : undefined,
|
||||
subtaskCount,
|
||||
});
|
||||
return res.json(prediction);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { assertLegacyAttemptEditable } from '../utils/task-attempt-edit.js';
|
||||
import { Router, type NextFunction, type Response, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getTaskService } from '../services/task-service.js';
|
||||
|
|
@ -1014,27 +1015,7 @@ router.patch(
|
|||
if (!oldTask) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
const authoritativeAttempt = oldTask.attempt;
|
||||
if (
|
||||
input.attempt &&
|
||||
authoritativeAttempt &&
|
||||
(authoritativeAttempt.taskEnvelope || authoritativeAttempt.completionResult)
|
||||
) {
|
||||
if (input.attempt.id !== authoritativeAttempt.id) {
|
||||
throw new ValidationError(
|
||||
'Generic task updates cannot replace an attempt with an authoritative run contract'
|
||||
);
|
||||
}
|
||||
input.attempt = {
|
||||
...input.attempt,
|
||||
...(authoritativeAttempt.taskEnvelope
|
||||
? { taskEnvelope: authoritativeAttempt.taskEnvelope }
|
||||
: {}),
|
||||
...(authoritativeAttempt.completionResult
|
||||
? { completionResult: authoritativeAttempt.completionResult }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (input.attempt) assertLegacyAttemptEditable(oldTask.attempt);
|
||||
assertFreshRevision(req, 'task', oldTask.id, oldTask);
|
||||
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
|
@ -1100,7 +1081,11 @@ router.patch(
|
|||
input.blockedReason = null;
|
||||
}
|
||||
|
||||
const task = await taskService.updateTask(req.params.id as string, input);
|
||||
const task = input.attempt
|
||||
? await taskService.updateTask(req.params.id as string, input, {
|
||||
protectManagedAttempt: true,
|
||||
})
|
||||
: await taskService.updateTask(req.params.id as string, input);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ import { selectProviderRuntimeManifest } from './provider-runtime-capability-ser
|
|||
|
||||
const log = createLogger('agent-routing');
|
||||
|
||||
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
|
||||
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'> & {
|
||||
subtaskCount?: number;
|
||||
};
|
||||
|
||||
interface RoutingTraceContext {
|
||||
taskId?: string;
|
||||
|
|
@ -114,7 +116,7 @@ export class AgentRoutingService {
|
|||
type: task.type,
|
||||
priority: task.priority,
|
||||
project: task.project,
|
||||
subtaskCount: task.subtasks?.length,
|
||||
subtaskCount: task.subtasks?.length ?? task.subtaskCount,
|
||||
},
|
||||
config.teamRoster
|
||||
);
|
||||
|
|
@ -433,7 +435,7 @@ export class AgentRoutingService {
|
|||
* Used when an agent fails and `fallbackOnFailure` is enabled.
|
||||
*/
|
||||
async getFallback(
|
||||
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
task: RoutableTask,
|
||||
failedAgent: AgentType,
|
||||
context: FallbackRoutingContext = {}
|
||||
): Promise<RoutingResult | null> {
|
||||
|
|
@ -636,10 +638,7 @@ export class AgentRoutingService {
|
|||
* All specified criteria must match (AND logic).
|
||||
* Unspecified criteria are ignored (wildcard).
|
||||
*/
|
||||
private matchesRule(
|
||||
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
match: RoutingMatchCriteria
|
||||
): boolean {
|
||||
private matchesRule(task: RoutableTask, match: RoutingMatchCriteria): boolean {
|
||||
// Type check
|
||||
if (match.type !== undefined) {
|
||||
if (!this.matchesValue(task.type, match.type)) return false;
|
||||
|
|
@ -658,7 +657,7 @@ export class AgentRoutingService {
|
|||
|
||||
// Complexity (subtask count)
|
||||
if (match.minSubtasks !== undefined) {
|
||||
const subtaskCount = task.subtasks?.length ?? 0;
|
||||
const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0;
|
||||
if (subtaskCount < match.minSubtasks) return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class CostPredictionService {
|
|||
project?: string;
|
||||
description?: string;
|
||||
subtasks?: Array<unknown>;
|
||||
subtaskCount?: number;
|
||||
}): Promise<CostPrediction> {
|
||||
// 1. Get historical base cost from telemetry
|
||||
const historicalBase = await this.getHistoricalBaseCost(task.type, task.project);
|
||||
|
|
@ -138,7 +139,7 @@ class CostPredictionService {
|
|||
|
||||
// 4. Estimate complexity from description length + subtask count
|
||||
const descLength = (task.description || '').length;
|
||||
const subtaskCount = task.subtasks?.length || 0;
|
||||
const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0;
|
||||
let complexityMultiplier: number;
|
||||
if (descLength < COMPLEXITY_THRESHOLDS.simple && subtaskCount === 0) {
|
||||
complexityMultiplier = COMPLEXITY_MULTIPLIERS.simple;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { assertLegacyAttemptEditable } from '../utils/task-attempt-edit.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type {
|
||||
Task,
|
||||
|
|
@ -93,6 +94,7 @@ interface BoardStatusConfig {
|
|||
}
|
||||
|
||||
type TaskMutationInput = UpdateTaskInput & {
|
||||
protectManagedAttempt?: boolean;
|
||||
attemptPatch?: Pick<TaskAttempt, 'id'> & Partial<Omit<TaskAttempt, 'id'>>;
|
||||
lastBoardMove?: TaskBoardMoveReceipt;
|
||||
boardRank?: string | null;
|
||||
|
|
@ -781,10 +783,17 @@ export class TaskService {
|
|||
return task;
|
||||
}
|
||||
|
||||
async updateTask(id: string, input: UpdateTaskInput): Promise<Task | null> {
|
||||
async updateTask(
|
||||
id: string,
|
||||
input: UpdateTaskInput,
|
||||
options: { protectManagedAttempt?: boolean } = {}
|
||||
): Promise<Task | null> {
|
||||
const affectsBoard = input.position !== undefined || input.status !== undefined;
|
||||
const mutationInput: TaskMutationInput =
|
||||
input.position !== undefined ? { ...input, boardRank: null } : input;
|
||||
const mutationInput: TaskMutationInput = {
|
||||
...input,
|
||||
...(input.position !== undefined ? { boardRank: null } : {}),
|
||||
protectManagedAttempt: options.protectManagedAttempt,
|
||||
};
|
||||
if (affectsBoard) {
|
||||
return this.withBoardMoveMutex((commitStorage) =>
|
||||
this.withTaskMutex(id, () =>
|
||||
|
|
@ -838,6 +847,7 @@ export class TaskService {
|
|||
boardRank: boardRankUpdate,
|
||||
expectedRevision: _expectedRevision,
|
||||
attemptPatch,
|
||||
protectManagedAttempt,
|
||||
...restInput
|
||||
} = input;
|
||||
|
||||
|
|
@ -874,6 +884,12 @@ export class TaskService {
|
|||
? ((await this.sqliteTasks.findById(id)) ?? task)
|
||||
: (fileMutationTask ?? task);
|
||||
|
||||
// Check inside the storage lock: launch may have installed a managed
|
||||
// attempt after the generic route read the previous task revision.
|
||||
if (protectManagedAttempt && input.attempt) {
|
||||
assertLegacyAttemptEditable(freshTask.attempt);
|
||||
}
|
||||
|
||||
if (attemptPatch && freshTask.attempt?.id !== attemptPatch.id) {
|
||||
updatedTask = freshTask;
|
||||
return;
|
||||
|
|
|
|||
24
server/src/utils/task-attempt-edit.ts
Normal file
24
server/src/utils/task-attempt-edit.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { TaskAttempt } from '@veritas-kanban/shared';
|
||||
import { ValidationError } from '../middleware/error-handler.js';
|
||||
|
||||
// The historical generic task editor supports only these legacy fields. Any
|
||||
// additional field identifies server-owned evidence, including future contracts.
|
||||
const LEGACY_ATTEMPT_FIELDS = new Set([
|
||||
'id',
|
||||
'agent',
|
||||
'status',
|
||||
'started',
|
||||
'ended',
|
||||
'provider',
|
||||
'model',
|
||||
'threadId',
|
||||
'cloudUrl',
|
||||
'cloudTarget',
|
||||
'orchestration',
|
||||
]);
|
||||
|
||||
export function assertLegacyAttemptEditable(attempt: TaskAttempt | undefined): void {
|
||||
if (attempt && Object.keys(attempt).some((key) => !LEGACY_ATTEMPT_FIELDS.has(key))) {
|
||||
throw new ValidationError('Managed attempts can only be changed through run lifecycle APIs');
|
||||
}
|
||||
}
|
||||
|
|
@ -81,9 +81,9 @@ function normalizeApiPath(path: string): string {
|
|||
const url = new URL(path, 'http://veritas.local');
|
||||
let normalized = url.pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (normalized === '/api/v1') {
|
||||
if (normalized.toLowerCase() === '/api/v1') {
|
||||
normalized = '/api';
|
||||
} else if (normalized.startsWith('/api/v1/')) {
|
||||
} else if (normalized.toLowerCase().startsWith('/api/v1/')) {
|
||||
normalized = `/api${normalized.slice('/api/v1'.length)}`;
|
||||
}
|
||||
|
||||
|
|
@ -95,11 +95,12 @@ function routeRequirement(
|
|||
path: string,
|
||||
method: string
|
||||
): ApiPermissionRequirement | null {
|
||||
if (path !== config.prefix && !path.startsWith(`${config.prefix}/`)) {
|
||||
const matchingPath = path.toLowerCase();
|
||||
if (matchingPath !== config.prefix && !matchingPath.startsWith(`${config.prefix}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = path.slice(config.prefix.length) || '/';
|
||||
const relativePath = matchingPath.slice(config.prefix.length) || '/';
|
||||
const override = config.overrides?.find((candidate) => {
|
||||
const methodMatches =
|
||||
!candidate.methods ||
|
||||
|
|
@ -500,7 +501,7 @@ export function getApiPermissionRequirement(
|
|||
const method = (options.method || 'GET').toUpperCase();
|
||||
const normalizedPath = normalizeApiPath(path);
|
||||
|
||||
if (isPublicApiPath(normalizedPath)) {
|
||||
if (isPublicApiPath(normalizedPath.toLowerCase())) {
|
||||
return { permissions: [], path: normalizedPath, method, public: true };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,14 +89,13 @@ vi.mock('@/hooks/useAgentStatus', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useKeyboard', () => ({
|
||||
useKeyboard: () => ({
|
||||
selectedTaskId: null,
|
||||
setTasks: vi.fn(),
|
||||
setOnOpenTask: vi.fn(),
|
||||
setOnMoveTask: vi.fn(),
|
||||
}),
|
||||
const keyboardRegistration = vi.hoisted(() => ({
|
||||
selectedTaskId: null,
|
||||
setTasks: vi.fn(),
|
||||
setOnOpenTask: vi.fn(),
|
||||
setOnMoveTask: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/hooks/useKeyboard', () => ({ useKeyboard: () => keyboardRegistration }));
|
||||
|
||||
vi.mock('@/hooks/useFeatureSettings', () => ({
|
||||
useFeatureSettings: () => mockFeatureSettingsResult,
|
||||
|
|
@ -267,7 +266,7 @@ function renderDesktopBoard() {
|
|||
Object.defineProperty(window, 'veritasDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
toggleWindowMaximize: vi.fn(),
|
||||
performTitlebarAction: vi.fn(),
|
||||
},
|
||||
});
|
||||
window.localStorage.setItem('veritas.desktop.rightRailOpen', 'false');
|
||||
|
|
@ -310,6 +309,17 @@ afterEach(() => {
|
|||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
describe('KanbanBoard', () => {
|
||||
it('removes task navigation and callbacks when the board unmounts', () => {
|
||||
mockUseTasks = () => ({ data: mockTasks, isLoading: false, error: null });
|
||||
const view = renderBoard();
|
||||
expect(keyboardRegistration.setOnOpenTask).toHaveBeenLastCalledWith(expect.any(Function));
|
||||
expect(keyboardRegistration.setOnMoveTask).toHaveBeenLastCalledWith(expect.any(Function));
|
||||
view.unmount();
|
||||
expect(keyboardRegistration.setTasks).toHaveBeenLastCalledWith([]);
|
||||
expect(keyboardRegistration.setOnOpenTask).toHaveBeenLastCalledWith(null);
|
||||
expect(keyboardRegistration.setOnMoveTask).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('shows loading skeleton when data is loading', () => {
|
||||
mockUseTasks = () => ({ data: undefined, isLoading: true, error: null });
|
||||
renderBoard();
|
||||
|
|
|
|||
|
|
@ -156,6 +156,19 @@ describe('TaskCard', () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it('focuses and reveals keyboard selection with an accessible label', () => {
|
||||
ensureMantineBrowserApis();
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {});
|
||||
try {
|
||||
renderCard(createMockTask({ title: 'Keyboard target' }), { isSelected: true });
|
||||
const card = screen.getByRole('article', { name: /^Selected\. Task: Keyboard target/ });
|
||||
expect(document.activeElement).toBe(card);
|
||||
expect(scroll).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
|
||||
} finally {
|
||||
scroll.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders task title', () => {
|
||||
const task = createMockTask({ title: 'Implement login' });
|
||||
renderCard(task);
|
||||
|
|
|
|||
80
web/src/__tests__/keyboard-shortcut-help.test.tsx
Normal file
80
web/src/__tests__/keyboard-shortcut-help.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
import { KeyboardProvider } from '@/hooks/useKeyboard';
|
||||
import { KeyboardShortcutsDialog } from '@/components/layout/KeyboardShortcutsDialog';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const config = vi.hoisted(() => ({
|
||||
settings: { board: { columns: [] as Array<{ id: string; title: string }> } },
|
||||
}));
|
||||
vi.mock('@/hooks/useFeatureSettings', () => ({ useFeatureSettings: () => config }));
|
||||
|
||||
function Surface() {
|
||||
return (
|
||||
<KeyboardProvider>
|
||||
<KeyboardShortcutsDialog />
|
||||
</KeyboardProvider>
|
||||
);
|
||||
}
|
||||
function keyFor(label: string) {
|
||||
const description = screen.getByText(`Move to ${label}`);
|
||||
const row = description.parentElement;
|
||||
if (!row) throw new Error('Shortcut description has no row');
|
||||
return within(row).getByText(/^\d$/).textContent;
|
||||
}
|
||||
describe('configured shortcut help', () => {
|
||||
beforeEach(() => {
|
||||
config.settings.board.columns = [...DEFAULT_FEATURE_SETTINGS.board.columns];
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('matches default destinations and updates rename, order, additions and removals while open', async () => {
|
||||
const view = renderWithProviders(<Surface />);
|
||||
fireEvent.keyDown(window, { key: '?' });
|
||||
await screen.findByRole('dialog');
|
||||
for (const [i, column] of DEFAULT_FEATURE_SETTINGS.board.columns.entries()) {
|
||||
expect(keyFor(column.title)).toBe(String(i + 1));
|
||||
}
|
||||
expect(screen.queryByText('Move to Planning')).toBeNull();
|
||||
config.settings.board.columns = [
|
||||
{ id: 'done', title: 'Complete' },
|
||||
{ id: 'ready', title: 'Ready' },
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
];
|
||||
view.rerender(<Surface />);
|
||||
expect(keyFor('Complete')).toBe('1');
|
||||
expect(keyFor('Ready')).toBe('2');
|
||||
expect(keyFor('To Do')).toBe('3');
|
||||
expect(screen.queryByText('Move to In Progress')).toBeNull();
|
||||
expect(screen.queryByText('Move to Done')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists only nine numeric destinations and explains the limit', async () => {
|
||||
config.settings.board.columns = Array.from({ length: 11 }, (_, i) => ({
|
||||
id: `stage-${i}`,
|
||||
title: `Stage ${i + 1}`,
|
||||
}));
|
||||
renderWithProviders(<Surface />);
|
||||
fireEvent.keyDown(window, { key: '?' });
|
||||
await screen.findByRole('dialog');
|
||||
expect(keyFor('Stage 9')).toBe('9');
|
||||
expect(screen.queryByText('Move to Stage 10')).toBeNull();
|
||||
expect(screen.getByText(/Number shortcuts cover the first nine columns/)).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['MacIntel', '⌘⇧C'],
|
||||
['Win32', 'Ctrl+Shift+C'],
|
||||
['Linux x86_64', 'Ctrl+Shift+C'],
|
||||
])('labels chat modifiers for %s', async (platform, label) => {
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue(platform);
|
||||
renderWithProviders(<Surface />);
|
||||
fireEvent.keyDown(window, { key: '?' });
|
||||
await screen.findByRole('dialog');
|
||||
expect(screen.getByText(label)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, screen, waitFor, within } from '@testing-library/react';
|
||||
import { cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ViewProvider } from '@/contexts/ViewContext';
|
||||
|
|
@ -143,7 +143,7 @@ function renderDesktopHeaderChrome(options: { withBottomPanel?: boolean } = {})
|
|||
Object.defineProperty(window, 'veritasDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
toggleWindowMaximize: vi.fn(),
|
||||
performTitlebarAction: vi.fn(),
|
||||
},
|
||||
});
|
||||
document.documentElement.dataset.client = 'desktop';
|
||||
|
|
@ -381,10 +381,26 @@ describe('layout chrome Mantine migration', () => {
|
|||
expect(container.querySelector('.lucide-panel-right-close')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends titlebar double clicks only from the header background', () => {
|
||||
renderDesktopHeaderChrome();
|
||||
const action = (
|
||||
window as unknown as { veritasDesktop: { performTitlebarAction: ReturnType<typeof vi.fn> } }
|
||||
).veritasDesktop.performTitlebarAction;
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: 'New Task' }));
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: 'Settings' }));
|
||||
const input = document.createElement('input');
|
||||
screen.getByRole('navigation', { name: 'Main navigation' }).append(input);
|
||||
fireEvent.doubleClick(input);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
input.remove();
|
||||
fireEvent.doubleClick(screen.getByRole('navigation', { name: 'Main navigation' }));
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('uses the filled brand treatment with white text for the active desktop navigation item', () => {
|
||||
Object.defineProperty(window, 'veritasDesktop', {
|
||||
configurable: true,
|
||||
value: { toggleWindowMaximize: vi.fn() },
|
||||
value: { performTitlebarAction: vi.fn() },
|
||||
});
|
||||
document.documentElement.dataset.client = 'desktop';
|
||||
window.history.replaceState({}, '', '/drift');
|
||||
|
|
|
|||
|
|
@ -63,6 +63,19 @@ describe('desktop UI vocabulary', () => {
|
|||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps filled action text above AA contrast in both schemes and hover states', () => {
|
||||
const css = readFileSync('src/globals.css', 'utf8');
|
||||
const fills = [...css.matchAll(/--primary-action(?:-hover)?: (#[a-f0-9]{6});/g)].map(
|
||||
(match) => match[1]
|
||||
);
|
||||
expect(fills).toHaveLength(4);
|
||||
for (const fill of fills) {
|
||||
expect((luminance('#ffffff') + 0.05) / (luminance(fill) + 0.05), fill).toBeGreaterThanOrEqual(
|
||||
4.5
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every semantic foreground above 4.5:1 and the rendered CSS palette in sync', () => {
|
||||
const css = readFileSync('src/globals.css', 'utf8');
|
||||
for (const [scheme, palette] of Object.entries(VERITAS_SEMANTIC_PALETTE)) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import type { Task, TaskStatus } from '@veritas-kanban/shared';
|
||||
import { taskBoardRankAtIndex, type Task, type TaskStatus } from '@veritas-kanban/shared';
|
||||
import { createMockTask } from './test-utils';
|
||||
|
||||
// Mock toast — vi.mock is hoisted before imports.
|
||||
|
|
@ -100,6 +100,54 @@ describe('KeyboardProvider', () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it('follows positions and durable ranks across custom columns, reorder, and filtering', () => {
|
||||
featureSettingsMock.settings.board.columns = [
|
||||
{ id: 'ready', title: 'Ready' },
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
];
|
||||
const legacy = createMockTask({ id: 'legacy', title: 'Zulu', status: 'ready', position: 1 });
|
||||
const later = createMockTask({ id: 'later', title: 'Alpha', status: 'ready', position: 5 });
|
||||
const ranked = createMockTask({
|
||||
id: 'ranked',
|
||||
title: 'Middle',
|
||||
status: 'ready',
|
||||
position: 99,
|
||||
boardRank: taskBoardRankAtIndex([legacy, later], 1),
|
||||
});
|
||||
const todo = createMockTask({ id: 'todo', status: 'todo', position: -100 });
|
||||
const hidden = createMockTask({ id: 'hidden', status: 'retired', position: -200 });
|
||||
const tasks = [todo, later, ranked, legacy, hidden];
|
||||
const view = renderWithProvider({ tasks });
|
||||
for (const id of ['legacy', 'ranked', 'later', 'todo']) {
|
||||
fireEvent.keyDown(window, { key: 'j' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe(id);
|
||||
}
|
||||
fireEvent.keyDown(window, { key: 'ArrowUp' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe('later');
|
||||
const reordered = [
|
||||
todo,
|
||||
{ ...later, boardRank: taskBoardRankAtIndex([legacy, ranked], 0) },
|
||||
ranked,
|
||||
legacy,
|
||||
];
|
||||
view.rerender(
|
||||
<KeyboardProvider>
|
||||
<TestConsumer tasks={reordered} />
|
||||
</KeyboardProvider>
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe('later');
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe('legacy');
|
||||
view.rerender(
|
||||
<KeyboardProvider>
|
||||
<TestConsumer tasks={[ranked, todo]} />
|
||||
</KeyboardProvider>
|
||||
);
|
||||
expect(screen.getByTestId('selected').textContent).toBe('none');
|
||||
fireEvent.keyDown(window, { key: 'k' });
|
||||
expect(screen.getByTestId('selected').textContent).toBe('todo');
|
||||
});
|
||||
|
||||
it('throws when useKeyboard is used outside provider', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
|
|
|
|||
|
|
@ -375,7 +375,6 @@ export function KanbanBoard() {
|
|||
// Register filtered tasks with keyboard context
|
||||
useEffect(() => {
|
||||
setTasks(filteredTasks);
|
||||
return () => setTasks([]);
|
||||
}, [filteredTasks, setTasks]);
|
||||
|
||||
// Handler for opening a task
|
||||
|
|
@ -532,9 +531,17 @@ export function KanbanBoard() {
|
|||
[allTasksByStatus, announce, canWriteTasks, columns, commitBoardMove, filteredTasks, isOnline]
|
||||
);
|
||||
|
||||
// Register callbacks with keyboard context (refs, so no need for useEffect)
|
||||
setOnOpenTask(handleTaskClick);
|
||||
setOnMoveTask(handleMoveTask);
|
||||
// Board-owned callbacks must not outlive this view.
|
||||
useEffect(() => {
|
||||
setOnOpenTask(handleTaskClick);
|
||||
setOnMoveTask(handleMoveTask);
|
||||
return () => {
|
||||
setOnOpenTask(null);
|
||||
setOnMoveTask(null);
|
||||
};
|
||||
}, [handleTaskClick, handleMoveTask, setOnOpenTask, setOnMoveTask]);
|
||||
|
||||
useEffect(() => () => setTasks([]), [setTasks]);
|
||||
|
||||
// Drag and drop logic
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export function FloatingChat() {
|
|||
classNames={{ icon: 'floating-chat-icon' }}
|
||||
className={cn(
|
||||
'floating-chat-trigger z-40 h-14 w-14 rounded-full shadow-lg',
|
||||
'bg-primary hover:bg-primary/90 text-primary-foreground',
|
||||
'bg-primary-action hover:bg-primary-action-hover text-primary-foreground',
|
||||
'transition-colors duration-150',
|
||||
open && 'hidden'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -714,7 +714,10 @@ function SourceList({
|
|||
<div key={`${item.kind}:${item.id}`} className="min-w-0 text-sm">
|
||||
<div className="truncate">{item.label}</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Code className="truncate" color="dark">
|
||||
<Code
|
||||
className="truncate"
|
||||
style={{ color: 'var(--foreground)', backgroundColor: 'var(--muted)' }}
|
||||
>
|
||||
{item.id}
|
||||
</Code>
|
||||
<span className="shrink-0">{formatDateTime(item.timestamp)}</span>
|
||||
|
|
|
|||
|
|
@ -405,9 +405,10 @@ export function DriftMonitor({ onBack }: DriftMonitorProps) {
|
|||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm capitalize transition-colors',
|
||||
severity === level
|
||||
? 'bg-primary text-primary-foreground'
|
||||
? 'bg-primary-action text-primary-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
aria-pressed={severity === level}
|
||||
onClick={() => setSeverity(level)}
|
||||
>
|
||||
{level}
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ function SubmitTab() {
|
|||
className={[
|
||||
'rounded-full border px-3 py-1 text-sm transition-colors',
|
||||
selectedCategories.includes(cat)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
? 'border-primary bg-primary-action text-primary-foreground'
|
||||
: 'border-border bg-transparent hover:bg-muted',
|
||||
].join(' ')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ export function CommandPalette({
|
|||
cmd.disabledReason
|
||||
? 'cursor-not-allowed border border-dashed border-border/70 bg-muted/15 text-muted-foreground'
|
||||
: isSelected
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
? 'bg-primary-action text-white shadow-sm'
|
||||
: 'text-foreground hover:bg-muted/50'
|
||||
)}
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export function DesktopLeftSidebar() {
|
|||
className={cn(
|
||||
'desktop-no-drag flex min-h-9 items-center gap-2 rounded-md px-2 text-left text-sm transition-colors',
|
||||
active
|
||||
? 'bg-primary text-white shadow-sm hover:bg-primary/90'
|
||||
? 'bg-primary-action text-white shadow-sm hover:bg-primary-action-hover'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
!leftRailOpen && 'justify-center px-0'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -423,9 +423,9 @@ export function Header({
|
|||
}
|
||||
void (
|
||||
window as Window & {
|
||||
veritasDesktop?: { toggleWindowMaximize?: () => Promise<{ maximized: boolean }> };
|
||||
veritasDesktop?: { performTitlebarAction?: () => Promise<{ maximized: boolean }> };
|
||||
}
|
||||
).veritasDesktop?.toggleWindowMaximize?.();
|
||||
).veritasDesktop?.performTitlebarAction?.();
|
||||
},
|
||||
[isDesktopClient]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,34 +8,6 @@ interface Shortcut {
|
|||
description: string;
|
||||
}
|
||||
|
||||
const shortcuts: { category: string; items: Shortcut[] }[] = [
|
||||
{
|
||||
category: 'Navigation',
|
||||
items: [
|
||||
{ keys: ['j', '↓'], description: 'Select next task' },
|
||||
{ keys: ['k', '↑'], description: 'Select previous task' },
|
||||
{ keys: ['Enter'], description: 'Open selected task' },
|
||||
{ keys: ['Esc'], description: 'Close panel / Clear selection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Actions',
|
||||
items: [
|
||||
{ keys: ['c'], description: 'Create new task' },
|
||||
{ keys: ['⌘⇧C'], description: 'Open agent chat' },
|
||||
{ keys: ['1'], description: 'Move to To Do' },
|
||||
{ keys: ['2'], description: 'Move to Planning' },
|
||||
{ keys: ['3'], description: 'Move to In Progress' },
|
||||
{ keys: ['4'], description: 'Move to Blocked' },
|
||||
{ keys: ['5'], description: 'Move to Done' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
items: [{ keys: ['?'], description: 'Toggle this help' }],
|
||||
},
|
||||
];
|
||||
|
||||
function KeyBadge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Kbd className="inline-flex min-w-[24px] items-center justify-center px-2 text-xs font-medium">
|
||||
|
|
@ -45,7 +17,34 @@ function KeyBadge({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const { isHelpOpen, closeHelpDialog } = useKeyboard();
|
||||
const { isHelpOpen, closeHelpDialog, columns } = useKeyboard();
|
||||
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const shortcuts: { category: string; items: Shortcut[] }[] = [
|
||||
{
|
||||
category: 'Navigation',
|
||||
items: [
|
||||
{ keys: ['j', '↓'], description: 'Select next task' },
|
||||
{ keys: ['k', '↑'], description: 'Select previous task' },
|
||||
{ keys: ['Enter'], description: 'Open selected task' },
|
||||
{ keys: ['Esc'], description: 'Close panel / Clear selection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Actions',
|
||||
items: [
|
||||
{ keys: ['c'], description: 'Create new task' },
|
||||
{ keys: [isMac ? '⌘⇧C' : 'Ctrl+Shift+C'], description: 'Open agent chat' },
|
||||
...columns.slice(0, 9).map((column, index) => ({
|
||||
keys: [String(index + 1)],
|
||||
description: `Move to ${column.title}`,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
items: [{ keys: ['?'], description: 'Toggle this help' }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
|
@ -87,6 +86,12 @@ export function KeyboardShortcutsDialog() {
|
|||
</dl>
|
||||
</section>
|
||||
))}
|
||||
{columns.length > 9 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Number shortcuts cover the first nine columns. Use the card status control for other
|
||||
columns.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<OverlayFooter>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function PageFallback({ error, onRetry: _onRetry }: Omit<ErrorFallbackProps, 'le
|
|||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 text-sm font-medium rounded-md bg-primary-action text-primary-foreground hover:bg-primary-action-hover transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reload
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export function SkipToContent() {
|
|||
sr-only focus:not-sr-only
|
||||
focus:fixed focus:top-2 focus:left-2 focus:z-[100]
|
||||
focus:px-4 focus:py-2 focus:rounded-md
|
||||
focus:bg-primary focus:text-primary-foreground
|
||||
focus:bg-primary-action focus:text-primary-foreground
|
||||
focus:text-sm focus:font-medium
|
||||
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2
|
||||
focus:shadow-lg
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
import { memo, useMemo, useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Select, Tooltip } from '@mantine/core';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
|
@ -218,6 +218,21 @@ export const TaskCard = memo(function TaskCard({
|
|||
id: task.id,
|
||||
disabled: !dragEnabled,
|
||||
});
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const attachCard = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
cardRef.current = node;
|
||||
setNodeRef(node);
|
||||
},
|
||||
[setNodeRef]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
cardRef.current?.focus({ preventScroll: true });
|
||||
cardRef.current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
}, [isSelected]);
|
||||
|
||||
const { isSelecting, toggleSelect, isSelected: isBulkSelected } = useBulkActions();
|
||||
const [tooltipDismissed, setTooltipDismissed] = useState(false);
|
||||
const [statusMenuOpen, setStatusMenuOpen] = useState(false);
|
||||
|
|
@ -362,7 +377,7 @@ export const TaskCard = memo(function TaskCard({
|
|||
}
|
||||
>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
ref={attachCard}
|
||||
data-task-id={task.id}
|
||||
style={style}
|
||||
{...(dragEnabled ? listeners : {})}
|
||||
|
|
@ -372,7 +387,7 @@ export const TaskCard = memo(function TaskCard({
|
|||
onMouseLeave={() => setTooltipDismissed(false)}
|
||||
role="article"
|
||||
tabIndex={0}
|
||||
aria-label={`Task: ${task.title}, Type: ${typeLabel}, Priority: ${task.priority}${readinessAria}${isBlockedState ? ', Blocked' : ''}${isAgentRunning ? ', Agent running' : ''}${isAttemptFailed ? ', Latest attempt failed' : ''}${isAwaitingReview ? ', Awaiting review' : ''}${isVerified ? ', Verified' : ''}`}
|
||||
aria-label={`${isSelected ? 'Selected. ' : ''}Task: ${task.title}, Type: ${typeLabel}, Priority: ${task.priority}${readinessAria}${isBlockedState ? ', Blocked' : ''}${isAgentRunning ? ', Agent running' : ''}${isAttemptFailed ? ', Latest attempt failed' : ''}${isAwaitingReview ? ', Awaiting review' : ''}${isVerified ? ', Verified' : ''}`}
|
||||
data-type-color-token={typeColorToken}
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
data-dragging={isDragging || isCurrentlyDragging ? 'true' : undefined}
|
||||
|
|
@ -400,7 +415,7 @@ export const TaskCard = memo(function TaskCard({
|
|||
className={cn(
|
||||
'h-4 w-4 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-colors',
|
||||
isChecked
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
? 'bg-primary-action border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/50 hover:border-primary'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const badgeVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
default: 'bg-primary-action text-primary-foreground [a]:hover:bg-primary-action-hover',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
default: 'bg-primary-action text-primary-foreground [a]:hover:bg-primary-action-hover',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
|
|
|
|||
|
|
@ -720,6 +720,8 @@ html[data-client='desktop'] .desktop-board-with-right-rail {
|
|||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: #6541d5;
|
||||
--primary-action: #6541d5;
|
||||
--primary-action-hover: #5132b4;
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
|
|
@ -794,8 +796,10 @@ html[data-client='desktop'] .desktop-board-with-right-rail {
|
|||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
/* Keep Tailwind controls on the same bright Veritas shade as Mantine. */
|
||||
/* Preserve bright accent text; filled actions have their own contrast pair. */
|
||||
--primary: #8d68f8;
|
||||
--primary-action: #754fe8;
|
||||
--primary-action-hover: #6541d5;
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
|
@ -1334,6 +1338,8 @@ html[data-client='desktop'] .desktop-board-with-right-rail {
|
|||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-action: var(--primary-action);
|
||||
--color-primary-action-hover: var(--primary-action-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import {
|
||||
DEFAULT_FEATURE_SETTINGS,
|
||||
normalizeBoardColumns,
|
||||
sortTasksByBoardPosition,
|
||||
type Task,
|
||||
type TaskStatus,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
|
@ -33,13 +34,15 @@ interface KeyboardContextValue {
|
|||
selectedTaskId: string | null;
|
||||
setSelectedTaskId: (id: string | null) => void;
|
||||
|
||||
columns: ReturnType<typeof normalizeBoardColumns>;
|
||||
|
||||
// Task list for navigation
|
||||
tasks: Task[];
|
||||
setTasks: (tasks: Task[]) => void;
|
||||
|
||||
// Callbacks (using refs to avoid re-render loops)
|
||||
setOnOpenTask: (fn: (task: Task) => void) => void;
|
||||
setOnMoveTask: (fn: (taskId: string, status: TaskStatus) => void) => void;
|
||||
setOnOpenTask: (fn: ((task: Task) => void) | null) => void;
|
||||
setOnMoveTask: (fn: ((taskId: string, status: TaskStatus) => void) | null) => void;
|
||||
}
|
||||
|
||||
const KeyboardContext = createContext<KeyboardContextValue | null>(null);
|
||||
|
|
@ -52,7 +55,11 @@ function getColumnForShortcut(key: string, columns: Array<{ id: TaskStatus }>):
|
|||
export function KeyboardProvider({ children }: { children: ReactNode }) {
|
||||
const [isHelpOpen, setIsHelpOpen] = useState(false);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [tasks, updateTasks] = useState<Task[]>([]);
|
||||
const setTasks = useCallback((next: Task[]) => {
|
||||
updateTasks(next);
|
||||
setSelectedTaskId((id) => (id && next.some((task) => task.id === id) ? id : null));
|
||||
}, []);
|
||||
const { settings } = useFeatureSettings();
|
||||
const columns = useMemo(
|
||||
() => normalizeBoardColumns(settings.board?.columns ?? DEFAULT_FEATURE_SETTINGS.board.columns),
|
||||
|
|
@ -89,24 +96,23 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
setIsHelpOpen(false);
|
||||
}, []);
|
||||
|
||||
const setOnOpenTask = useCallback((fn: (task: Task) => void) => {
|
||||
const setOnOpenTask = useCallback((fn: ((task: Task) => void) | null) => {
|
||||
onOpenTaskRef.current = fn;
|
||||
}, []);
|
||||
|
||||
const setOnMoveTask = useCallback((fn: (taskId: string, status: TaskStatus) => void) => {
|
||||
const setOnMoveTask = useCallback((fn: ((taskId: string, status: TaskStatus) => void) | null) => {
|
||||
onMoveTaskRef.current = fn;
|
||||
}, []);
|
||||
|
||||
// Get flat list of tasks sorted by column then position
|
||||
const getTaskList = useCallback(() => {
|
||||
const statusOrder = columns.map((column) => column.id);
|
||||
return [...tasks].sort((a, b) => {
|
||||
const aIndex = statusOrder.indexOf(a.status);
|
||||
const bIndex = statusOrder.indexOf(b.status);
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [columns, tasks]);
|
||||
// Match the rendered column order and the board's canonical rank/position order.
|
||||
// Compute once per snapshot, rather than sorting during each keystroke.
|
||||
const taskList = useMemo(
|
||||
() =>
|
||||
columns.flatMap((column) =>
|
||||
sortTasksByBoardPosition(tasks.filter((task) => task.status === column.id))
|
||||
),
|
||||
[columns, tasks]
|
||||
);
|
||||
|
||||
// Keyboard event handler
|
||||
useEffect(() => {
|
||||
|
|
@ -141,7 +147,6 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
return;
|
||||
}
|
||||
|
||||
const taskList = getTaskList();
|
||||
const currentIndex = selectedTaskId ? taskList.findIndex((t) => t.id === selectedTaskId) : -1;
|
||||
|
||||
// Cmd+Shift+C (or Ctrl+Shift+C on Windows/Linux) - Toggle chat panel
|
||||
|
|
@ -237,7 +242,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [getTaskList, selectedTaskId, isHelpOpen, openCreateDialog, openChatPanel, columns]);
|
||||
}, [taskList, selectedTaskId, isHelpOpen, openCreateDialog, openChatPanel, columns]);
|
||||
|
||||
const value = useMemo<KeyboardContextValue>(
|
||||
() => ({
|
||||
|
|
@ -250,6 +255,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
isHelpOpen,
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
columns,
|
||||
tasks,
|
||||
setTasks,
|
||||
setOnOpenTask,
|
||||
|
|
@ -265,6 +271,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
isHelpOpen,
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
columns,
|
||||
tasks,
|
||||
setTasks,
|
||||
setOnOpenTask,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createTheme, type MantineColorsTuple } from '@mantine/core';
|
||||
import { createTheme, defaultVariantColorsResolver, type MantineColorsTuple } from '@mantine/core';
|
||||
import { VERITAS_UI_METRICS } from './ui-contract';
|
||||
|
||||
export const veritasPrimary: MantineColorsTuple = [
|
||||
|
|
@ -27,6 +27,19 @@ export const veritasStatusColors = {
|
|||
|
||||
export const veritasMantineTheme = createTheme({
|
||||
primaryColor: 'veritas',
|
||||
// Filled surfaces need a darker swatch than accent text on dark backgrounds.
|
||||
variantColorResolver: (input) => {
|
||||
const resolved = defaultVariantColorsResolver(input);
|
||||
if (input.variant === 'filled' && (input.color ?? input.theme.primaryColor) === 'veritas') {
|
||||
return {
|
||||
...resolved,
|
||||
background: 'var(--primary-action)',
|
||||
hover: 'var(--primary-action-hover)',
|
||||
color: '#ffffff',
|
||||
};
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
primaryShade: { light: 6, dark: 4 },
|
||||
colors: {
|
||||
veritas: veritasPrimary,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue