diff --git a/docs/superpowers/plans/2026-07-25-local-task-management-ui.md b/docs/superpowers/plans/2026-07-25-local-task-management-ui.md new file mode 100644 index 0000000..82a91e2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-local-task-management-ui.md @@ -0,0 +1,257 @@ +# Local Task Management UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Relay's health-check shell with an accessible, desktop-oriented React task UI for capture, review, editing, lifecycle actions, and recent completions. + +**Architecture:** Keep task HTTP/Zod parsing in a browser-only API layer, loading state in `useTaskView`, and UI concerns in focused components. `App` owns selected view, selected task, and the cross-component reconciliation rules; every mutation uses the server-returned DTO rather than optimistic status construction. + +**Tech Stack:** React 19, TypeScript, Vite, Zod, browser `fetch`, Vitest, Testing Library, ordinary CSS. + +## Global Constraints + +- Access tasks only through the loopback HTTP API; never import backend/domain modules or SQLite code. +- Do not add Router, Redux, Zustand, TanStack Query, component/UI frameworks, Tailwind, drag-and-drop, icon packages, or generated clients. +- API views are `inbox`, `active`, `backlog`, and `completed`; completed requests use `limit=50`. +- Preserve server authority: render plausible actions only and surface/reconcile 409 conflicts. +- Keep the existing 80% coverage thresholds while covering authored `web/src` modules without snapshots for workflows. + +--- + +## File Structure + +- `web/src/api/task-contracts.ts`: Zod task, success, and error contracts plus inferred browser DTO/input types. +- `web/src/api/task-client.ts`: fetch wrapper, response validation, typed API error, and all task endpoints. +- `tests/unit/web/task-client.test.ts`: API boundary behaviour and request construction. +- `web/src/hooks/useTaskView.ts`: abortable, stale-safe list loading and local list reconciliation helpers. +- `web/src/components/*.tsx`: focused navigation, composer, list/row/badge, errors, and details/lifecycle editing controls. +- `web/src/App.tsx`: orchestration only; view/selection state and mutation reconciliation. +- `web/src/styles.css`: the single semantic desktop list-first layout stylesheet. +- `web/src/App.test.tsx`: behavioural UI tests using a mocked API module. +- `vitest.config.ts`: coverage include extended to authored UI modules. + +### Task 1: Add browser task contracts and HTTP client + +**Files:** + +- Create: `web/src/api/task-contracts.ts` +- Create: `web/src/api/task-client.ts` +- Create: `tests/unit/web/task-client.test.ts` + +**Interfaces:** + +- Produces `TaskDto`, `TaskView`, `CreateTaskInput`, `EditTaskInput`, and `RelayApiError`. +- Produces `createTask`, `getTask`, `listTasks`, `editTask`, and one `POST` helper per lifecycle endpoint. + +- [ ] **Step 1: Write the failing API-client tests** + +Test a hand-authored `TaskDto` fixture for successful `{ task }` and `{ tasks }` parsing; malformed success/error payloads; 400/404/409/500 error messages and details; encoded IDs; each endpoint's method/path/body; and an `AbortError` rejected by `fetch`. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `pnpm.cmd test -- tests/unit/web/task-client.test.ts` + +Expected: FAIL because the browser task contracts/client do not exist. + +- [ ] **Step 3: Implement the minimal API boundary** + +Use Zod schemas inferred into types. Body requests pass `{ method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal }`; action/list/get requests omit the content type. Parse non-2xx `{ error }` into `RelayApiError`, use `INVALID_SERVER_RESPONSE` for malformed payloads, and rethrow aborts unchanged. + +- [ ] **Step 4: Run the focused test to verify it passes** + +Run: `pnpm.cmd test -- tests/unit/web/task-client.test.ts` + +- [ ] **Step 5: Commit** + +```bash +git add web/src/api/task-contracts.ts web/src/api/task-client.ts tests/unit/web/task-client.test.ts +git commit -m "Add browser task API client" +``` + +### Task 2: Establish the semantic UI shell and task-view loading hook + +**Files:** + +- Create: `web/src/components/ViewNavigation.tsx` +- Create: `web/src/components/ErrorBanner.tsx` +- Create: `web/src/hooks/useTaskView.ts` +- Create: `web/src/styles.css` +- Modify: `web/src/App.tsx` +- Modify: `web/src/main.tsx` +- Modify: `web/src/App.test.tsx` + +**Interfaces:** + +- `useTaskView(view)` returns `{ tasks, loading, error, reload, replaceTask, removeTask }`. +- `ViewNavigation` accepts the selected `TaskView` and view-change callback. + +- [ ] **Step 1: Write failing tests for initial active loading, empty state, navigation, and retry** + +Mock `task-client`. Assert initial Active navigation has `aria-current`, shows a loading message before a response, renders “No active work.” for an empty list, calls `listTasks('backlog')` on navigation, and offers retry after a list failure. + +- [ ] **Step 2: Run the focused App test and verify it fails** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +- [ ] **Step 3: Implement the shell and hook** + +Render semantic `header`, `nav`, and `main`, retaining a compact health indicator/retry. In `useTaskView`, cancel the prior `AbortController`, retain displayed tasks while reloading, discard stale results, and ignore aborts. Import the one CSS file from `main.tsx`. + +- [ ] **Step 4: Run the focused App test and verify it passes** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +### Task 3: Implement task list, status display, and read-only detail selection + +**Files:** + +- Create: `web/src/components/TaskStatusBadge.tsx` +- Create: `web/src/components/TaskRow.tsx` +- Create: `web/src/components/TaskList.tsx` +- Create: `web/src/components/TaskDetailsPanel.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.test.tsx` + +**Interfaces:** + +- `TaskList` receives a view, `readonly TaskDto[]`, selected task ID, and `onSelect`. +- `TaskDetailsPanel` receives one selected `TaskDto | null`, task callbacks, and pending/error state. + +- [ ] **Step 1: Write failing tests for representative rows and keyboard selection** + +Verify title, explicit `ACTIVE`/`IN_PROGRESS` status text, optional priority/workspace, and updated time render. Select a row with its named button and keyboard Enter; assert all read-only provenance/lifecycle fields appear in the panel. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +- [ ] **Step 3: Implement list-first read mode** + +Use buttons for row selection, concise local time formatting, exact per-view empty copy, a visible status badge, and a readable details section. Keep IDs/status/timestamps/provenance non-editable. + +- [ ] **Step 4: Run the focused test to verify it passes** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +### Task 4: Add quick task capture and authoritative create reconciliation + +**Files:** + +- Create: `web/src/components/TaskComposer.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.test.tsx` + +**Interfaces:** + +- `TaskComposer` submits `CreateTaskInput` and receives `pending`, error/details, and success notification callbacks. + +- [ ] **Step 1: Write failing tests for create success, validation failure, and duplicate submission** + +Assert title is labelled, required, and `maxLength=300`; successful creation clears fields, moves to Inbox, reloads, and selects the returned task. A `RelayApiError` with title details keeps values and displays field feedback. Verify the submit button disables while the promise remains pending. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +- [ ] **Step 3: Implement focused capture UI** + +Always render title plus expandable description/priority/workspace fields; do not send creator/status/source context. Guard concurrent submit, use `aria-live="polite"` feedback, and focus the title after successful creation. + +- [ ] **Step 4: Run the focused test to verify it passes** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +### Task 5: Add metadata editing and app-owned dirty-discard confirmation + +**Files:** + +- Modify: `web/src/components/TaskDetailsPanel.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.test.tsx` + +**Interfaces:** + +- `onEdit(id, EditTaskInput): Promise` supplies the complete server-returned task to view reconciliation. +- Details panel exposes `Edit task`, `Save changes`, `Cancel`, and discard-confirmation controls. + +- [ ] **Step 1: Write failing tests for edit, nullable clearing, and dirty discard** + +Open a task, explicitly enter edit mode, alter every editable field, clear nullable fields to `null`, save, and assert the returned DTO is shown. Change a field then close/select another task; assert an app-owned confirmation is shown and only discard proceeds when confirmed. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +- [ ] **Step 3: Implement normalized edit state** + +Keep form state separate from the DTO, compare normalized empty inputs to nullable task values for dirty state, preserve input on error, disable save while pending, and restore exactly the last server representation on cancel. Allow editing `DONE` tasks. + +- [ ] **Step 4: Run the focused test to verify it passes** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +### Task 6: Add lifecycle actions, archive confirmation, and conflict reconciliation + +**Files:** + +- Modify: `web/src/components/TaskDetailsPanel.tsx` +- Modify: `web/src/App.tsx` +- Modify: `web/src/App.test.tsx` + +**Interfaces:** + +- `onAction(task, action)` calls the matching client function and reconciles only with its returned `TaskDto`. + +- [ ] **Step 1: Write failing lifecycle tests** + +For each status, assert exactly the allowed labels: INBOX activate/backlog/archive; ACTIVE inbox/start/backlog/complete/archive; IN_PROGRESS return-to-active/backlog/complete/archive; BACKLOG inbox/activate/archive; DONE archive only. Test removal/close when a result leaves the view, replacement when it remains, two-step archive cancel/confirm, and a 409 message followed by reload. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +- [ ] **Step 3: Implement lifecycle controls** + +Map labels to client calls without recreating transition legality. Disable all mutation controls for the selected task while pending. Archive first switches to `Confirm archive`/`Cancel`, clears confirmation on view/selection change, and makes no request until confirmation. On 409, retain the server message and reload. + +- [ ] **Step 4: Run the focused test to verify it passes** + +Run: `pnpm.cmd test -- web/src/App.test.tsx` + +### Task 7: Complete coverage configuration and quality verification + +**Files:** + +- Modify: `vitest.config.ts` +- Modify: `web/src/App.test.tsx` + +- [ ] **Step 1: Add focused accessibility and pending-state tests** + +Cover service-unavailable retry, `aria-live` feedback, labelled inputs, current navigation, core keyboard interaction, stale request abortion, and disabled mutation controls. + +- [ ] **Step 2: Extend coverage to authored UI modules** + +Change coverage inclusion to `web/src/**/*.{ts,tsx}` and exclude `main.tsx`, `*.d.ts`, and type-only files while retaining the 80% thresholds. + +- [ ] **Step 3: Run the required quality gate** + +Run: + +```bash +pnpm.cmd test -- tests/unit/web/task-client.test.ts web/src/App.test.tsx +pnpm.cmd typecheck +pnpm.cmd lint +pnpm.cmd build:web +pnpm.cmd verify +``` + +- [ ] **Step 4: Perform the manual keyboard smoke test** + +Run `pnpm.cmd dev:ui`; create, select, edit, transition, and archive a task entirely with the keyboard against a temporary local database. Confirm no unsupported UI framework or lifecycle-rule duplication was introduced. + +- [ ] **Step 5: Commit** + +```bash +git add web/src tests/unit/web/task-client.test.ts vitest.config.ts +git commit -m "Build minimal local task management UI" +``` diff --git a/package.json b/package.json index 108ac60..a228609 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "jsdom": "^29.1.1", + "prettier": "^3.9.6", "tsup": "^8.5.1", "tsx": "^4.23.1", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24fb465..5731d92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: jsdom: specifier: ^29.1.1 version: 29.1.1 + prettier: + specifier: ^3.9.6 + version: 3.9.6 tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.23)(tsx@4.23.1)(typescript@5.9.3) @@ -1891,6 +1894,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -3966,6 +3974,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.9.6: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 diff --git a/src/interfaces/http/http-router.ts b/src/interfaces/http/http-router.ts index af7371d..e5d445d 100644 --- a/src/interfaces/http/http-router.ts +++ b/src/interfaces/http/http-router.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { readFileSync } from 'node:fs'; import type { TaskApplication } from '../../application/tasks/task-application.js'; +import { TaskApplicationError } from '../../application/tasks/task-application-errors.js'; import { getHealth } from '../../application/health/get-health.js'; import { toHttpError, HttpError } from './http-errors.js'; import { sendError, sendJson } from './http-json.js'; @@ -46,7 +47,8 @@ export async function routeHttpRequest( throw new HttpError(404, 'NOT_FOUND', 'Route was not found.'); } catch (error) { const mapped = toHttpError(error); - if (mapped.status === 500) process.stderr.write('[ERROR] Unhandled HTTP request failure.\n'); + if (mapped.status === 500 && !(error instanceof TaskApplicationError)) + process.stderr.write('[ERROR] Unhandled HTTP request failure.\n'); sendError(response, mapped); } } diff --git a/tests/integration/http-tasks.test.ts b/tests/integration/http-tasks.test.ts index c3e3fb5..a72f31c 100644 --- a/tests/integration/http-tasks.test.ts +++ b/tests/integration/http-tasks.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createTaskApplication, type TaskApplication, @@ -175,15 +175,21 @@ describe('http tasks integration', () => { taskApplication: failingApplication, }); - const response = await fetch(`${server.url}/api/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'Persist safely' }), - }); - expect(response.status).toBe(500); - await expect(response.json()).resolves.toEqual({ - error: { code: 'INTERNAL_ERROR', message: 'An unexpected internal error occurred.' }, - }); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const response = await fetch(`${server.url}/api/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Persist safely' }), + }); + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'An unexpected internal error occurred.' }, + }); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stderr.mockRestore(); + } }); it('retrieves tasks and exposes every explicit lifecycle route without generic transitions', async () => { diff --git a/tests/unit/web/task-client.test.ts b/tests/unit/web/task-client.test.ts new file mode 100644 index 0000000..079adbd --- /dev/null +++ b/tests/unit/web/task-client.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + activateTask, + archiveTask, + completeTask, + createTask, + editTask, + getTask, + listTasks, + moveTaskToBacklog, + moveTaskToInbox, + startTask, +} from '../../../web/src/api/task-client.js'; +import type { RelayApiError } from '../../../web/src/api/task-client.js'; + +const task = { + id: 'task / 1', + title: 'Ship the UI', + description: 'Use the local API.', + status: 'ACTIVE', + priority: 'HIGH', + workspace: 'relay', + sourceContext: 'issue-9', + createdByType: 'HUMAN', + createdByName: null, + createdAt: '2026-07-25T10:00:00.000Z', + updatedAt: '2026-07-25T10:01:00.000Z', + startedAt: null, + completedAt: null, + archivedAt: null, +} as const; + +function response(ok: boolean, status: number, body: unknown): Response { + return { + ok, + status, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('task-client', () => { + it('parses valid task and list responses', async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(response(true, 201, { task })) + .mockResolvedValueOnce(response(true, 200, { tasks: [task] })); + vi.stubGlobal('fetch', fetchSpy); + + await expect(createTask({ title: 'Ship the UI' })).resolves.toEqual(task); + await expect(listTasks('active')).resolves.toEqual([task]); + + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + '/api/tasks', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Ship the UI' }), + }), + ); + expect(fetchSpy).toHaveBeenNthCalledWith(2, '/api/tasks?view=active', {}); + }); + + it('uses completed limit and encodes a task ID as one path segment', async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(response(true, 200, { tasks: [] })) + .mockResolvedValueOnce(response(true, 200, { task })); + vi.stubGlobal('fetch', fetchSpy); + + await listTasks('completed', 50); + await getTask(task.id); + + expect(fetchSpy).toHaveBeenNthCalledWith(1, '/api/tasks?view=completed&limit=50', {}); + expect(fetchSpy).toHaveBeenNthCalledWith(2, '/api/tasks/task%20%2F%201', {}); + }); + + it('sends the correct method, path, and body for edits and lifecycle actions', async () => { + const fetchSpy = vi.fn().mockResolvedValue(response(true, 200, { task })); + vi.stubGlobal('fetch', fetchSpy); + + await editTask(task.id, { title: 'Updated', priority: null }); + await moveTaskToInbox(task.id); + await activateTask(task.id); + await startTask(task.id); + await moveTaskToBacklog(task.id); + await completeTask(task.id); + await archiveTask(task.id); + + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + '/api/tasks/task%20%2F%201', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Updated', priority: null }), + }), + ); + expect(fetchSpy.mock.calls.slice(1).map(([url, init]) => [url, init?.method])).toEqual([ + ['/api/tasks/task%20%2F%201/move-to-inbox', 'POST'], + ['/api/tasks/task%20%2F%201/activate', 'POST'], + ['/api/tasks/task%20%2F%201/start', 'POST'], + ['/api/tasks/task%20%2F%201/move-to-backlog', 'POST'], + ['/api/tasks/task%20%2F%201/complete', 'POST'], + ['/api/tasks/task%20%2F%201/archive', 'POST'], + ]); + for (const [, init] of fetchSpy.mock.calls.slice(1)) { + expect(init?.headers).toBeUndefined(); + expect(init?.body).toBeUndefined(); + } + }); + + it.each([400, 404, 409, 500])('surfaces a structured %i server error', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + response(false, status, { + error: { + code: 'TASK_CONFLICT', + message: 'The task changed elsewhere.', + details: { title: ['Title is required'] }, + }, + }), + ), + ); + + await expect(createTask({ title: 'Ship the UI' })).rejects.toMatchObject({ + name: 'RelayApiError', + status, + code: 'TASK_CONFLICT', + message: 'The task changed elsewhere.', + details: { title: ['Title is required'] }, + } satisfies Partial); + }); + + it('rejects malformed success and error payloads with a safe API error', async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(response(true, 200, { task: { id: 123 } })) + .mockResolvedValueOnce(response(false, 500, { unexpected: 'body' })); + vi.stubGlobal('fetch', fetchSpy); + + await expect(getTask('task-1')).rejects.toMatchObject({ + code: 'INVALID_SERVER_RESPONSE', + message: 'Relay returned an invalid response.', + }); + await expect(getTask('task-1')).rejects.toMatchObject({ + code: 'INVALID_SERVER_RESPONSE', + message: 'Relay returned an invalid error response.', + }); + }); + + it('preserves an AbortError from fetch', async () => { + const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)); + + await expect(listTasks('active')).rejects.toBe(abortError); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 349dd03..6962e40 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,10 +14,14 @@ export default defineConfig({ 'src/database/**/*.ts', 'src/interfaces/mcp/create-mcp-server.ts', 'src/interfaces/http/**/*.ts', - 'web/src/api/**/*.ts', - 'web/src/App.tsx', + 'web/src/**/*.{ts,tsx}', + ], + exclude: [ + 'src/application/health/health.ts', + 'src/**/*.d.ts', + 'web/src/main.tsx', + 'web/src/**/*.d.ts', ], - exclude: ['src/application/health/health.ts', 'src/**/*.d.ts'], thresholds: { statements: 80, branches: 80, diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 3801ace..331f254 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,76 +1,190 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { App } from './App.js'; import * as healthClient from './api/health-client.js'; - -describe('App component', () => { - beforeEach(() => { - vi.restoreAllMocks(); +import * as taskClient from './api/task-client.js'; + +const task = { + id: 'task-1', + title: 'Ship UI', + description: 'Build the sidecar.', + status: 'ACTIVE' as const, + priority: 'HIGH' as const, + workspace: 'relay', + sourceContext: 'issue-9', + createdByType: 'HUMAN' as const, + createdByName: null, + createdAt: '2026-07-25T10:00:00.000Z', + updatedAt: '2026-07-25T10:01:00.000Z', + startedAt: null, + completedAt: null, + archivedAt: null, +}; + +beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(healthClient, 'fetchHealth').mockResolvedValue({ + name: 'relay', + status: 'ok', + version: '0.1.0', }); + vi.spyOn(taskClient, 'listTasks').mockResolvedValue([task]); + vi.spyOn(taskClient, 'createTask'); + vi.spyOn(taskClient, 'editTask'); + vi.spyOn(taskClient, 'archiveTask'); +}); - it('renders loading, then success state', async () => { - vi.spyOn(healthClient, 'fetchHealth').mockResolvedValue({ - name: 'relay', - status: 'ok', - version: '0.1.0', - }); - +describe('App', () => { + it('loads Active by default, navigates views, and selects a task', async () => { render(); - - expect(screen.getByTestId('status-loading')).toBeDefined(); - - await waitFor(() => { - expect(screen.getByTestId('status-success')).toBeDefined(); - }); - expect(screen.getByText('Connected (v0.1.0)')).toBeDefined(); + expect(screen.getByText('Loading tasks…')).toBeDefined(); + await screen.findByRole('button', { name: 'Open Ship UI' }); + expect(screen.getByRole('button', { name: 'Active' }).getAttribute('aria-current')).toBe( + 'page', + ); + fireEvent.click(screen.getByRole('button', { name: 'Open Ship UI' })); + expect(screen.getByRole('heading', { name: 'Ship UI' })).toBeDefined(); + expect(screen.getAllByText('ACTIVE').length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole('button', { name: 'Backlog' })); + await waitFor(() => + expect(taskClient.listTasks).toHaveBeenCalledWith( + 'backlog', + undefined, + expect.any(AbortSignal), + ), + ); }); - it('renders error state and handles retry button click', async () => { - const fetchSpy = vi - .spyOn(healthClient, 'fetchHealth') + it('shows empty, service-unavailable, and retry states', async () => { + vi.mocked(taskClient.listTasks) .mockRejectedValueOnce(new Error('Connection refused')) - .mockResolvedValueOnce({ name: 'relay', status: 'ok', version: '0.1.0' }); - + .mockResolvedValueOnce([]); + vi.mocked(healthClient.fetchHealth).mockRejectedValueOnce(new Error('Connection refused')); render(); + await screen.findByText('Unable to load tasks.'); + expect(screen.getByText('Relay service unavailable')).toBeDefined(); + expect(screen.getByRole('button', { name: 'Retry connection' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await screen.findByText('No active work.'); + }); - await waitFor(() => { - expect(screen.getByTestId('status-error')).toBeDefined(); - }); - - const retryBtn = screen.getByText('Retry'); - fireEvent.click(retryBtn); + it('creates into Inbox and preserves validation input after failure', async () => { + const inboxTask = { ...task, id: 'task-2', title: 'Captured', status: 'INBOX' as const }; + vi.mocked(taskClient.createTask) + .mockRejectedValueOnce( + new taskClient.RelayApiError(400, 'INVALID', 'Title is required.', { + title: ['Title is required.'], + }), + ) + .mockResolvedValueOnce(inboxTask); + render(); + await screen.findByRole('button', { name: 'Open Ship UI' }); + const title = screen.getByLabelText('Title'); + fireEvent.change(title, { target: { value: 'Captured' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create task' })); + await screen.findByText('Title is required.'); + expect((title as HTMLInputElement).value).toBe('Captured'); + fireEvent.click(screen.getByRole('button', { name: 'Create task' })); + await waitFor(() => expect(taskClient.createTask).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(taskClient.listTasks).toHaveBeenCalledWith( + 'inbox', + undefined, + expect.any(AbortSignal), + ), + ); + }); - await waitFor(() => { - expect(screen.getByTestId('status-success')).toBeDefined(); + it('edits a task and requires explicit archive confirmation', async () => { + vi.mocked(taskClient.editTask).mockResolvedValue({ ...task, title: 'Updated' }); + vi.mocked(taskClient.archiveTask).mockResolvedValue({ + ...task, + status: 'ARCHIVED', + archivedAt: '2026-07-25T10:02:00.000Z', }); - expect(fetchSpy).toHaveBeenCalledTimes(2); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Open Ship UI' })); + fireEvent.click(screen.getByRole('button', { name: 'Edit task' })); + fireEvent.change(screen.getAllByLabelText('Title')[1]!, { target: { value: 'Updated' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + await screen.findByRole('heading', { name: 'Updated' }); + fireEvent.click(screen.getByRole('button', { name: 'Archive' })); + expect(taskClient.archiveTask).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Confirm archive' })); + await waitFor(() => expect(taskClient.archiveTask).toHaveBeenCalledWith('task-1')); }); - it('aborts the active retry request when the component unmounts', async () => { - const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' }); - vi.spyOn(healthClient, 'fetchHealth') - .mockRejectedValueOnce(new Error('Connection refused')) - .mockImplementationOnce( - (signal?: AbortSignal) => - new Promise((_, reject) => { - signal?.addEventListener('abort', () => reject(abortError), { once: true }); - }), - ); - - const { unmount } = render(); - - await waitFor(() => { - expect(screen.getByTestId('status-error')).toBeDefined(); - }); + it('keeps an edit draft visible after a validation failure', async () => { + vi.mocked(taskClient.editTask).mockRejectedValue( + new taskClient.RelayApiError(400, 'INVALID', 'Title is required.', { + title: ['Title is required.'], + }), + ); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Open Ship UI' })); + fireEvent.click(screen.getByRole('button', { name: 'Edit task' })); + const title = screen.getAllByLabelText('Title')[1]!; + fireEvent.change(title, { target: { value: 'Invalid title' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + await screen.findByText('Title is required.'); + expect(screen.getByRole('button', { name: 'Save changes' })).toBeDefined(); + expect((screen.getAllByLabelText('Title')[1] as HTMLInputElement).value).toBe('Invalid title'); + }); - fireEvent.click(screen.getByText('Retry')); - unmount(); + it('confirms before discarding a dirty edit when another task is selected', async () => { + const second = { ...task, id: 'task-2', title: 'Second task' }; + vi.mocked(taskClient.listTasks).mockResolvedValue([task, second]); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Open Ship UI' })); + fireEvent.click(screen.getByRole('button', { name: 'Edit task' })); + fireEvent.change(screen.getAllByLabelText('Title')[1]!, { target: { value: 'Changed' } }); + fireEvent.click(screen.getByRole('button', { name: 'Open Second task' })); + + expect(screen.getByText('Discard unsaved changes?')).toBeDefined(); + expect(screen.getByRole('heading', { name: 'Ship UI' })).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: 'Discard changes' })); + expect(screen.getByRole('heading', { name: 'Second task' })).toBeDefined(); + }); - await waitFor(() => { - expect(healthClient.fetchHealth).toHaveBeenCalledTimes(2); - }); + it('shows all read-only provenance and lifecycle fields', async () => { + const detailed = { + ...task, + createdByName: 'Krishna', + startedAt: '2026-07-25T10:02:00.000Z', + completedAt: '2026-07-25T10:03:00.000Z', + archivedAt: '2026-07-25T10:04:00.000Z', + }; + vi.mocked(taskClient.listTasks).mockResolvedValue([detailed]); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Open Ship UI' })); + + for (const label of [ + 'Task ID', + 'Created', + 'Updated', + 'Started', + 'Completed', + 'Archived', + 'Created by', + ]) + expect(screen.getAllByText(label, { exact: true }).length).toBeGreaterThan(0); + expect(screen.getByText('HUMAN (Krishna)')).toBeDefined(); + }); - const retrySignal = vi.mocked(healthClient.fetchHealth).mock.calls[1]?.[0]; - expect(retrySignal?.aborted).toBe(true); + it.each([ + ['INBOX', ['Activate', 'Move to backlog']], + ['ACTIVE', ['Move to inbox', 'Start', 'Move to backlog', 'Complete']], + ['IN_PROGRESS', ['Return to active', 'Move to backlog', 'Complete']], + ['BACKLOG', ['Move to inbox', 'Activate']], + ['DONE', []], + ] as const)('renders only valid lifecycle actions for %s', async (status, actions) => { + vi.mocked(taskClient.listTasks).mockResolvedValue([{ ...task, status }]); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Open Ship UI' })); + for (const action of actions) + expect(screen.getByRole('button', { name: action })).toBeDefined(); + expect(screen.getByRole('button', { name: 'Archive' })).toBeDefined(); }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index f63840a..76a7883 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,72 +1,187 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { fetchHealth, type HealthStatusResponse } from './api/health-client.js'; +import { useCallback, useEffect, useState } from 'react'; +import { fetchHealth } from './api/health-client.js'; +import { + activateTask, + archiveTask, + completeTask, + createTask, + editTask, + moveTaskToBacklog, + moveTaskToInbox, + RelayApiError, + startTask, +} from './api/task-client.js'; +import type { CreateTaskInput, EditTaskInput, TaskDto, TaskView } from './api/task-contracts.js'; +import { ErrorBanner } from './components/ErrorBanner.js'; +import { TaskComposer } from './components/TaskComposer.js'; +import { TaskDetailsPanel } from './components/TaskDetailsPanel.js'; +import { TaskList } from './components/TaskList.js'; +import { ViewNavigation } from './components/ViewNavigation.js'; +import { useTaskView } from './hooks/useTaskView.js'; -export function App() { - const [health, setHealth] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const activeControllerRef = useRef(null); - - const loadHealth = useCallback(() => { - activeControllerRef.current?.abort(); - setLoading(true); - setError(null); - const controller = new AbortController(); - activeControllerRef.current = controller; +const names: Record = { + inbox: 'Inbox', + active: 'Active', + backlog: 'Backlog', + completed: 'Completed', +}; +function stays(view: TaskView, status: TaskDto['status']) { + return ( + (view === 'inbox' && status === 'INBOX') || + (view === 'active' && (status === 'ACTIVE' || status === 'IN_PROGRESS')) || + (view === 'backlog' && status === 'BACKLOG') || + (view === 'completed' && status === 'DONE') + ); +} - fetchHealth(controller.signal) - .then((data) => { - if (controller.signal.aborted || activeControllerRef.current !== controller) { - return; - } - setHealth(data); - setLoading(false); - activeControllerRef.current = null; - }) - .catch((err: unknown) => { - if (controller.signal.aborted || activeControllerRef.current !== controller) { - return; - } - if (err instanceof Error && err.name === 'AbortError') return; - setError(err instanceof Error ? err.message : 'Unknown error'); - setLoading(false); - activeControllerRef.current = null; - }); +export function App() { + const [view, setView] = useState('active'); + const { tasks, loading, error, reload, replaceTask, removeTask } = useTaskView(view); + const [selected, setSelected] = useState(null); + const [pending, setPending] = useState(false); + const [message, setMessage] = useState(null); + const [serviceError, setServiceError] = useState(false); + const [health, setHealth] = useState(null); + const [dirty, setDirty] = useState(false); + const [deferred, setDeferred] = useState<(() => void) | null>(null); + const checkHealth = useCallback(() => { + setServiceError(false); + void fetchHealth() + .then((value) => setHealth(value.version)) + .catch(() => setServiceError(true)); }, []); - useEffect(() => { - loadHealth(); - - return () => { - activeControllerRef.current?.abort(); - activeControllerRef.current = null; - }; - }, [loadHealth]); - + checkHealth(); + }, [checkHealth]); + const requestChange = (change: () => void) => { + if (dirty) setDeferred(() => change); + else change(); + }; + const select = (task: TaskDto) => + requestChange(() => { + setSelected(task); + setMessage(null); + }); + const changeView = (next: TaskView) => + requestChange(() => { + setView(next); + setSelected(null); + setMessage(null); + }); + const reconcile = (task: TaskDto) => { + if (stays(view, task.status)) { + replaceTask(task); + setSelected(task); + } else { + removeTask(task.id); + setSelected(null); + } + }; + async function capture(input: CreateTaskInput) { + const created = await createTask(input); + if (view !== 'inbox') setView('inbox'); + reload(); + setSelected(created); + } + async function save(id: string, input: EditTaskInput): Promise { + if (pending) return false; + setPending(true); + setMessage(null); + try { + reconcile(await editTask(id, input)); + return true; + } catch (cause) { + setMessage(cause instanceof Error ? cause.message : 'Unable to update task.'); + return false; + } finally { + setPending(false); + } + } + async function action(name: 'inbox' | 'activate' | 'start' | 'backlog' | 'complete' | 'archive') { + if (!selected || pending) return; + const operation = { + inbox: moveTaskToInbox, + activate: activateTask, + start: startTask, + backlog: moveTaskToBacklog, + complete: completeTask, + archive: archiveTask, + }[name]; + setPending(true); + setMessage(null); + try { + reconcile(await operation(selected.id)); + } catch (cause) { + setMessage(cause instanceof Error ? cause.message : 'Unable to update task.'); + if (cause instanceof RelayApiError && cause.status === 409) reload(); + } finally { + setPending(false); + } + } return ( -
-

Relay

-

Local task sidecar for human–AI workflows.

- -
- {loading &&

Checking local service…

} - {!loading && error && ( -
-

Relay service unavailable

- -
- )} - {!loading && health &&

Connected (v{health.version})

} -
+
+
+
+

Relay

+

Local task sidecar for human–AI workflows.

+
+
+ {serviceError ? ( + <> + Relay service unavailable{' '} + + + ) : health ? ( + `Connected (v${health})` + ) : ( + 'Checking local service…' + )} +
+
+ + {deferred && ( +
+

Discard unsaved changes?

+ + +
+ )} +
+
+ +

{names[view]}

+ {loading && !tasks.length &&

Loading tasks…

} + {error && } + +
+ void action(name)} + onDirtyChange={setDirty} + onEdit={save} + pending={pending} + task={selected} + /> +
); } diff --git a/web/src/api/task-client.ts b/web/src/api/task-client.ts new file mode 100644 index 0000000..e4c7771 --- /dev/null +++ b/web/src/api/task-client.ts @@ -0,0 +1,151 @@ +import { + ErrorResponseSchema, + TaskListResponseSchema, + TaskResponseSchema, + type CreateTaskInput, + type EditTaskInput, + type TaskDto, + type TaskView, +} from './task-contracts.js'; + +export class RelayApiError extends Error { + readonly status: number; + readonly code: string; + readonly details?: Readonly>; + + constructor( + status: number, + code: string, + message: string, + details?: Readonly>, + ) { + super(message); + this.name = 'RelayApiError'; + this.status = status; + this.code = code; + if (details !== undefined) this.details = details; + } +} + +export async function createTask(input: CreateTaskInput, signal?: AbortSignal): Promise { + return singleTask('/api/tasks', jsonRequest('POST', input, signal)); +} + +export async function getTask(id: string, signal?: AbortSignal): Promise { + return singleTask(taskPath(id), signal === undefined ? {} : { signal }); +} + +export async function listTasks( + view: TaskView, + limit?: number, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ view }); + if (limit !== undefined) params.set('limit', String(limit)); + const response = await request( + `/api/tasks?${params.toString()}`, + signal === undefined ? {} : { signal }, + ); + const payload = await responsePayload(response); + const parsed = TaskListResponseSchema.safeParse(payload); + if (!parsed.success) throw invalidResponse(false); + return parsed.data.tasks; +} + +export async function editTask( + id: string, + input: EditTaskInput, + signal?: AbortSignal, +): Promise { + return singleTask(taskPath(id), jsonRequest('PATCH', input, signal)); +} + +export async function moveTaskToInbox(id: string, signal?: AbortSignal): Promise { + return action(id, 'move-to-inbox', signal); +} + +export async function activateTask(id: string, signal?: AbortSignal): Promise { + return action(id, 'activate', signal); +} + +export async function startTask(id: string, signal?: AbortSignal): Promise { + return action(id, 'start', signal); +} + +export async function moveTaskToBacklog(id: string, signal?: AbortSignal): Promise { + return action(id, 'move-to-backlog', signal); +} + +export async function completeTask(id: string, signal?: AbortSignal): Promise { + return action(id, 'complete', signal); +} + +export async function archiveTask(id: string, signal?: AbortSignal): Promise { + return action(id, 'archive', signal); +} + +async function action(id: string, name: string, signal?: AbortSignal): Promise { + return singleTask( + `${taskPath(id)}/${name}`, + signal === undefined ? { method: 'POST' } : { method: 'POST', signal }, + ); +} + +async function singleTask(path: string, init: RequestInit): Promise { + const response = await request(path, init); + const payload = await responsePayload(response); + const parsed = TaskResponseSchema.safeParse(payload); + if (!parsed.success) throw invalidResponse(false); + return parsed.data.task; +} + +async function request(path: string, init: RequestInit): Promise { + const response = await fetch(path, init); + if (!response.ok) { + const payload = await jsonOrUndefined(response); + const parsed = ErrorResponseSchema.safeParse(payload); + if (!parsed.success) throw invalidResponse(true); + throw new RelayApiError( + response.status, + parsed.data.error.code, + parsed.data.error.message, + parsed.data.error.details, + ); + } + return response; +} + +async function responsePayload(response: Response): Promise { + const payload = await jsonOrUndefined(response); + if (payload === undefined) throw invalidResponse(false); + return payload; +} + +async function jsonOrUndefined(response: Response): Promise { + try { + return await response.json(); + } catch { + return undefined; + } +} + +function jsonRequest(method: 'POST' | 'PATCH', body: object, signal?: AbortSignal): RequestInit { + return { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + ...(signal === undefined ? {} : { signal }), + }; +} + +function taskPath(id: string): string { + return `/api/tasks/${encodeURIComponent(id)}`; +} + +function invalidResponse(error: boolean): RelayApiError { + return new RelayApiError( + 500, + 'INVALID_SERVER_RESPONSE', + error ? 'Relay returned an invalid error response.' : 'Relay returned an invalid response.', + ); +} diff --git a/web/src/api/task-contracts.ts b/web/src/api/task-contracts.ts new file mode 100644 index 0000000..156c97f --- /dev/null +++ b/web/src/api/task-contracts.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +export const TaskStatusSchema = z.enum([ + 'INBOX', + 'ACTIVE', + 'IN_PROGRESS', + 'BACKLOG', + 'DONE', + 'ARCHIVED', +]); +export const TaskPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH']); +export const TaskCreatorTypeSchema = z.enum(['HUMAN', 'AGENT']); +export const TaskViewSchema = z.enum(['inbox', 'active', 'backlog', 'completed']); + +export const TaskDtoSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + status: TaskStatusSchema, + priority: TaskPrioritySchema.nullable(), + workspace: z.string().nullable(), + sourceContext: z.string().nullable(), + createdByType: TaskCreatorTypeSchema, + createdByName: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + startedAt: z.string().nullable(), + completedAt: z.string().nullable(), + archivedAt: z.string().nullable(), +}); + +export const TaskResponseSchema = z.object({ task: TaskDtoSchema }); +export const TaskListResponseSchema = z.object({ tasks: z.array(TaskDtoSchema) }); +export const ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.array(z.string())).optional(), + }), +}); + +export const CreateTaskInputSchema = z.object({ + title: z.string(), + description: z.string().nullable().optional(), + priority: TaskPrioritySchema.nullable().optional(), + workspace: z.string().nullable().optional(), + sourceContext: z.string().nullable().optional(), +}); +export const EditTaskInputSchema = CreateTaskInputSchema.partial().refine( + (value) => Object.keys(value).length > 0, + 'At least one editable task field is required.', +); + +export type TaskDto = z.infer; +export type TaskView = z.infer; +export type CreateTaskInput = z.infer; +export type EditTaskInput = z.infer; diff --git a/web/src/components/ErrorBanner.tsx b/web/src/components/ErrorBanner.tsx new file mode 100644 index 0000000..5d510be --- /dev/null +++ b/web/src/components/ErrorBanner.tsx @@ -0,0 +1,10 @@ +export function ErrorBanner({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +

+ {message}{' '} + +

+ ); +} diff --git a/web/src/components/TaskComposer.tsx b/web/src/components/TaskComposer.tsx new file mode 100644 index 0000000..b96fa26 --- /dev/null +++ b/web/src/components/TaskComposer.tsx @@ -0,0 +1,97 @@ +import { useRef, useState } from 'react'; +import type { CreateTaskInput } from '../api/task-contracts.js'; +export function TaskComposer({ + onCreate, +}: { + onCreate: (input: CreateTaskInput) => Promise; +}) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [workspace, setWorkspace] = useState(''); + const [priority, setPriority] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const titleRef = useRef(null); + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (pending) return; + setPending(true); + setError(null); + try { + await onCreate({ + title, + description: description || null, + workspace: workspace || null, + priority, + }); + setTitle(''); + setDescription(''); + setWorkspace(''); + setPriority(null); + titleRef.current?.focus(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Unable to create task.'); + } finally { + setPending(false); + } + } + return ( +
{ + void submit(event); + }} + className="composer" + > +

Capture task

+ +
+ Optional metadata +