diff --git a/apps/web/package.json b/apps/web/package.json index 0e1efee9..93ab92a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,13 +5,13 @@ "scripts": { "build": "node ../../scripts/build-web.mjs", "build:vite": "vite build", - "dev": "next dev", + "dev": "next dev -p 3101", "dev:vite": "vite", "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "test:e2e": "playwright test", - "start": "next start" + "start": "next start -p 3101" }, "dependencies": { "@mosaicstack/design-tokens": "workspace:^", diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index dc852e38..0dd525ec 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -5,6 +5,14 @@ import { RegisterPage } from '@/spa/pages/register'; import { SsoCallbackPage } from '@/spa/pages/sso-callback'; import { ChatPage } from '@/spa/pages/chat'; import { ChatRouteErrorBoundary } from '@/spa/pages/chat-error-boundary'; +import { ProjectDetailPage } from '@/spa/pages/project-detail'; +import { ProjectsPage } from '@/spa/pages/projects'; +import { + ProjectDetailRouteErrorBoundary, + ProjectsRouteErrorBoundary, + TasksRouteErrorBoundary, +} from '@/spa/pages/resource-route-error-boundaries'; +import { TasksPage } from '@/spa/pages/tasks'; import { AuthGuard, GuestGuard } from '@/spa/guards'; import { Placeholder } from '@/spa/placeholder'; @@ -37,9 +45,17 @@ export const routes: RouteObject[] = [ children: [ { path: '/', element: }, { path: '/chat', element: , errorElement: }, - { path: '/projects', element: }, - { path: '/projects/:id', element: }, - { path: '/tasks', element: }, + { + path: '/projects', + element: , + errorElement: , + }, + { + path: '/projects/:id', + element: , + errorElement: , + }, + { path: '/tasks', element: , errorElement: }, { path: '/settings', element: }, { path: '/admin', element: }, ], diff --git a/apps/web/src/spa/pages/page-errors.ts b/apps/web/src/spa/pages/page-errors.ts new file mode 100644 index 00000000..fc06c6e1 --- /dev/null +++ b/apps/web/src/spa/pages/page-errors.ts @@ -0,0 +1,7 @@ +export function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return fallback; +} diff --git a/apps/web/src/spa/pages/page-fixtures.ts b/apps/web/src/spa/pages/page-fixtures.ts new file mode 100644 index 00000000..cd52be5d --- /dev/null +++ b/apps/web/src/spa/pages/page-fixtures.ts @@ -0,0 +1,115 @@ +import type { Mission, Project, Task } from '@/lib/types'; + +export const projectFixtures: Project[] = [ + { + id: 'project-1', + name: 'Mosaic Stack', + description: 'Gateway and dashboard parity work', + status: 'active', + userId: 'user-1', + metadata: { + prd: '# Mosaic Stack PRD\n\n## Objective\n\nShip the SPA route parity pages.', + }, + createdAt: '2026-08-01T12:00:00.000Z', + updatedAt: '2026-08-09T18:30:00.000Z', + }, + { + id: 'project-2', + name: 'Agent Runtime', + description: 'Pi SDK integration', + status: 'paused', + userId: 'user-1', + metadata: null, + createdAt: '2026-08-02T08:00:00.000Z', + updatedAt: '2026-08-05T10:00:00.000Z', + }, +]; + +export const missionFixtures: Mission[] = [ + { + id: 'mission-1', + name: 'Ship web parity', + description: 'Port read-only routes into the SPA', + status: 'active', + projectId: 'project-1', + metadata: null, + createdAt: '2026-08-03T12:00:00.000Z', + updatedAt: '2026-08-09T12:00:00.000Z', + }, + { + id: 'mission-2', + name: 'Unrelated mission', + description: 'Must be filtered out of the project detail view', + status: 'planning', + projectId: 'project-2', + metadata: null, + createdAt: '2026-08-04T12:00:00.000Z', + updatedAt: '2026-08-04T12:00:00.000Z', + }, +]; + +export const taskFixtures: Task[] = [ + { + id: 'task-1', + title: 'Route /projects', + description: 'Port the read-only projects listing into the SPA', + status: 'done', + priority: 'high', + projectId: 'project-1', + missionId: 'mission-1', + assignee: 'Jarvis', + tags: ['spa', 'projects'], + dueDate: '2026-08-12T00:00:00.000Z', + metadata: { + notes: 'Read-only modal content should remain intact.', + pr_links: [{ url: 'https://example.invalid/pr/1', label: 'PR #1' }], + }, + createdAt: '2026-08-04T10:00:00.000Z', + updatedAt: '2026-08-09T15:00:00.000Z', + }, + { + id: 'task-2', + title: 'Route /projects/:id', + description: 'Reuse overview, tasks, missions, and PRD tabs', + status: 'in-progress', + priority: 'critical', + projectId: 'project-1', + missionId: 'mission-1', + assignee: null, + tags: ['spa', 'detail'], + dueDate: null, + metadata: null, + createdAt: '2026-08-05T09:00:00.000Z', + updatedAt: '2026-08-10T08:00:00.000Z', + }, + { + id: 'task-3', + title: 'Route /tasks', + description: 'Wire list and kanban modal interactions', + status: 'blocked', + priority: 'medium', + projectId: 'project-1', + missionId: null, + assignee: null, + tags: ['spa', 'tasks'], + dueDate: null, + metadata: null, + createdAt: '2026-08-06T09:00:00.000Z', + updatedAt: '2026-08-08T08:00:00.000Z', + }, + { + id: 'task-4', + title: 'Other project task', + description: 'Used only to confirm mission filtering remains project-scoped', + status: 'not-started', + priority: 'low', + projectId: 'project-2', + missionId: 'mission-2', + assignee: null, + tags: null, + dueDate: null, + metadata: null, + createdAt: '2026-08-06T09:00:00.000Z', + updatedAt: '2026-08-06T09:00:00.000Z', + }, +]; diff --git a/apps/web/src/spa/pages/project-detail.spec.tsx b/apps/web/src/spa/pages/project-detail.spec.tsx new file mode 100644 index 00000000..00280ab5 --- /dev/null +++ b/apps/web/src/spa/pages/project-detail.spec.tsx @@ -0,0 +1,155 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { missionFixtures, projectFixtures, taskFixtures } from './page-fixtures'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})); + +import { ProjectDetailPage } from './project-detail'; + +let root: Root | null = null; +let container: HTMLDivElement; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + apiMock.mockReset(); +}); + +async function renderProjectDetailPage(): Promise> { + const routes: RouteObject[] = [ + { path: '/projects', element:

Projects index target

}, + { path: '/projects/:id', element: }, + ]; + const router = createMemoryRouter(routes, { initialEntries: ['/projects/project-1'] }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + return router; +} + +function clickButtonByText(text: string): void { + const button = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes(text), + ); + if (!button) { + throw new Error(`Button containing "${text}" not found`); + } + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +describe('ProjectDetailPage', () => { + it('loads the project, tasks, missions, and optional PRD content for the active project', async () => { + apiMock + .mockResolvedValueOnce(projectFixtures[0]) + .mockResolvedValueOnce(missionFixtures) + .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); + + await renderProjectDetailPage(); + + expect(apiMock.mock.calls).toEqual([ + ['/api/projects/project-1'], + ['/api/missions'], + ['/api/tasks?projectId=project-1'], + ]); + + expect(container.textContent).toContain('Mosaic Stack'); + expect(container.textContent).toContain('Route /projects/:id'); + expect(container.textContent).toContain('Tasks'); + expect(container.textContent).toContain('Done'); + expect(container.textContent).toContain('Blocked'); + + await act(async () => { + clickButtonByText('Missions (1)'); + }); + + expect(container.textContent).toContain('Ship web parity'); + expect(container.textContent).not.toContain('Unrelated mission'); + + await act(async () => { + clickButtonByText('PRD'); + }); + + expect(container.textContent).toContain('Mosaic Stack PRD'); + expect(container.textContent).toContain('Ship the SPA route parity pages.'); + }); + + it('opens and closes the existing read-only task modal from the tasks tab', async () => { + apiMock + .mockResolvedValueOnce(projectFixtures[0]) + .mockResolvedValueOnce(missionFixtures) + .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); + + await renderProjectDetailPage(); + + await act(async () => { + clickButtonByText('Tasks (3)'); + }); + + const row = [...container.querySelectorAll('tr')].find((candidate) => + candidate.textContent?.includes('Route /projects'), + ); + expect(row).toBeTruthy(); + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[role="dialog"]')).toBeTruthy(); + expect(container.textContent).toContain('Read-only modal content should remain intact.'); + + const closeButton = container.querySelector('button[aria-label="Close task details"]'); + expect(closeButton).toBeTruthy(); + + await act(async () => { + closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it('renders a visible alert when any project detail request fails and lets the user navigate back', async () => { + apiMock + .mockResolvedValueOnce(projectFixtures[0]) + .mockRejectedValueOnce(new Error('Missions request failed')) + .mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1')); + + const router = await renderProjectDetailPage(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert).toBeTruthy(); + expect(alert?.textContent).toContain('Missions request failed'); + + await act(async () => { + clickButtonByText('Back to projects'); + }); + + expect(router.state.location.pathname).toBe('/projects'); + }); +}); diff --git a/apps/web/src/spa/pages/project-detail.tsx b/apps/web/src/spa/pages/project-detail.tsx new file mode 100644 index 00000000..0f060c99 --- /dev/null +++ b/apps/web/src/spa/pages/project-detail.tsx @@ -0,0 +1,344 @@ +import { useEffect, useState, type ReactElement } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { MissionTimeline } from '@/components/projects/mission-timeline'; +import { PrdViewer } from '@/components/projects/prd-viewer'; +import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; +import { TaskListView } from '@/components/tasks/task-list-view'; +import { TaskStatusSummary } from '@/components/tasks/task-status-summary'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/cn'; +import type { Mission, Project, Task, TaskStatus } from '@/lib/types'; +import { getErrorMessage } from './page-errors'; + +type Tab = 'overview' | 'tasks' | 'missions' | 'prd'; + +const projectStatusColors: Record = { + active: 'bg-success/20 text-success', + paused: 'bg-warning/20 text-warning', + completed: 'bg-blue-600/20 text-blue-400', + archived: 'bg-gray-600/20 text-gray-400', +}; + +const taskStatusColors: Record = { + 'not-started': 'bg-gray-600/20 text-gray-300', + 'in-progress': 'bg-blue-600/20 text-blue-400', + blocked: 'bg-error/20 text-error', + done: 'bg-success/20 text-success', + cancelled: 'bg-gray-600/20 text-gray-500', +}; + +interface TabButtonProps { + id: Tab; + label: string; + activeTab: Tab; + onClick: (tab: Tab) => void; +} + +function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElement { + return ( + + ); +} + +export function ProjectDetailPage(): ReactElement { + const { id = '' } = useParams(); + const navigate = useNavigate(); + const [project, setProject] = useState(null); + const [missions, setMissions] = useState([]); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + const [taskFilter, setTaskFilter] = useState('all'); + const [selectedTask, setSelectedTask] = useState(null); + + useEffect(() => { + if (!id) { + setError('Project id is missing.'); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + void Promise.all([ + api('/api/projects/' + id), + api('/api/missions'), + api('/api/tasks?projectId=' + id), + ]) + .then(([loadedProject, allMissions, loadedTasks]) => { + if (cancelled) return; + setProject(loadedProject); + setMissions(allMissions.filter((mission) => mission.projectId === id)); + setTasks(loadedTasks); + }) + .catch((caught: unknown) => { + if (cancelled) return; + setError(getErrorMessage(caught, 'Failed to load project.')); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [id]); + + if (loading) { + return ( +
+
+

Project

+
+

Loading project...

+
+ ); + } + + if (error || !project) { + return ( +
+
+

Project

+
+
+ {error ?? 'Project not found.'} +
+ +
+ ); + } + + const filteredTasks = + taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter); + const prdContent = getPrdContent(project); + const tabs: Array<{ id: Tab; label: string }> = [ + { id: 'overview', label: 'Overview' }, + { id: 'tasks', label: `Tasks (${tasks.length})` }, + { id: 'missions', label: `Missions (${missions.length})` }, + ...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []), + ]; + + return ( +
+
+ + +
+
+
+

{project.name}

+ + {project.status} + +
+ {project.description ? ( +

{project.description}

+ ) : null} +

+ Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '} + {new Date(project.updatedAt).toLocaleDateString()} +

+
+
+
+ +
+ + task.status === 'done').length)} + valueClass="text-success" + /> + task.status === 'in-progress').length)} + valueClass="text-blue-400" + /> + task.status === 'blocked').length)} + valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined} + /> +
+ +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === 'overview' ? ( + + ) : null} + + {activeTab === 'tasks' ? ( +
+
+ +
+ +
+ ) : null} + + {activeTab === 'missions' ? : null} + + {activeTab === 'prd' && prdContent ? ( +
+ +
+ ) : null} + + {selectedTask ? ( + setSelectedTask(null)} /> + ) : null} +
+ ); +} + +function OverviewTab({ + project, + missions, + tasks, +}: { + project: Project; + missions: Mission[]; + tasks: Task[]; +}): ReactElement { + const recentTasks = [...tasks] + .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()) + .slice(0, 5); + + return ( +
+
+

Recent Tasks

+ {recentTasks.length === 0 ? ( +
+

No tasks yet

+
+ ) : ( +
+ {recentTasks.map((task) => ( +
+ {task.title} + + {task.status} + +
+ ))} +
+ )} +
+ +
+

Missions

+ {missions.length === 0 ? ( +
+

No missions yet

+
+ ) : ( + + )} +
+ + {project.metadata && Object.keys(project.metadata).length > 0 ? ( +
+

Project Metadata

+
+
+              {JSON.stringify(project.metadata, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} + +function StatCard({ + label, + value, + valueClass, +}: { + label: string; + value: string; + valueClass?: string; +}): ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function getPrdContent(project: Project): string | null { + if (!project.metadata) return null; + + const prd = project.metadata['prd']; + if (typeof prd === 'string' && prd.trim().length > 0) { + return prd; + } + + const prdContent = project.metadata['prdContent']; + if (typeof prdContent === 'string' && prdContent.trim().length > 0) { + return prdContent; + } + + return null; +} diff --git a/apps/web/src/spa/pages/projects.spec.tsx b/apps/web/src/spa/pages/projects.spec.tsx new file mode 100644 index 00000000..e030fc22 --- /dev/null +++ b/apps/web/src/spa/pages/projects.spec.tsx @@ -0,0 +1,131 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { projectFixtures } from './page-fixtures'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})); + +import { ProjectsPage } from './projects'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let root: Root | null = null; +let container: HTMLDivElement; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + apiMock.mockReset(); +}); + +async function renderProjectsPage(): Promise> { + const routes: RouteObject[] = [ + { path: '/projects', element: }, + { path: '/projects/:id', element:

Project detail target

}, + ]; + + const router = createMemoryRouter(routes, { initialEntries: ['/projects'] }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + return router; +} + +describe('ProjectsPage', () => { + it('shows a visible loading state while the project request is in flight', async () => { + const deferred = createDeferred(); + apiMock.mockReturnValueOnce(deferred.promise); + + await renderProjectsPage(); + + expect(container.textContent).toContain('Loading projects...'); + + await act(async () => { + deferred.resolve(projectFixtures); + await deferred.promise; + }); + }); + + it('renders project cards from the API and navigates to a project detail route on click', async () => { + apiMock.mockResolvedValueOnce(projectFixtures); + + const router = await renderProjectsPage(); + + expect(apiMock).toHaveBeenCalledWith('/api/projects'); + expect(container.textContent).toContain('Mosaic Stack'); + expect(container.textContent).toContain('Agent Runtime'); + + const button = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes('Mosaic Stack'), + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(router.state.location.pathname).toBe('/projects/project-1'); + expect(container.textContent).toContain('Project detail target'); + }); + + it('renders the empty state when the API returns no projects', async () => { + apiMock.mockResolvedValueOnce([]); + + await renderProjectsPage(); + + expect(container.textContent).toContain('No projects yet'); + expect(container.textContent).toContain( + 'Projects will appear here when created via the gateway API', + ); + }); + + it('renders a visible alert when the projects request fails', async () => { + apiMock.mockRejectedValueOnce(new Error('Projects are unavailable')); + + await renderProjectsPage(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert).toBeTruthy(); + expect(alert?.textContent).toContain('Projects are unavailable'); + }); +}); diff --git a/apps/web/src/spa/pages/projects.tsx b/apps/web/src/spa/pages/projects.tsx new file mode 100644 index 00000000..5ab7a746 --- /dev/null +++ b/apps/web/src/spa/pages/projects.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState, type ReactElement } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ProjectCard } from '@/components/projects/project-card'; +import { api } from '@/lib/api'; +import type { Project } from '@/lib/types'; +import { getErrorMessage } from './page-errors'; + +export function ProjectsPage(): ReactElement { + const navigate = useNavigate(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + void api('/api/projects') + .then((response) => { + if (cancelled) return; + setProjects(response); + }) + .catch((caught: unknown) => { + if (cancelled) return; + setError(getErrorMessage(caught, 'Failed to load projects.')); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

Projects

+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +

Loading projects...

+ ) : projects.length === 0 ? ( +
+

No projects yet

+

+ Projects will appear here when created via the gateway API +

+
+ ) : ( +
+ {projects.map((project) => ( + navigate(`/projects/${selectedProject.id}`)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/spa/pages/resource-route-boundaries.spec.tsx b/apps/web/src/spa/pages/resource-route-boundaries.spec.tsx new file mode 100644 index 00000000..912a2b6f --- /dev/null +++ b/apps/web/src/spa/pages/resource-route-boundaries.spec.tsx @@ -0,0 +1,86 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +const { useSessionMock } = vi.hoisted(() => ({ + useSessionMock: vi.fn(), +})); + +vi.mock('@/lib/auth-client', () => ({ + useSession: useSessionMock, +})); + +import { routes } from '@/routes'; + +function Boom(): never { + throw new Error('resource route render blew up'); +} + +function replaceRouteElementWithBoom(nodes: RouteObject[], path: string): RouteObject[] { + return nodes.map((node) => { + const cloned: RouteObject = { ...node }; + if (cloned.path === path) { + cloned.element = ; + } + if (cloned.children) { + cloned.children = replaceRouteElementWithBoom(cloned.children, path); + } + return cloned; + }); +} + +let root: Root | null = null; +let container: HTMLDivElement; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + useSessionMock.mockReset(); +}); + +describe('resource route error boundaries', () => { + it.each(['/projects', '/projects/:id', '/tasks'])( + 'renders a recoverable fallback when %s throws during route render', + async (path) => { + useSessionMock.mockReturnValue({ data: { user: { id: 'user-1' } }, isPending: false }); + + const initialEntry = path === '/projects/:id' ? '/projects/project-1' : path; + const router = createMemoryRouter(replaceRouteElementWithBoom(routes, path), { + initialEntries: [initialEntry], + }); + + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await act(async () => { + root?.render(); + }); + + expect(consoleErrorSpy).toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + } + + expect(container.textContent).not.toBe(''); + expect(container.querySelector('[role="alert"]')).toBeTruthy(); + }, + ); +}); diff --git a/apps/web/src/spa/pages/resource-route-error-boundaries.tsx b/apps/web/src/spa/pages/resource-route-error-boundaries.tsx new file mode 100644 index 00000000..f1a3b11e --- /dev/null +++ b/apps/web/src/spa/pages/resource-route-error-boundaries.tsx @@ -0,0 +1,55 @@ +import type { ReactElement } from 'react'; +import { useRouteError } from 'react-router-dom'; + +interface ResourceRouteErrorBoundaryProps { + message: string; + href: string; + linkLabel: string; +} + +function ResourceRouteErrorBoundary({ + message, + href, + linkLabel, +}: ResourceRouteErrorBoundaryProps): ReactElement { + useRouteError(); + + return ( +
+

{message}

+ + {linkLabel} + +
+ ); +} + +export function ProjectsRouteErrorBoundary(): ReactElement { + return ( + + ); +} + +export function ProjectDetailRouteErrorBoundary(): ReactElement { + return ( + + ); +} + +export function TasksRouteErrorBoundary(): ReactElement { + return ( + + ); +} diff --git a/apps/web/src/spa/pages/tasks.spec.tsx b/apps/web/src/spa/pages/tasks.spec.tsx new file mode 100644 index 00000000..4a1a77a6 --- /dev/null +++ b/apps/web/src/spa/pages/tasks.spec.tsx @@ -0,0 +1,144 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { taskFixtures } from './page-fixtures'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})); + +import { TasksPage } from './tasks'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +let root: Root | null = null; +let container: HTMLDivElement; + +beforeAll(() => { + Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + document.body.replaceChildren(); + root = null; + apiMock.mockReset(); +}); + +async function renderTasksPage(): Promise { + const routes: RouteObject[] = [{ path: '/tasks', element: }]; + const router = createMemoryRouter(routes, { initialEntries: ['/tasks'] }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); +} + +function clickButtonByText(text: string): void { + const button = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes(text), + ); + if (!button) { + throw new Error(`Button containing "${text}" not found`); + } + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); +} + +describe('TasksPage', () => { + it('shows a visible loading state before the tasks request settles', async () => { + const deferred = createDeferred(); + apiMock.mockReturnValueOnce(deferred.promise); + + await renderTasksPage(); + + expect(container.textContent).toContain('Loading tasks...'); + + await act(async () => { + deferred.resolve(taskFixtures); + await deferred.promise; + }); + }); + + it('starts in kanban view, toggles to list view, and opens the read-only modal from cards and rows', async () => { + apiMock.mockResolvedValueOnce(taskFixtures); + + await renderTasksPage(); + + expect(container.textContent).toContain('Not Started'); + expect(container.textContent).toContain('In Progress'); + expect(container.textContent).toContain('Blocked'); + + const kanbanCard = [...container.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes('Route /projects/:id'), + ); + expect(kanbanCard).toBeTruthy(); + + await act(async () => { + kanbanCard?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[role="dialog"]')).toBeTruthy(); + + await act(async () => { + container + .querySelector('button[aria-label="Close task details"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + + await act(async () => { + clickButtonByText('List'); + }); + + const row = [...container.querySelectorAll('tr')].find((candidate) => + candidate.textContent?.includes('Route /tasks'), + ); + expect(row).toBeTruthy(); + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('[role="dialog"]')).toBeTruthy(); + expect(container.textContent).toContain('Wire list and kanban modal interactions'); + }); + + it('renders a visible alert when the tasks request fails', async () => { + apiMock.mockRejectedValueOnce(new Error('Tasks request failed')); + + await renderTasksPage(); + + const alert = container.querySelector('[role="alert"]'); + expect(alert).toBeTruthy(); + expect(alert?.textContent).toContain('Tasks request failed'); + }); +}); diff --git a/apps/web/src/spa/pages/tasks.tsx b/apps/web/src/spa/pages/tasks.tsx new file mode 100644 index 00000000..b388ba95 --- /dev/null +++ b/apps/web/src/spa/pages/tasks.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState, type ReactElement } from 'react'; +import { KanbanBoard } from '@/components/tasks/kanban-board'; +import { TaskDetailModal } from '@/components/tasks/task-detail-modal'; +import { TaskListView } from '@/components/tasks/task-list-view'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/cn'; +import type { Task } from '@/lib/types'; +import { getErrorMessage } from './page-errors'; + +type ViewMode = 'list' | 'kanban'; + +export function TasksPage(): ReactElement { + const [tasks, setTasks] = useState([]); + const [view, setView] = useState('kanban'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + + useEffect(() => { + let cancelled = false; + + void api('/api/tasks') + .then((response) => { + if (cancelled) return; + setTasks(response); + }) + .catch((caught: unknown) => { + if (cancelled) return; + setError(getErrorMessage(caught, 'Failed to load tasks.')); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

Tasks

+
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +

Loading tasks...

+ ) : view === 'kanban' ? ( + + ) : ( + + )} + + {selectedTask ? ( + setSelectedTask(null)} /> + ) : null} +
+ ); +} diff --git a/apps/web/src/spa/routes.spec.tsx b/apps/web/src/spa/routes.spec.tsx index 43689ac1..018386d5 100644 --- a/apps/web/src/spa/routes.spec.tsx +++ b/apps/web/src/spa/routes.spec.tsx @@ -4,6 +4,9 @@ import type { RouteObject } from 'react-router-dom'; import { routes } from '@/routes'; import { Placeholder } from '@/spa/placeholder'; import { ChatPage } from '@/spa/pages/chat'; +import { ProjectDetailPage } from '@/spa/pages/project-detail'; +import { ProjectsPage } from '@/spa/pages/projects'; +import { TasksPage } from '@/spa/pages/tasks'; function collectPaths(routeObjects: RouteObject[]): string[] { return routeObjects.flatMap((route) => [ @@ -67,4 +70,28 @@ describe('SPA route table', () => { expect(element.type).not.toBe(Placeholder); expect(element.type).toBe(ChatPage); }); + + it.each([ + ['/projects', ProjectsPage], + ['/projects/:id', ProjectDetailPage], + ['/tasks', TasksPage], + ])( + 'renders a real authenticated page instead of the P1 placeholder at %s', + (path, expectedType) => { + const element = findRoute(routes, path)?.element; + expect(isValidElement(element)).toBe(true); + if (!isValidElement(element)) throw new Error(`Missing route element for ${path}`); + expect(element.type).not.toBe(Placeholder); + expect(element.type).toBe(expectedType); + }, + ); + + it.each(['/chat', '/projects', '/projects/:id', '/tasks'])( + 'defines an error boundary for %s', + (path) => { + const route = findRoute(routes, path); + expect(route?.errorElement).toBeTruthy(); + expect(isValidElement(route?.errorElement)).toBe(true); + }, + ); }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 73835f27..ea042157 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ }, server: { port: 3100, + strictPort: true, proxy: { '/api': gatewayTarget, '/socket.io': { target: gatewayTarget, ws: true }, diff --git a/docs/scratchpads/webui-p4-1.md b/docs/scratchpads/webui-p4-1.md new file mode 100644 index 00000000..721e9fa9 --- /dev/null +++ b/docs/scratchpads/webui-p4-1.md @@ -0,0 +1,271 @@ +# WebUI Phase P — P4-1 Projects + Tasks SPA Scratchpad + +**Task ID:** P4-1 +**Tracking ref:** Phase P RFC §6.4 / §2.4 author brief; no provider issue supplied +**Mission context:** `mvp-20260312` (active) +**Branch:** `feat/webui-p4-1` from `origin/next` +**Started:** 2026-08-11 +**Role:** orchestrator-controlled author worker; `docs/TASKS.md` remains orchestrator-only and is not part of this increment + +## Original tasking + +Implement the bounded P4-1 SPA parity slice exactly as briefed: real authenticated `/projects`, `/projects/:id`, and `/tasks` React Router pages ported from the existing Next baseline; reuse existing project/task components; preserve relative same-origin REST access; support only the existing project/task PATCH edit flows; add route error boundaries and page tests; pin Vite strict port 3100 and legacy Next dev/start to 3101; make no gateway, package, dependency, nav-shell, chat, settings, or admin changes; verify, commit, and push only `feat/webui-p4-1` (no PR or merge). If any required REST endpoint is absent, stop as `BLOCKED:` rather than inventing a workaround. + +## Objective and acceptance map + +- A: `/projects` list with loading, cards, empty, and surfaced API-error states; defer MissionStatus side panel. +- B: `/projects/:id` detail with overview/tasks/missions tabs, parallel project/mission/task load, bounded project and task PATCH edits. +- C: `/tasks` list/kanban with bounded task PATCH edits. +- D: replace the three route placeholders and add route error boundaries without changing chat. +- E: use only `@/lib/types` and `@/lib/api`; relative `/api/...` REST paths only. +- F: Vite `strictPort: true`; Next dev/start pinned to 3101; no proxy/dependency changes. +- G: fixture-backed Vitest coverage for lists, states, tabs/toggles, PATCH calls, and real route elements; all named verification gates pass. + +## Plan + +1. Verify the required gateway REST routes and inspect the exact `origin/next` SPA, Next baseline pages, shared components, types, API helper, and P3 test conventions. +2. Record the pre-change web test count and add required page/route tests first, observing expected RED failures. +3. Port the three pages and shared route error boundary, then wire routes and bounded PATCH flows. +4. Apply only the two dev-port pin changes. +5. Run focused tests, all user-required gates, lockfile and same-origin checks, and `git diff --check`. +6. Obtain independent spec/code/security review; remediate and repeat affected gates until clear. +7. Commit conventionally, run the required pre-push queue guard, push only the feature branch, and record exact evidence here. + +## Testing strategy + +TDD is applied because this adds user-visible data/edit behavior. Component tests mock only the existing API boundary and exercise rendered behavior and PATCH payloads. Primary situational evidence is the required page interaction suite plus route-resolution checks; baseline evidence is typecheck, lint, full web test, build, frozen install, formatting/diff hygiene, and same-origin grep. + +## Budget + +No explicit token cap was supplied. Working soft cap: **50K tokens**, derived from three coupled React pages, route/error wiring, interaction tests, port config, review/remediation, and full verification. One Codex implementation worker and independent review workers will be used serially to avoid worktree collisions. + +## Base evidence + +- `git fetch origin` rc=0. +- `origin/next` and branch start: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`. +- Branch created: `feat/webui-p4-1` tracking `origin/next`. +- Harness-owned `.mosaic/orchestrator/session.lock` is dirty and must remain unstaged/uncommitted. + +## Progress / evidence + +- [x] Loaded active mission manifest, latest scratchpad, top-level tasks, PRD, orchestration/delivery/frontend/QA/documentation/review/TypeScript guides, and matching implementation skills. +- [x] Confirmed exact base SHA and created the feature branch. +- [x] Required REST endpoints verified in Gateway source: project list/detail/PATCH, task list/filter/detail/PATCH, and mission list all exist. +- [x] Scope assumption check found a blocking contradiction before source implementation. +- [ ] Pre-change web test count recorded. +- [ ] RED tests observed. +- [ ] Implementation complete. +- [ ] Independent review clear. +- [x] Required verification gates run against the unchanged web baseline; exact leak-grep expectation is independently blocked by 12 pre-existing matches. +- [x] Blocker record committed as `5ede86a5` and feature branch pushed; no PR opened and no merge performed. + +## Blocker — 2026-08-11 + +`P4-1` is blocked because the bounded edit UX asserted by the brief does not exist at the confirmed `origin/next` base (`e00cc475`): + +- `apps/web/src/components/tasks/task-detail-modal.tsx` is read-only. Its props are only `task` and `onClose`; it contains no input/select/textarea, update callback, or `api()`/PATCH call. +- `apps/web/src/app/(dashboard)/projects/[id]/page.tsx` contains no project edit controls and no project/task PATCH call. +- `apps/web/src/app/(dashboard)/tasks/page.tsx` does not open `TaskDetailModal`; its click handler only logs that a future detail view will be added. +- A repository grep over those reference/component paths finds zero `method: 'PATCH'` calls and zero edit/save form controls. +- The required leak grep also disproves the brief's baseline claim that `apps/web/src` is clean: the exact command returns rc=0 with 12 pre-existing matches at `origin/next` (external font links, an avatar placeholder, an SVG namespace, and test fixtures). There are zero new matches in the worktree, but making the exact grep empty would require unrelated changes the brief forbids. + +The brief simultaneously requires tests proving task/project PATCH edits and forbids inventing edit UX, fields, or flows beyond what those references expose. Adding controls or callback contracts would therefore be new design outside the authorized port/wiring scope. Per the explicit stop rule, no SPA source, route, config, package, lockfile, or test implementation was started. + +## Verification evidence on unchanged web baseline + +These gates were still run to distinguish the brief contradiction from a broken base: + +- `pnpm --filter @mosaicstack/web typecheck` — rc=0. +- `pnpm --filter @mosaicstack/web lint` — rc=0. +- `pnpm --filter @mosaicstack/web test` — rc=0; 15 files, 133 tests passed. Before→after remains 133→133 because implementation was halted. +- `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated. +- `pnpm install --frozen-lockfile` — rc=0; lock SHA-256 remained `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`; `git diff` for the lockfile rc=0. +- `git grep -nE "http://|https://" -- apps/web/src` — rc=0 with 12 pre-existing matches; `origin/next` count=12, current HEAD count=12, new worktree additions=0. +- `pnpm format:check` — rc=0. +- `git diff --check` — rc=0. + +## Delivery evidence + +- Initial queue-guard invocation through shell `~` failed rc=127 because this Distrobox resolves `HOME` to `/home/jwoltje/distrobox-homes/mosaic-dev`, where the injected fleet status already reports the tools installation missing. +- Correct supported host-tool invocation `/home/jwoltje/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B feat/webui-p4-1` — rc=0; branch absent remotely and queue clear. +- `git push -u origin feat/webui-p4-1` — rc=0. The push hook additionally ran repository preflight, typecheck (45/45 tasks), lint (25/25 tasks), and format check successfully. +- No PR was opened and no merge was attempted, per the brief. + +## Risks / blockers + +- The active harness mutates `.mosaic/orchestrator/session.lock`; it is excluded from staging. +- The Phase P §6.4 prose is absent from this checkout, so the brief-named route spec and existing Next pages are the bounded implementation anchors. +- The MissionStatus `/api/coord/status` panel is explicitly deferred to a follow-up and must not enter P4-1. + +## REV 2 continuation — 2026-08-10T21:59:17-05:00 + +REV 2 supersedes the original tasking above. The independent check confirmed that the Next reference pages and `task-detail-modal.tsx` are read-only, so project/task editing is deliberately deferred to P4-1b. Do not re-litigate or implement the former PATCH requirements. + +### Revised objective and acceptance map + +- A: `/projects` read-only SPA list with loading, cards, empty, and surfaced API-error states; card navigation to `/projects/:id`; no MissionStatus panel. +- B: `/projects/:id` read-only SPA detail using `useParams`, `useNavigate`, and the specified three-request `Promise.all`; preserve reference overview/tasks/missions tabs and read-only task modal. +- C: `/tasks` read-only SPA list/kanban view with the existing read-only task modal. +- D: replace only the three route placeholders and add page-local route error boundaries without modifying chat. +- E: use only `@/lib/types` and `@/lib/api` with relative `/api/...` REST paths. +- F: add Vite `strictPort: true` and pin legacy Next dev/start to 3101 without dependency or proxy changes. +- G: add fixture-backed page tests and route-resolution assertions; run every user-specified verification gate and the narrowed new-code origin check. + +### Revised plan + +1. Reset `feat/webui-p4-1` to the exact `origin/next` base and independently verify the read-only reference/component/API assumptions. +2. Record the pre-change web suite count; write and run focused page/route tests first to observe expected RED failures. +3. Port the three read-only pages, add independent route error boundaries, wire routes, and apply only the two dev-port pins. +4. Run focused tests, full required web gates, frozen install/lockfile proof, origin check, format/diff hygiene, and accessibility/state-transition sanity checks. +5. Obtain independent spec/code/security review, remediate every blocker, and repeat affected gates. +6. Commit conventionally, run the supported pre-push queue guard, and push only `feat/webui-p4-1`; no PR and no merge. + +### Revised budget and session state + +- No explicit token cap was supplied. Working soft cap remains **50K tokens**. +- TDD is required by the user and frontend skill for the new SPA behavior; tests must fail for missing pages before production implementation. +- Documentation assessment: this is a parity port of existing read-only behavior, not a new public workflow or API contract; the task scratchpad is the required delivery record, with no user/developer/API documentation changes in this bounded increment. +- Exact base confirmed after fresh fetch/reset: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`. +- Remote `feat/webui-p4-1` is absent; the eventual push is a fresh branch creation. +- Harness-owned `.mosaic/orchestrator/session.lock` remains excluded from staging. + +## REV 2 implementation pass — 2026-08-11T22:05:00Z + +### Startup verification + +- Loaded required startup files: `/home/jwoltje/.config/mosaic/CONSTITUTION.md`, `/home/jwoltje/.config/mosaic/SOUL.md`, project `AGENTS.md`, `/home/jwoltje/.config/mosaic/guides/E2E-DELIVERY.md`, `docs/PRD.md`, and this scratchpad. +- Loaded required skills: `test-driven-development`, `vitest`, `vite`, `next-best-practices`, and `verification-before-completion`. +- Loaded required runtime guide: `/home/jwoltje/.config/mosaic/runtime/codex/RUNTIME.md`. +- Structured reasoning tool availability verified through the harness before planning. +- `git rev-parse HEAD` confirmed exact required base: `e00cc475a2b1e9866bd4e2f8df80aff640c3a543`. +- `git status --short` at startup showed only the expected untracked append-only scratchpad; `.mosaic/orchestrator/session.lock` did not appear and must remain unstaged if it changes later. + +### Current plan + +1. Record baseline evidence. +2. Add focused failing SPA page/route specs first. +3. Run focused RED command and record the missing-page failure. +4. Implement the bounded read-only SPA pages, boundaries, route wiring, and the two dev-port pins. +5. Run the required verification matrix and inspect diff hygiene. + +### Pre-change baseline + +- Command: `pnpm --filter @mosaicstack/web test` +- rc=0 +- File/test totals before changes: `15 files / 133 tests` +- Notes: baseline already includes `/chat` SPA route coverage and raw React `createRoot`/`act` page specs that this pass should mirror. + +### RED evidence before production implementation + +- Command: `pnpm --filter @mosaicstack/web test -- src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx` +- rc=1 +- Expected missing-feature reason confirmed: + - `src/spa/pages/projects.spec.tsx`, `src/spa/pages/project-detail.spec.tsx`, `src/spa/pages/tasks.spec.tsx`, and `src/spa/routes.spec.tsx` fail import resolution because the SPA page modules do not exist yet. + - `src/spa/pages/resource-route-boundaries.spec.tsx` fails because the current `/projects`, `/projects/:id`, and `/tasks` routes still render placeholders without route-level alert fallbacks. + +### Implementation summary + +- Added SPA pages: + - `apps/web/src/spa/pages/projects.tsx` + - `apps/web/src/spa/pages/project-detail.tsx` + - `apps/web/src/spa/pages/tasks.tsx` +- Added page-local route boundary components in `apps/web/src/spa/pages/resource-route-error-boundaries.tsx`. +- Wired `/projects`, `/projects/:id`, and `/tasks` in `apps/web/src/routes.tsx` with real elements and `errorElement`s. +- Added fixture-backed raw React Vitest coverage plus route assertions for the three pages and their boundaries. +- Applied only the requested dev topology pins: + - `apps/web/vite.config.ts`: `server.strictPort = true` + - `apps/web/package.json`: `next dev -p 3101`, `next start -p 3101` + +### Post-implementation verification + +- Focused changed specs: + - Command: `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx` + - rc=0 + - Result: `5 files / 26 tests` passed. +- `pnpm --filter @mosaicstack/web typecheck` — rc=0. +- `pnpm --filter @mosaicstack/web lint` — rc=0. +- `pnpm --filter @mosaicstack/web test` — rc=0; post-change totals `19 files / 153 tests`. +- `pnpm --filter @mosaicstack/web build` — rc=1. + - Limitation: Next/Turbopack hit a sandbox/runtime failure while processing `apps/web/src/app/globals.css`: `creating new process`, `binding to a port`, `Operation not permitted (os error 1)`. This appears environmental, not route-code-specific. +- `pnpm --filter @mosaicstack/web build:vite` — rc=0. +- `pnpm install --frozen-lockfile` — rc=1. + - Limitation: repo `prepare` hook attempted to lock `/home/jwoltje/distrobox-homes/mosaic-dev/src/stack/.git/config`, which is read-only in this harness. +- `pnpm-lock.yaml` SHA-256 before/after install attempt: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`. +- `origin/next` `pnpm-lock.yaml` SHA-256: `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332`. +- `git diff -- pnpm-lock.yaml` — rc=0 (unchanged). +- `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty, as required). +- `pnpm format:check` — rc=0. +- `git diff --check` — rc=0. + +### Final worktree check + +- `git status --short` shows only the authorized web files plus this scratchpad. +- `.mosaic/orchestrator/session.lock` remains unstaged. +- Current changed file set: + - `apps/web/package.json` + - `apps/web/src/routes.tsx` + - `apps/web/src/spa/routes.spec.tsx` + - `apps/web/vite.config.ts` + - `apps/web/src/spa/pages/page-errors.ts` + - `apps/web/src/spa/pages/page-fixtures.ts` + - `apps/web/src/spa/pages/project-detail.spec.tsx` + - `apps/web/src/spa/pages/project-detail.tsx` + - `apps/web/src/spa/pages/projects.spec.tsx` + - `apps/web/src/spa/pages/projects.tsx` + - `apps/web/src/spa/pages/resource-route-boundaries.spec.tsx` + - `apps/web/src/spa/pages/resource-route-error-boundaries.tsx` + - `apps/web/src/spa/pages/tasks.spec.tsx` + - `apps/web/src/spa/pages/tasks.tsx` + - `docs/scratchpads/webui-p4-1.md` + +## P4-1 REV 2 Final Delivery Verification — 2026-08-10T22:23:00Z + +### Spec/Security Review Verdicts (Independent Reviews) + +Both independent reviews cleared P4-1 REV 2 implementation without blockers: +- **Spec review:** approved; 0 blockers, 0 should-fix findings +- **Code/security review:** approved; 0 blockers, 0 critical/high findings, 0 should-fix recommendations + +### Final Fresh Gate Verification (This Session) + +All verification gates executed sequentially with rc=0 (except where noted): + +1. `pnpm --filter @mosaicstack/web typecheck` — rc=0 +2. `pnpm --filter @mosaicstack/web lint` — rc=0 +3. `pnpm --filter @mosaicstack/web test` — rc=0; **19 files / 153 tests** (baseline: 15 files / 133 tests; added: 4 files / 20 tests) +4. `pnpm --filter @mosaicstack/web build` — rc=0; Next production build completed, 10/10 static pages generated +5. `pnpm --filter @mosaicstack/web build:vite` — rc=0; Vite production bundle generated +6. Lockfile integrity: + - Before install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332` + - After frozen install: SHA-256 `9acaa89d213b3281e757b6edf6fdb8727176570d725b78a0de234c61a7f3c332` + - Equality: ✓ verified + - `git diff --quiet origin/next -- pnpm-lock.yaml` rc=0 ✓ +7. `pnpm format:check` — rc=0; all matched files use Prettier code style +8. `git diff --check` — rc=0; no trailing whitespace or merged conflict markers +9. New-code origin check: `git diff -- apps/web/src | grep -nE '^\+.*(fetch|io|api)\(\s*[\x27\"]https?://'` — rc=1 (empty result, as required) +10. `pnpm --filter @mosaicstack/web exec vitest run src/spa/pages/projects.spec.tsx src/spa/pages/project-detail.spec.tsx src/spa/pages/tasks.spec.tsx src/spa/pages/resource-route-boundaries.spec.tsx src/spa/routes.spec.tsx` — rc=0; 5 files / 26 tests passed + +### Scope Audit + +**Authorized in-scope changes:** +- ✓ `apps/web/package.json` — dev port pins only +- ✓ `apps/web/vite.config.ts` — strictPort flag only +- ✓ `apps/web/src/routes.tsx` — route wiring with errorElements +- ✓ `apps/web/src/spa/pages/projects.tsx` — read-only SPA list +- ✓ `apps/web/src/spa/pages/project-detail.tsx` — read-only SPA detail +- ✓ `apps/web/src/spa/pages/tasks.tsx` — read-only SPA list/kanban +- ✓ `apps/web/src/spa/pages/resource-route-error-boundaries.tsx` — page-local error components +- ✓ `apps/web/src/spa/pages/*.spec.tsx` — fixture-backed tests (4 new) +- ✓ `docs/scratchpads/webui-p4-1.md` — append-only record + +**Out-of-scope verification:** +- `.mosaic/orchestrator/session.lock` — not modified/staged ✓ +- `pnpm-lock.yaml` — not modified ✓ +- `apps/gateway/**` — not modified ✓ +- `packages/**` — not modified ✓ +- Chat SPA — not modified ✓ +- Settings, admin, navigation, or auth flow — not modified ✓ + +### Next Action + +Commit, run authorized queue guard, and push only `feat/webui-p4-1` (no PR, no merge).