From 256eb6a9fbae58279a1c3b9829475e6e44500c8e Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 12:35:22 -0500 Subject: [PATCH 01/14] fix: align route permission matching with router semantics --- .../src/__tests__/routes/codex-review.test.ts | 38 +++++++++++++ .../routes/work-product-artifacts.test.ts | 55 ++++++++++++++++++- .../__tests__/shared-api-permissions.test.ts | 14 +++++ server/src/middleware/auth.ts | 10 +++- shared/src/utils/api-permissions.ts | 11 ++-- 5 files changed, 121 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/routes/codex-review.test.ts b/server/src/__tests__/routes/codex-review.test.ts index d901d3de..50521053 100644 --- a/server/src/__tests__/routes/codex-review.test.ts +++ b/server/src/__tests__/routes/codex-review.test.ts @@ -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 }); + }); + } + } +}); diff --git a/server/src/__tests__/routes/work-product-artifacts.test.ts b/server/src/__tests__/routes/work-product-artifacts.test.ts index c39d3b10..7bc9c5c6 100644 --- a/server/src/__tests__/routes/work-product-artifacts.test.ts +++ b/server/src/__tests__/routes/work-product-artifacts.test.ts @@ -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, + }); + }); + } + } +}); diff --git a/server/src/__tests__/shared-api-permissions.test.ts b/server/src/__tests__/shared-api-permissions.test.ts index 5fbdf2bd..565c5ec3 100644 --- a/server/src/__tests__/shared-api-permissions.test.ts +++ b/server/src/__tests__/shared-api-permissions.test.ts @@ -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'); + }); +}); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index bbcadecd..ab45df8b 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -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) => { diff --git a/shared/src/utils/api-permissions.ts b/shared/src/utils/api-permissions.ts index 9fc16bda..d6b96a52 100644 --- a/shared/src/utils/api-permissions.ts +++ b/shared/src/utils/api-permissions.ts @@ -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 }; } From c97feea765aa54ac8b16c5d7235e3e085f265523 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 12:40:47 -0500 Subject: [PATCH 02/14] fix: preserve managed attempt ownership during task edits --- docs/API-REFERENCE.md | 2 + .../__tests__/routes/tasks-coverage.test.ts | 37 ++++-- .../src/__tests__/task-attempt-edit.test.ts | 113 ++++++++++++++++++ server/src/routes/tasks.ts | 29 ++--- server/src/services/task-service.ts | 22 +++- server/src/utils/task-attempt-edit.ts | 24 ++++ 6 files changed, 189 insertions(+), 38 deletions(-) create mode 100644 server/src/__tests__/task-attempt-edit.test.ts create mode 100644 server/src/utils/task-attempt-edit.ts diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d33fc191..d0b0d044 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -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 diff --git a/server/src/__tests__/routes/tasks-coverage.test.ts b/server/src/__tests__/routes/tasks-coverage.test.ts index 7078847f..6fdf5445 100644 --- a/server/src/__tests__/routes/tasks-coverage.test.ts +++ b/server/src/__tests__/routes/tasks-coverage.test.ts @@ -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 () => { diff --git a/server/src/__tests__/task-attempt-edit.test.ts b/server/src/__tests__/task-attempt-edit.test.ts new file mode 100644 index 00000000..713f097c --- /dev/null +++ b/server/src/__tests__/task-attempt-edit.test.ts @@ -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' }); + }); + }); +} diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 26439576..9bf6b4df 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -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'); } diff --git a/server/src/services/task-service.ts b/server/src/services/task-service.ts index 9696143b..85d4ebc5 100644 --- a/server/src/services/task-service.ts +++ b/server/src/services/task-service.ts @@ -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 & Partial>; lastBoardMove?: TaskBoardMoveReceipt; boardRank?: string | null; @@ -781,10 +783,17 @@ export class TaskService { return task; } - async updateTask(id: string, input: UpdateTaskInput): Promise { + async updateTask( + id: string, + input: UpdateTaskInput, + options: { protectManagedAttempt?: boolean } = {} + ): Promise { 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; diff --git a/server/src/utils/task-attempt-edit.ts b/server/src/utils/task-attempt-edit.ts new file mode 100644 index 00000000..942617ff --- /dev/null +++ b/server/src/utils/task-attempt-edit.ts @@ -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'); + } +} From b733d70ac8bee8960511eef0bbcb729d5a9db473 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 12:45:28 -0500 Subject: [PATCH 03/14] fix: bound metadata preview counts without placeholder allocation --- docs/API-REFERENCE.md | 4 +- .../__tests__/agent-routing-service.test.ts | 33 ++++++- .../__tests__/cost-prediction-count.test.ts | 23 +++++ .../routes/admin-governance-auth.test.ts | 2 +- .../routes/metadata-preview-bounds.test.ts | 90 +++++++++++++++++++ server/src/routes/agent-routing.ts | 11 +-- server/src/routes/cost-prediction.ts | 4 +- server/src/services/agent-routing-service.ts | 15 ++-- .../src/services/cost-prediction-service.ts | 3 +- 9 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 server/src/__tests__/cost-prediction-count.test.ts create mode 100644 server/src/__tests__/routes/metadata-preview-bounds.test.ts diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d33fc191..bf4d0a7b 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -2405,7 +2405,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 +3900,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 diff --git a/server/src/__tests__/agent-routing-service.test.ts b/server/src/__tests__/agent-routing-service.test.ts index 956eb6e7..d4add7c4 100644 --- a/server/src/__tests__/agent-routing-service.test.ts +++ b/server/src/__tests__/agent-routing-service.test.ts @@ -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 = [ diff --git a/server/src/__tests__/cost-prediction-count.test.ts b/server/src/__tests__/cost-prediction-count.test.ts new file mode 100644 index 00000000..18579324 --- /dev/null +++ b/server/src/__tests__/cost-prediction-count.test.ts @@ -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); + }); +}); diff --git a/server/src/__tests__/routes/admin-governance-auth.test.ts b/server/src/__tests__/routes/admin-governance-auth.test.ts index e2188920..9eb5b463 100644 --- a/server/src/__tests__/routes/admin-governance-auth.test.ts +++ b/server/src/__tests__/routes/admin-governance-auth.test.ts @@ -383,7 +383,7 @@ describe('admin-only governance routes', () => { type: 'feature', priority: 'medium', project: undefined, - subtasks: undefined, + subtaskCount: undefined, }, { requiredRuntimeCapabilities: undefined } ); diff --git a/server/src/__tests__/routes/metadata-preview-bounds.test.ts b/server/src/__tests__/routes/metadata-preview-bounds.test.ts new file mode 100644 index 00000000..68e87ac5 --- /dev/null +++ b/server/src/__tests__/routes/metadata-preview-bounds.test.ts @@ -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'); + } + }); + } + } +}); diff --git a/server/src/routes/agent-routing.ts b/server/src/routes/agent-routing.ts index 87db9e9e..71ee5350 100644 --- a/server/src/routes/agent-routing.ts +++ b/server/src/routes/agent-routing.ts @@ -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 } ); diff --git a/server/src/routes/cost-prediction.ts b/server/src/routes/cost-prediction.ts index b22dc480..8b494ada 100644 --- a/server/src/routes/cost-prediction.ts +++ b/server/src/routes/cost-prediction.ts @@ -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); } diff --git a/server/src/services/agent-routing-service.ts b/server/src/services/agent-routing-service.ts index b3ea64e3..0b112613 100644 --- a/server/src/services/agent-routing-service.ts +++ b/server/src/services/agent-routing-service.ts @@ -41,7 +41,9 @@ import { selectProviderRuntimeManifest } from './provider-runtime-capability-ser const log = createLogger('agent-routing'); -type RoutableTask = Pick; +type RoutableTask = Pick & { + 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: RoutableTask, failedAgent: AgentType, context: FallbackRoutingContext = {} ): Promise { @@ -636,10 +638,7 @@ export class AgentRoutingService { * All specified criteria must match (AND logic). * Unspecified criteria are ignored (wildcard). */ - private matchesRule( - task: Pick, - 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; } diff --git a/server/src/services/cost-prediction-service.ts b/server/src/services/cost-prediction-service.ts index dfda471a..11bf9545 100644 --- a/server/src/services/cost-prediction-service.ts +++ b/server/src/services/cost-prediction-service.ts @@ -123,6 +123,7 @@ class CostPredictionService { project?: string; description?: string; subtasks?: Array; + subtaskCount?: number; }): Promise { // 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; From 3892b70335e307396c843e28b0bf5498010ab07e Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:16:41 -0500 Subject: [PATCH 04/14] fix: enforce desktop connection transport policy --- .../__tests__/connection-redirects.test.ts | 133 ++++++++++++++++++ desktop/src/main/bridge.ts | 2 + 2 files changed, 135 insertions(+) create mode 100644 desktop/src/main/__tests__/connection-redirects.test.ts diff --git a/desktop/src/main/__tests__/connection-redirects.test.ts b/desktop/src/main/__tests__/connection-redirects.test.ts new file mode 100644 index 00000000..af2f00b5 --- /dev/null +++ b/desktop/src/main/__tests__/connection-redirects.test.ts @@ -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, + 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((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((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' }); + }); + }); +}); diff --git a/desktop/src/main/bridge.ts b/desktop/src/main/bridge.ts index c259ba43..77b1b145 100644 --- a/desktop/src/main/bridge.ts +++ b/desktop/src/main/bridge.ts @@ -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, From e8a1de51f26405a64fc9d713bf5c28b34de762eb Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:19:37 -0500 Subject: [PATCH 05/14] fix: restore standard macOS menus and window lifecycle --- desktop/src/main/__tests__/menu.test.ts | 38 ++++++++++++ desktop/src/main/index.ts | 26 ++++++-- desktop/src/main/menu.ts | 62 +++++++++++++------ docs/DESKTOP-RELEASE.md | 5 ++ scripts/native-ui/menu-commands.mjs | 82 +++++++++++++++++++++++++ scripts/native-ui/run.mjs | 5 +- 6 files changed, 193 insertions(+), 25 deletions(-) diff --git a/desktop/src/main/__tests__/menu.test.ts b/desktop/src/main/__tests__/menu.test.ts index 7033b281..b1fed2ca 100644 --- a/desktop/src/main/__tests__/menu.test.ts +++ b/desktop/src/main/__tests__/menu.test.ts @@ -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'); + }); +}); diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index c5cd7157..f570a8bf 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -402,13 +402,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) => { diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index c45037b5..2641f99f 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -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: [ diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index 7f2fe4b6..87cac51c 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -317,3 +317,8 @@ 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. diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 65d8b6f5..393dd8fd 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -61,3 +61,85 @@ 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 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' + ); + return { page: reopened, roles: items }; +} diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index b34d9f34..4b7c48ce 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -6,7 +6,7 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect } from '@playwright/test'; import { createNativeSession } from './session.mjs'; -import { verifyNativeMenuCommands } from './menu-commands.mjs'; +import { verifyNativeMenuCommands, verifyNativeWindowMenu } from './menu-commands.mjs'; import { fileDigest, evidenceFailures, @@ -654,6 +654,9 @@ async function checkSeededRendererFailures() { try { await launch(); report.menuCommands = await verifyNativeMenuCommands(app, page); + const windowMenu = await verifyNativeWindowMenu(app, page); + page = windowMenu.page; + report.menuRoles = windowMenu.roles; await persist(); for (const mode of modes) { for (const state of states) { From 753363bd95538522864b163a4e4b299b0ba7f486 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:21:14 -0500 Subject: [PATCH 06/14] fix: restore normal window bounds within available displays --- .../src/main/__tests__/window-state.test.ts | 52 ++++++++++++++++++- desktop/src/main/index.ts | 22 ++++++-- desktop/src/main/window-state.ts | 49 +++++++++++++++-- docs/DESKTOP-RELEASE.md | 4 ++ scripts/native-ui/menu-commands.mjs | 18 ++++++- 5 files changed, 136 insertions(+), 9 deletions(-) diff --git a/desktop/src/main/__tests__/window-state.test.ts b/desktop/src/main/__tests__/window-state.test.ts index 53f28b25..160cc09b 100644 --- a/desktop/src/main/__tests__/window-state.test.ts +++ b/desktop/src/main/__tests__/window-state.test.ts @@ -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(); + }); +}); diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index f570a8bf..100429e4 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -1,4 +1,13 @@ -import { app, BrowserWindow, clipboard, ipcMain, Notification, safeStorage, shell } from 'electron'; +import { + app, + BrowserWindow, + clipboard, + ipcMain, + Notification, + safeStorage, + shell, + screen, +} from 'electron'; import path from 'node:path'; import { mkdirSync } from 'node:fs'; import { createRequire } from 'node:module'; @@ -97,12 +106,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, diff --git a/desktop/src/main/window-state.ts b/desktop/src/main/window-state.ts index 0a01c670..4f811db6 100644 --- a/desktop/src/main/window-state.ts +++ b/desktop/src/main/window-state.ts @@ -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 { 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 { diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index 87cac51c..f66ee1b2 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -322,3 +322,7 @@ 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. diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 393dd8fd..75886e77 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -126,6 +126,15 @@ export async function verifyNativeWindowMenu(app, page) { 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'); @@ -141,5 +150,12 @@ export async function verifyNativeWindowMenu(app, page) { before.server.pid, 'Closing the window restarted the managed server' ); - return { page: reopened, roles: items }; + 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 }; } From 0249194e33f96efc0d0c703150634e92ac092e3b Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:26:36 -0500 Subject: [PATCH 07/14] fix: honor the macOS titlebar double-click preference --- .../main/__tests__/titlebar-action.test.ts | 40 +++++++++++++++++++ desktop/src/main/bridge.ts | 6 +-- desktop/src/main/index.ts | 24 +++++------ desktop/src/main/titlebar-action.ts | 33 +++++++++++++++ desktop/src/preload/index.ts | 12 +++--- .../src/shared/desktop-bridge-contracts.ts | 12 +++--- docs/DESKTOP-RELEASE.md | 5 +++ scripts/native-ui/menu-commands.mjs | 30 ++++++++++++++ scripts/native-ui/run.mjs | 7 +++- web/src/__tests__/KanbanBoard.test.tsx | 2 +- .../__tests__/layout-chrome-mantine.test.tsx | 22 ++++++++-- web/src/components/layout/Header.tsx | 4 +- 12 files changed, 163 insertions(+), 34 deletions(-) create mode 100644 desktop/src/main/__tests__/titlebar-action.test.ts create mode 100644 desktop/src/main/titlebar-action.ts diff --git a/desktop/src/main/__tests__/titlebar-action.test.ts b/desktop/src/main/__tests__/titlebar-action.test.ts new file mode 100644 index 00000000..96c38382 --- /dev/null +++ b/desktop/src/main/__tests__/titlebar-action.test.ts @@ -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(); + }); +}); diff --git a/desktop/src/main/bridge.ts b/desktop/src/main/bridge.ts index c259ba43..9d1260ce 100644 --- a/desktop/src/main/bridge.ts +++ b/desktop/src/main/bridge.ts @@ -26,7 +26,7 @@ import { type DesktopBridgeResponse, type DesktopConnectionConfigRequest, type DesktopConnectionValidationResult, - type DesktopWindowToggleMaximizeResult, + type DesktopWindowTitlebarActionResult, } from '../shared/desktop-bridge-contracts.js'; type MaybePromise = T | Promise; @@ -38,7 +38,7 @@ export type DesktopBridgeHandlerMap = { }; export interface DesktopWindowControls { - toggleMaximize(): DesktopWindowToggleMaximizeResult; + performTitlebarAction(): DesktopWindowTitlebarActionResult; } async function remoteConnectionDestinationError(serverUrl: string): Promise { @@ -289,7 +289,7 @@ export function createDesktopBridgeHandlers( await shell.openExternal(url); return undefined; }, - toggleWindowMaximize: () => windowControls?.toggleMaximize() ?? { maximized: false }, + performTitlebarAction: () => windowControls?.performTitlebarAction() ?? { maximized: false }, }; } diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 100429e4..1ec41a32 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -7,6 +7,7 @@ import { safeStorage, shell, screen, + systemPreferences, } from 'electron'; import path from 'node:path'; import { mkdirSync } from 'node:fs'; @@ -15,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'; @@ -347,18 +349,16 @@ async function boot(): Promise { 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(); diff --git a/desktop/src/main/titlebar-action.ts b/desktop/src/main/titlebar-action.ts new file mode 100644 index 00000000..a5bf8adb --- /dev/null +++ b/desktop/src/main/titlebar-action.ts @@ -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() }; +} diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index 24598b81..005d54a9 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -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; openExternal(url: string): Promise; - toggleWindowMaximize(): Promise; + performTitlebarAction(): Promise; 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(DESKTOP_BRIDGE_METHODS.openExternal.channel, { url }), - toggleWindowMaximize: () => - invokeDesktop( - DESKTOP_BRIDGE_METHODS.toggleWindowMaximize.channel + performTitlebarAction: () => + invokeDesktop( + DESKTOP_BRIDGE_METHODS.performTitlebarAction.channel ), onSetupProgress: (listener) => onDesktopEvent('setupProgress', listener), onCommunicationCheck: (listener) => onDesktopEvent('communicationCheck', listener), diff --git a/desktop/src/shared/desktop-bridge-contracts.ts b/desktop/src/shared/desktop-bridge-contracts.ts index abf19577..dbfb02aa 100644 --- a/desktop/src/shared/desktop-bridge-contracts.ts +++ b/desktop/src/shared/desktop-bridge-contracts.ts @@ -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 { diff --git a/docs/DESKTOP-RELEASE.md b/docs/DESKTOP-RELEASE.md index f66ee1b2..906d7492 100644 --- a/docs/DESKTOP-RELEASE.md +++ b/docs/DESKTOP-RELEASE.md @@ -326,3 +326,8 @@ 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. diff --git a/scripts/native-ui/menu-commands.mjs b/scripts/native-ui/menu-commands.mjs index 75886e77..59fd1059 100644 --- a/scripts/native-ui/menu-commands.mjs +++ b/scripts/native-ui/menu-commands.mjs @@ -159,3 +159,33 @@ export async function verifyNativeWindowMenu(app, page) { .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 }; +} diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index 4b7c48ce..57603fb0 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -6,7 +6,11 @@ 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, verifyNativeWindowMenu } from './menu-commands.mjs'; +import { + verifyNativeMenuCommands, + verifyNativeWindowMenu, + verifyConfiguredTitlebarAction, +} from './menu-commands.mjs'; import { fileDigest, evidenceFailures, @@ -653,6 +657,7 @@ 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; diff --git a/web/src/__tests__/KanbanBoard.test.tsx b/web/src/__tests__/KanbanBoard.test.tsx index d118db93..6be76738 100644 --- a/web/src/__tests__/KanbanBoard.test.tsx +++ b/web/src/__tests__/KanbanBoard.test.tsx @@ -267,7 +267,7 @@ function renderDesktopBoard() { Object.defineProperty(window, 'veritasDesktop', { configurable: true, value: { - toggleWindowMaximize: vi.fn(), + performTitlebarAction: vi.fn(), }, }); window.localStorage.setItem('veritas.desktop.rightRailOpen', 'false'); diff --git a/web/src/__tests__/layout-chrome-mantine.test.tsx b/web/src/__tests__/layout-chrome-mantine.test.tsx index 0969f987..6ba61fcd 100644 --- a/web/src/__tests__/layout-chrome-mantine.test.tsx +++ b/web/src/__tests__/layout-chrome-mantine.test.tsx @@ -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 } } + ).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'); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 2c34fb00..9f3dfc85 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -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] ); From f91a8974381ef307cb8d46e8c7ce25b280621947 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:32:11 -0500 Subject: [PATCH 08/14] fix: separate filled action colors from dark-mode accent text --- docs/design/BOARD-COLOR-SYSTEM.md | 10 +++ scripts/native-ui/contrast.mjs | 72 +++++++++++++++++++ scripts/native-ui/run.mjs | 13 ++++ web/src/__tests__/ui-vocabulary.test.tsx | 13 ++++ web/src/components/chat/FloatingChat.tsx | 2 +- .../digest/OperationsDigestPage.tsx | 5 +- web/src/components/drift/DriftMonitor.tsx | 3 +- web/src/components/feedback/FeedbackPanel.tsx | 2 +- web/src/components/layout/CommandPalette.tsx | 2 +- .../components/layout/DesktopLeftSidebar.tsx | 2 +- web/src/components/shared/ErrorFallback.tsx | 2 +- web/src/components/shared/SkipToContent.tsx | 2 +- web/src/components/task/TaskCard.tsx | 2 +- web/src/components/ui/badge.tsx | 2 +- web/src/components/ui/button.tsx | 2 +- web/src/globals.css | 8 ++- web/src/theme/mantine-theme.ts | 15 +++- 17 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 scripts/native-ui/contrast.mjs diff --git a/docs/design/BOARD-COLOR-SYSTEM.md b/docs/design/BOARD-COLOR-SYSTEM.md index 95ea54e2..815f7144 100644 --- a/docs/design/BOARD-COLOR-SYSTEM.md +++ b/docs/design/BOARD-COLOR-SYSTEM.md @@ -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. diff --git a/scripts/native-ui/contrast.mjs b/scripts/native-ui/contrast.mjs new file mode 100644 index 00000000..22d2089a --- /dev/null +++ b/scripts/native-ui/contrast.mjs @@ -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; +} diff --git a/scripts/native-ui/run.mjs b/scripts/native-ui/run.mjs index 57603fb0..56de0394 100644 --- a/scripts/native-ui/run.mjs +++ b/scripts/native-ui/run.mjs @@ -6,6 +6,7 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { expect } from '@playwright/test'; import { createNativeSession } from './session.mjs'; +import { verifyRouteContrast } from './contrast.mjs'; import { verifyNativeMenuCommands, verifyNativeWindowMenu, @@ -214,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( @@ -662,6 +666,15 @@ try { 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) { diff --git a/web/src/__tests__/ui-vocabulary.test.tsx b/web/src/__tests__/ui-vocabulary.test.tsx index 7157247e..866e5144 100644 --- a/web/src/__tests__/ui-vocabulary.test.tsx +++ b/web/src/__tests__/ui-vocabulary.test.tsx @@ -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)) { diff --git a/web/src/components/chat/FloatingChat.tsx b/web/src/components/chat/FloatingChat.tsx index 863900ac..8f2fccf9 100644 --- a/web/src/components/chat/FloatingChat.tsx +++ b/web/src/components/chat/FloatingChat.tsx @@ -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' )} diff --git a/web/src/components/digest/OperationsDigestPage.tsx b/web/src/components/digest/OperationsDigestPage.tsx index f73c5b1e..0cefaee6 100644 --- a/web/src/components/digest/OperationsDigestPage.tsx +++ b/web/src/components/digest/OperationsDigestPage.tsx @@ -714,7 +714,10 @@ function SourceList({
{item.label}
- + {item.id} {formatDateTime(item.timestamp)} diff --git a/web/src/components/drift/DriftMonitor.tsx b/web/src/components/drift/DriftMonitor.tsx index 7a4ef569..d00770cf 100644 --- a/web/src/components/drift/DriftMonitor.tsx +++ b/web/src/components/drift/DriftMonitor.tsx @@ -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} diff --git a/web/src/components/feedback/FeedbackPanel.tsx b/web/src/components/feedback/FeedbackPanel.tsx index e273afee..5f2165c8 100644 --- a/web/src/components/feedback/FeedbackPanel.tsx +++ b/web/src/components/feedback/FeedbackPanel.tsx @@ -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(' ')} > diff --git a/web/src/components/layout/CommandPalette.tsx b/web/src/components/layout/CommandPalette.tsx index dae9bff1..e3b51669 100644 --- a/web/src/components/layout/CommandPalette.tsx +++ b/web/src/components/layout/CommandPalette.tsx @@ -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={ diff --git a/web/src/components/layout/DesktopLeftSidebar.tsx b/web/src/components/layout/DesktopLeftSidebar.tsx index f7b21d59..d8de8b58 100644 --- a/web/src/components/layout/DesktopLeftSidebar.tsx +++ b/web/src/components/layout/DesktopLeftSidebar.tsx @@ -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' )} diff --git a/web/src/components/shared/ErrorFallback.tsx b/web/src/components/shared/ErrorFallback.tsx index 84e21e5b..5d0fbca5 100644 --- a/web/src/components/shared/ErrorFallback.tsx +++ b/web/src/components/shared/ErrorFallback.tsx @@ -57,7 +57,7 @@ function PageFallback({ error, onRetry: _onRetry }: Omit 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" > Reload diff --git a/web/src/components/shared/SkipToContent.tsx b/web/src/components/shared/SkipToContent.tsx index c75f40c5..1eb15033 100644 --- a/web/src/components/shared/SkipToContent.tsx +++ b/web/src/components/shared/SkipToContent.tsx @@ -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 diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index 668516fd..556485b0 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -400,7 +400,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' )} > diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 6d5f7cb5..dced9743 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -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', diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index f1f59b65..833c8b0f 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -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: diff --git a/web/src/globals.css b/web/src/globals.css index e5564d33..77a8e923 100644 --- a/web/src/globals.css +++ b/web/src/globals.css @@ -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); diff --git a/web/src/theme/mantine-theme.ts b/web/src/theme/mantine-theme.ts index 267a62fb..f54abcbe 100644 --- a/web/src/theme/mantine-theme.ts +++ b/web/src/theme/mantine-theme.ts @@ -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, From 3d2e693b2b6b5c19944e46e04f91fb06e7574dce Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:47:48 -0500 Subject: [PATCH 09/14] fix: navigate tasks in visible board order --- docs/FEATURES.md | 2 +- web/src/__tests__/KanbanBoard.test.tsx | 24 ++++++++---- web/src/__tests__/TaskCard.test.tsx | 13 ++++++ web/src/__tests__/useKeyboard.test.tsx | 50 +++++++++++++++++++++++- web/src/components/board/KanbanBoard.tsx | 15 +++++-- web/src/components/task/TaskCard.tsx | 21 ++++++++-- web/src/hooks/useKeyboard.tsx | 37 ++++++++++-------- 7 files changed, 129 insertions(+), 33 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 89a67b45..16149865 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -122,7 +122,7 @@ The Kanban board is the central interface — a drag-and-drop workspace that ref - **Mobile shell controls** — Compact navigation uses bounded labels and full accessible names; Board Chat stays fixed above the bottom navigation and device safe area - **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 -- **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 diff --git a/web/src/__tests__/KanbanBoard.test.tsx b/web/src/__tests__/KanbanBoard.test.tsx index d118db93..a69901bf 100644 --- a/web/src/__tests__/KanbanBoard.test.tsx +++ b/web/src/__tests__/KanbanBoard.test.tsx @@ -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, @@ -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(); diff --git a/web/src/__tests__/TaskCard.test.tsx b/web/src/__tests__/TaskCard.test.tsx index 2960bc7d..de114397 100644 --- a/web/src/__tests__/TaskCard.test.tsx +++ b/web/src/__tests__/TaskCard.test.tsx @@ -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); diff --git a/web/src/__tests__/useKeyboard.test.tsx b/web/src/__tests__/useKeyboard.test.tsx index c542477a..05874753 100644 --- a/web/src/__tests__/useKeyboard.test.tsx +++ b/web/src/__tests__/useKeyboard.test.tsx @@ -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( + + + + ); + expect(screen.getByTestId('selected').textContent).toBe('later'); + fireEvent.keyDown(window, { key: 'ArrowDown' }); + expect(screen.getByTestId('selected').textContent).toBe('legacy'); + view.rerender( + + + + ); + 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(() => {}); diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index bfd847b8..927a75a2 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -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 { diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index 668516fd..96ff520f 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -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(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({ } >
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} diff --git a/web/src/hooks/useKeyboard.tsx b/web/src/hooks/useKeyboard.tsx index abd842ee..c29f4619 100644 --- a/web/src/hooks/useKeyboard.tsx +++ b/web/src/hooks/useKeyboard.tsx @@ -11,6 +11,7 @@ import { import { DEFAULT_FEATURE_SETTINGS, normalizeBoardColumns, + sortTasksByBoardPosition, type Task, type TaskStatus, } from '@veritas-kanban/shared'; @@ -38,8 +39,8 @@ interface KeyboardContextValue { 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(null); @@ -52,7 +53,11 @@ function getColumnForShortcut(key: string, columns: Array<{ id: TaskStatus }>): export function KeyboardProvider({ children }: { children: ReactNode }) { const [isHelpOpen, setIsHelpOpen] = useState(false); const [selectedTaskId, setSelectedTaskId] = useState(null); - const [tasks, setTasks] = useState([]); + const [tasks, updateTasks] = useState([]); + 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 +94,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 +145,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 +240,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( () => ({ From c24c6a9e63361e4fdbc21dc46f2180547c45fe3c Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 18:51:49 -0500 Subject: [PATCH 10/14] chore: run checks against the main branch target From 63920799cb520f1d5e84c4fc17c903ffd01b8825 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 18:51:53 -0500 Subject: [PATCH 11/14] chore: run checks against the main branch target From d6cd74acf2b1669677b4c51f77335780502392aa Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 18:51:56 -0500 Subject: [PATCH 12/14] chore: run checks against the main branch target From 23ff349765a203ca8464391ff217a0c246238329 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 13:49:50 -0500 Subject: [PATCH 13/14] fix: derive keyboard help from configured board columns --- .../__tests__/keyboard-shortcut-help.test.tsx | 80 +++++++++++++++++++ .../layout/KeyboardShortcutsDialog.tsx | 63 ++++++++------- web/src/hooks/useKeyboard.tsx | 4 + 3 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 web/src/__tests__/keyboard-shortcut-help.test.tsx diff --git a/web/src/__tests__/keyboard-shortcut-help.test.tsx b/web/src/__tests__/keyboard-shortcut-help.test.tsx new file mode 100644 index 00000000..3e154a93 --- /dev/null +++ b/web/src/__tests__/keyboard-shortcut-help.test.tsx @@ -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 ( + + + + ); +} +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(); + 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(); + 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(); + 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(); + fireEvent.keyDown(window, { key: '?' }); + await screen.findByRole('dialog'); + expect(screen.getByText(label)).toBeDefined(); + }); +}); diff --git a/web/src/components/layout/KeyboardShortcutsDialog.tsx b/web/src/components/layout/KeyboardShortcutsDialog.tsx index 68dcacf8..d6d9f145 100644 --- a/web/src/components/layout/KeyboardShortcutsDialog.tsx +++ b/web/src/components/layout/KeyboardShortcutsDialog.tsx @@ -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 ( @@ -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 ( ))} + {columns.length > 9 && ( + + Number shortcuts cover the first nine columns. Use the card status control for other + columns. + + )}
diff --git a/web/src/hooks/useKeyboard.tsx b/web/src/hooks/useKeyboard.tsx index c29f4619..b8ac9adf 100644 --- a/web/src/hooks/useKeyboard.tsx +++ b/web/src/hooks/useKeyboard.tsx @@ -34,6 +34,8 @@ interface KeyboardContextValue { selectedTaskId: string | null; setSelectedTaskId: (id: string | null) => void; + columns: ReturnType; + // Task list for navigation tasks: Task[]; setTasks: (tasks: Task[]) => void; @@ -253,6 +255,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { isHelpOpen, selectedTaskId, setSelectedTaskId, + columns, tasks, setTasks, setOnOpenTask, @@ -268,6 +271,7 @@ export function KeyboardProvider({ children }: { children: ReactNode }) { isHelpOpen, selectedTaskId, setSelectedTaskId, + columns, tasks, setTasks, setOnOpenTask, From 0a39c512fc6593028da5fa081638655896e22fe8 Mon Sep 17 00:00:00 2001 From: bradgroux Date: Mon, 7 Sep 2026 18:54:19 -0500 Subject: [PATCH 14/14] docs: use an explicit placeholder for the login token example --- .gitleaksignore | 1 - docs/API-REFERENCE.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index 4453c9a2..5344fdf2 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -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. diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d0b0d044..24170bb7 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -1042,7 +1042,7 @@ POST /api/auth/login ```json { - "token": "eyJhbGciOiJIUzI1NiIs...", + "token": "", "role": "admin", "expiresIn": "24h" }