Merge remote-tracking branch 'origin/main' into fix/browser-onboarding-1535

# Conflicts:
#	docs/FEATURES.md
This commit is contained in:
bradgroux 2026-09-07 19:01:33 -05:00
commit 54cb28141d
60 changed files with 1489 additions and 211 deletions

View file

@ -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.

View 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' });
});
});
});

View file

@ -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');
});
});

View 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();
});
});

View file

@ -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();
});
});

View file

@ -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 },
};
}

View file

@ -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) => {

View file

@ -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: [

View 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() };
}

View file

@ -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 {

View file

@ -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),

View file

@ -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 {

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.

View 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;
}

View file

@ -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 };
}

View file

@ -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) {

View file

@ -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 = [

View 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);
});
});

View file

@ -383,7 +383,7 @@ describe('admin-only governance routes', () => {
type: 'feature',
priority: 'medium',
project: undefined,
subtasks: undefined,
subtaskCount: undefined,
},
{ requiredRuntimeCapabilities: undefined }
);

View file

@ -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 });
});
}
}
});

View 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');
}
});
}
}
});

View file

@ -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 () => {

View file

@ -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,
});
});
}
}
});

View file

@ -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');
});
});

View 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' });
});
});
}

View file

@ -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) => {

View file

@ -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 }
);

View file

@ -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);
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View 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');
}
}

View file

@ -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 };
}

View file

@ -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();

View file

@ -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);

View 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();
});
});

View file

@ -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');

View file

@ -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)) {

View file

@ -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(() => {});

View file

@ -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 {

View file

@ -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'
)}

View file

@ -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>

View file

@ -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}

View file

@ -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(' ')}
>

View file

@ -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={

View file

@ -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'
)}

View file

@ -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]
);

View file

@ -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">

View file

@ -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

View file

@ -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

View file

@ -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'
)}
>

View file

@ -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',

View file

@ -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:

View file

@ -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);

View file

@ -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,

View file

@ -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,