From cc8096f341cf287c7f05b909f17759df02518451 Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Tue, 17 Mar 2026 09:18:13 +0100 Subject: [PATCH] Sprint 6B: governed request approval workflow UI --- .ai/active/SPRINT_PACKET.md | 217 +++++----- BUILD_REPORT.md | 202 +++++---- REVIEW_REPORT.md | 61 +-- apps/web/app/approvals/loading.tsx | 51 +++ apps/web/app/approvals/page.tsx | 219 +++------- apps/web/app/chat/page.tsx | 68 +-- apps/web/app/globals.css | 88 ++++ apps/web/app/tasks/loading.tsx | 65 +++ apps/web/app/tasks/page.tsx | 354 ++++----------- apps/web/components/approval-actions.test.tsx | 136 ++++++ apps/web/components/approval-actions.tsx | 136 ++++++ apps/web/components/approval-detail.tsx | 150 +++++++ apps/web/components/approval-list.tsx | 203 ++------- apps/web/components/request-composer.tsx | 406 ++++++++++++------ apps/web/components/status-badge.tsx | 17 +- apps/web/components/task-list.tsx | 37 +- apps/web/components/task-step-list.tsx | 85 ++-- apps/web/components/task-summary.tsx | 89 ++++ apps/web/lib/api.test.ts | 157 +++++++ apps/web/lib/api.ts | 348 +++++++++++++++ apps/web/lib/fixtures.ts | 390 +++++++++++++++++ apps/web/package.json | 10 +- apps/web/test/setup.ts | 1 + apps/web/vitest.config.ts | 12 + 24 files changed, 2391 insertions(+), 1111 deletions(-) create mode 100644 apps/web/app/approvals/loading.tsx create mode 100644 apps/web/app/tasks/loading.tsx create mode 100644 apps/web/components/approval-actions.test.tsx create mode 100644 apps/web/components/approval-actions.tsx create mode 100644 apps/web/components/approval-detail.tsx create mode 100644 apps/web/components/task-summary.tsx create mode 100644 apps/web/lib/api.test.ts create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/fixtures.ts create mode 100644 apps/web/test/setup.ts create mode 100644 apps/web/vitest.config.ts diff --git a/.ai/active/SPRINT_PACKET.md b/.ai/active/SPRINT_PACKET.md index d9a1d32..91a174b 100644 --- a/.ai/active/SPRINT_PACKET.md +++ b/.ai/active/SPRINT_PACKET.md @@ -2,7 +2,7 @@ ## Sprint Title -Sprint 6A: MVP Web Shell and Core Operator Views +Sprint 6B: Governed Request Submission and Actionable Approval Workflow ## Sprint Type @@ -10,196 +10,179 @@ ui ## Sprint Reason -Sprint 5U is implemented and the project is on track, but the largest remaining MVP gap is no longer Gmail auth hardening. The backend now has enough governed capability to support a real human-usable shell, and `DESIGN_SYSTEM.md` exists as a design truth source. To avoid looping on narrow connector internals while the product remains invisible, the next sprint should open the thinnest serious web surface for the shipped backend seams. +Sprint 6A delivered the first real AliceBot web shell, but it is still mostly a review surface. The highest-value next MVP gap is not another Gmail or docs sprint; it is turning the shell into a usable governed workflow surface. The backend already ships approval request creation, approval resolution, and task/task-step reads, so the next safe step is to wire those existing seams into the web UI without expanding backend scope. ## Sprint Intent -Build the first real web shell for AliceBot on top of the existing backend by adding a calm, high-trust operator interface for navigation, request submission, approvals review, task and task-step inspection, and explain-why/traces viewing, without expanding backend product scope. +Extend the web shell so the operator can submit governed requests, review live approval details, approve or reject them from the UI, and inspect the resulting task/task-step state, using existing backend endpoints only. ## Git Instructions -- Branch Name: `codex/sprint-6a-mvp-web-shell` +- Branch Name: `codex/sprint-6b-governed-request-approval-ui` - Base Branch: `main` - PR Strategy: one sprint branch, one PR, no stacked PRs unless Control Tower explicitly opens a follow-up sprint - Merge Policy: squash merge only after reviewer `PASS` and explicit Control Tower merge approval ## Why This Sprint -- The accepted repo state through Sprint 5T already ships the core backend substrate: - - governed context compilation - - approvals and execution review - - tasks and task steps - - explainable traces - - rooted workspaces, artifacts, document ingestion, and Gmail-backed artifacts -- `DESIGN_SYSTEM.md` now exists and should be treated as a source of truth alongside `ARCHITECTURE.md`. -- The product brief requires web-based chat and task orchestration, plus explain-why visibility. -- Continuing with another Gmail-only sprint right now would optimize one backend seam while the MVP still lacks a usable interface. -- The narrowest safe UI slice is the shell plus core operator views only, not full workflow completion or new backend scope. +- Sprint 6A proved the web shell can render a calm, high-trust operator interface aligned to `DESIGN_SYSTEM.md`. +- The current shell still leaves the key governed workflow mostly inert: + - requests are not yet clearly submitted through the approval-request seam + - approvals are inspectable but not actionable from the UI + - tasks can be viewed but not as the immediate downstream result of a governed request +- The product brief requires web-based chat and task orchestration with explicit approval for consequential actions. +- The narrowest safe next step is to wire the existing approval and task seams into the shell, not to invent new backend features. ## Design Truth -- `DESIGN_SYSTEM.md` is in force for this sprint. -- The UI must follow its calm, premium, restrained visual language. -- This sprint must preserve strong hierarchy, readable density, stable navigation, and explicit containment rules. -- Do not introduce playful AI styling, loud gradients, decorative clutter, or unstable layout behavior. +- `DESIGN_SYSTEM.md` remains in force for this sprint. +- The UI must keep the calm, premium, restrained operator feel established in Sprint 6A. +- Interaction states must feel stable and explicit: + - pending + - submitting + - approved + - rejected + - loading + - empty +- Avoid noisy notification patterns or consumer-chat styling. ## Exact Screens In Scope -- `/` - - app shell landing view - - primary navigation - - summary cards for the existing backend seams - `/chat` - - request composer surface - - recent request/response panel - - explicit “governed request” framing, not a consumer chat skin + - governed request composer + - request mode focused on the shipped approval-request seam + - resulting request summary state - `/approvals` - - approval inbox list - - approval detail panel or inline inspector + - live approval inbox list + - approval detail inspector + - approve action + - reject action - `/tasks` - - task list - - task detail panel - - task-step list/inspection area -- `/traces` - - trace or explain-why review view for context compile and governed actions + - live task list + - selected task summary + - live task-step inspection + - visible linkage from approval outcome to task state when available ## Exact Components In Scope -- app shell frame - - top bar - - left navigation rail or sidebar - - main content container -- shared primitives - - page header - - section card - - metric card - - status badge - - empty state - - list row - - split-panel or inspector layout -- domain views - - request composer - - approval list - - approval detail inspector - - task list - - task detail summary - - task-step timeline or ordered list - - trace event list - - explainability summary panel +- existing shared shell primitives from Sprint 6A, refined as needed +- governed request form +- approval detail inspector +- approval action bar +- task summary panel +- task-step status list +- inline success/error state messaging for approval actions +- empty/loading state variants for live workflow screens ## Exact Files In Scope - `DESIGN_SYSTEM.md` - reference only; do not rewrite unless the sprint reveals a concrete contradiction that must be corrected -- `apps/web/app/layout.tsx` -- `apps/web/app/page.tsx` - `apps/web/app/chat/page.tsx` - `apps/web/app/approvals/page.tsx` - `apps/web/app/tasks/page.tsx` -- `apps/web/app/traces/page.tsx` - `apps/web/app/globals.css` -- `apps/web/components/app-shell.tsx` -- `apps/web/components/page-header.tsx` -- `apps/web/components/section-card.tsx` -- `apps/web/components/status-badge.tsx` -- `apps/web/components/empty-state.tsx` - `apps/web/components/request-composer.tsx` - `apps/web/components/approval-list.tsx` - `apps/web/components/task-list.tsx` - `apps/web/components/task-step-list.tsx` -- `apps/web/components/trace-list.tsx` +- `apps/web/components/section-card.tsx` +- `apps/web/components/status-badge.tsx` +- `apps/web/components/empty-state.tsx` +- `apps/web/components/approval-actions.tsx` +- `apps/web/components/approval-detail.tsx` +- `apps/web/components/task-summary.tsx` +- `apps/web/lib/api.ts` +- `apps/web/lib/fixtures.ts` - `apps/web/package.json` ## In Scope -- Replace the current placeholder Next.js landing page with a real app shell aligned to `DESIGN_SYSTEM.md`. -- Add the exact screens and components listed above. -- Use only existing backend concepts already shipped in the repo: - - context compilation - - approvals - - tasks and task steps - - traces or explain-why data -- Implement a thin frontend data layer only as needed to render those views. -- If live API wiring is used, it must consume existing endpoints only. -- If mocked or fixture-backed UI data is used for part of the shell, keep the mock layer explicit and local to the web app. -- Ensure the shell is usable on desktop and mobile widths. +- Wire `/chat` to the existing governed request path using shipped backend behavior only. +- Support governed request submission through existing endpoints such as: + - `POST /v0/approvals/requests` +- Support live approval review using existing endpoints such as: + - `GET /v0/approvals` + - `GET /v0/approvals/{approval_id}` if already available through the current web layer pattern + - `POST /v0/approvals/{approval_id}/approve` + - `POST /v0/approvals/{approval_id}/reject` +- Support live task and task-step review using existing endpoints such as: + - `GET /v0/tasks` + - `GET /v0/tasks/{task_id}` + - `GET /v0/tasks/{task_id}/steps` +- Keep fixture-backed fallback behavior explicit when API configuration is absent. +- Keep all workflow state user-scoped and visibly deterministic. ## Out of Scope - No new backend endpoints. - No backend schema changes. -- No Gmail search or broader connector work. -- No Calendar connector UI. -- No write-capable action UI beyond the existing governed-request framing. -- No authentication/product auth redesign. -- No full end-to-end magnesium reorder workflow yet. -- No design-system rewrite. +- No approval execution from the web UI yet. +- No task-step mutations from the web UI beyond review. +- No general trace-event listing backend work. +- No Gmail breadth, Calendar UI, or connector expansion. +- No auth redesign. +- No full magnesium reorder workflow yet. - No runner-style orchestration UI. ## Required Deliverables -- A real Next.js app shell aligned to `DESIGN_SYSTEM.md`. -- The exact screens listed in this packet, implemented in the exact in-scope file set or a narrower subset of it. -- Stable layout, navigation, responsive behavior, and readable empty states. -- A thin request submission surface for the governed request path. -- Approval, task, task-step, and trace/explain-why review views. +- A governed request submission surface in `/chat`. +- Actionable approve and reject controls in `/approvals`. +- Live task and task-step inspection that reflects governed-request outcomes in `/tasks`. +- Stable loading, empty, success, and failure states for those workflow surfaces. - Updated `BUILD_REPORT.md` with exact verification results and explicit deferred scope. ## Acceptance Criteria -- The web app no longer renders only the current placeholder foundation card at `/`. -- The app exposes the exact in-scope screens: - - `/` - - `/chat` - - `/approvals` - - `/tasks` - - `/traces` -- The UI visibly follows `DESIGN_SYSTEM.md` and feels calm, premium, and high-trust rather than demo-like. -- Text remains contained within cards and panels across responsive breakpoints. -- Navigation is stable and the current location is obvious. -- The UI uses only existing backend concepts and does not widen product scope. -- `pnpm build` or `npm run build` for `apps/web` passes. -- Any added frontend tests or lint checks pass if introduced. +- `/chat` can submit a governed request through an existing shipped backend path when API configuration is present. +- `/approvals` can approve and reject approvals through existing shipped backend endpoints when API configuration is present. +- `/tasks` reflects live task/task-step state from existing shipped endpoints when API configuration is present. +- When API configuration is absent, the UI falls back to explicit fixture-backed or empty states instead of broken behavior. +- The sprint stays within the exact in-scope screens, components, and files listed above. +- The UI continues to follow `DESIGN_SYSTEM.md` materially. +- `npm run build` in `apps/web` passes. +- Any added checks pass if introduced. ## Implementation Constraints - Keep the sprint narrow and boring. -- Treat this as the first operator shell, not the finished product. -- Prefer a few strong views over too many half-finished surfaces. -- Reuse existing backend seams; do not invent placeholder product capabilities that the backend does not support. -- Keep interaction restrained and calm. -- Preserve mobile usability and text containment. -- If any API wiring is unstable, degrade to explicit empty, loading, or fixture-backed states instead of inventing hidden backend changes. +- Reuse existing backend seams only; do not hide backend feature requests inside the UI sprint. +- Keep the governed-request flow explicit and reviewable, not magical. +- Preserve the visual calm and text containment established in Sprint 6A. +- If an endpoint or payload is not stable enough for live use, degrade cleanly to an explicit unavailable state rather than inventing new backend logic. ## Suggested Work Breakdown -1. Replace the placeholder root page with a real shell and shared layout primitives. -2. Add the in-scope routes and core shared components. -3. Implement the request, approvals, tasks, and traces views using existing backend concepts only. -4. Apply responsive layout and empty-state handling. -5. Run frontend build and any introduced checks. -6. Update `BUILD_REPORT.md` with executed verification. +1. Refactor the current shell data layer into a clearer shared API helper plus fixtures. +2. Wire governed request submission into `/chat`. +3. Add approval detail plus approve/reject actions in `/approvals`. +4. Strengthen `/tasks` to show the downstream task/task-step state from the governed workflow. +5. Add deterministic loading, success, error, and empty states. +6. Run frontend build and any introduced checks. +7. Update `BUILD_REPORT.md` with executed verification. ## Build Report Requirements `BUILD_REPORT.md` must include: -- the exact screens and components implemented +- the exact screens and components implemented or updated - the exact files changed -- whether each screen is live-API-backed, fixture-backed, or mixed +- which routes are live-API-backed, fixture-backed, or mixed +- the exact shipped backend endpoints consumed by the UI - exact commands run - build and test results -- screenshots or concise visual verification notes for desktop and mobile behavior -- what remains intentionally deferred after this UI sprint +- concise desktop and mobile visual verification notes +- what remains intentionally deferred after this sprint ## Review Focus `REVIEW_REPORT.md` should verify: - the sprint stayed a UI sprint and did not widen backend product scope +- the governed request, approval, and task workflow uses only existing shipped backend seams - `DESIGN_SYSTEM.md` was followed materially -- the exact in-scope screens, components, and files were respected -- layout quality, text containment, hierarchy, and responsive behavior are acceptable -- no hidden Gmail breadth, Calendar, runner, or auth-scope expansion entered the sprint +- loading, empty, success, and error states are stable and readable +- no hidden Gmail breadth, Calendar, runner, auth-scope, or backend-scope expansion entered the sprint ## Exit Condition -This sprint is complete when AliceBot has a real web shell with stable navigation plus the first operator-facing views for requests, approvals, tasks, task steps, and traces, aligned to `DESIGN_SYSTEM.md`, built on existing backend concepts only, and verified by frontend build checks. +This sprint is complete when the AliceBot web shell can submit governed requests, resolve approvals through approve/reject UI actions, and reflect resulting task/task-step state through the existing backend seams, while staying within the current UI and backend boundaries. diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index e71226f..8d1d0a6 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -2,117 +2,155 @@ ## sprint objective -Implement Sprint 6A: replace the placeholder web app with the first real AliceBot operator shell for governed requests, approvals, tasks, task steps, and explain-why review, aligned to `DESIGN_SYSTEM.md` and bounded to existing backend seams only. +Implement Sprint 6B: turn the web shell into a governed workflow surface that can submit approval requests, resolve approvals from the UI, and inspect downstream task and task-step state using existing shipped backend seams only. -## exact screens implemented +## completed work -- `/` - - app shell landing view - - summary metrics - - primary navigation entry cards - `/chat` - - governed request composer - - recent request/response history - - trace reference display + - replaced the old response-oriented composer with governed approval-request submission + - added explicit request fields for `thread_id`, `tool_id`, `action`, `scope`, optional hints, and JSON attributes + - added resulting request summary cards with decision, approval linkage, task linkage, and trace references - `/approvals` - - approval inbox list - - approval detail inspector + - kept the live inbox list + - added a dedicated approval detail inspector component + - added actionable approve and reject controls backed by existing resolution endpoints + - added an explicit route-level `loading.tsx` boundary for slow live reads + - added inline action-state messaging for ready, submitting, success, and failure states - `/tasks` - - task list - - selected task summary - - task-step inspection list -- `/traces` - - explainability trace list - - trace detail review panel - -## exact shared components implemented - -- `app-shell` -- `page-header` -- `section-card` -- `status-badge` -- `empty-state` -- `request-composer` -- `approval-list` -- `task-list` -- `task-step-list` -- `trace-list` - -## exact files changed + - kept the live task list + - added a dedicated task summary panel + - added live task detail reads through `GET /v0/tasks/{task_id}` + - added an explicit route-level `loading.tsx` boundary for slow live reads + - strengthened task-step inspection with sequencing metadata and approval linkage +- shared web layer + - created `apps/web/lib/api.ts` for shared types, config, endpoint helpers, and live-mode detection + - created `apps/web/lib/fixtures.ts` for explicit fixture-backed fallback data + - added new shared components: + - `approval-actions` + - `approval-detail` + - `task-summary` + - added a minimal Vitest-based web test setup with unit coverage for the API helper layer and a UI flow boundary test for approval resolution + - updated styling in `apps/web/app/globals.css` for governed form layout, action bars, responsive field grids, and richer workflow state presentation + +## incomplete work + +- no approval execution from the web UI +- no task-step mutations from the web UI beyond inspection +- no live request-history read endpoint in `/chat`; the screen submits live requests but keeps local summary history plus explicit fixture fallback +- no Gmail, Calendar, runner, auth, or backend-scope expansion + +## exact screens and components implemented or updated + +- screens +- `/chat` +- `/approvals` +- `/tasks` +- `/approvals/loading` +- `/tasks/loading` +- components + - `request-composer` + - `approval-list` + - `approval-actions` + - `approval-detail` + - `task-list` + - `task-summary` + - `task-step-list` + - `status-badge` + +## files changed - `BUILD_REPORT.md` -- `apps/web/app/layout.tsx` -- `apps/web/app/page.tsx` - `apps/web/app/chat/page.tsx` - `apps/web/app/approvals/page.tsx` +- `apps/web/app/approvals/loading.tsx` - `apps/web/app/tasks/page.tsx` -- `apps/web/app/traces/page.tsx` +- `apps/web/app/tasks/loading.tsx` - `apps/web/app/globals.css` -- `apps/web/components/app-shell.tsx` -- `apps/web/components/page-header.tsx` -- `apps/web/components/section-card.tsx` -- `apps/web/components/status-badge.tsx` -- `apps/web/components/empty-state.tsx` - `apps/web/components/request-composer.tsx` - `apps/web/components/approval-list.tsx` +- `apps/web/components/approval-actions.tsx` +- `apps/web/components/approval-detail.tsx` - `apps/web/components/task-list.tsx` +- `apps/web/components/task-summary.tsx` - `apps/web/components/task-step-list.tsx` -- `apps/web/components/trace-list.tsx` +- `apps/web/components/status-badge.tsx` +- `apps/web/lib/api.ts` +- `apps/web/lib/api.test.ts` +- `apps/web/lib/fixtures.ts` +- `apps/web/components/approval-actions.test.tsx` +- `apps/web/test/setup.ts` +- `apps/web/vitest.config.ts` +- `apps/web/package.json` -## data-backing by screen +## route backing -- `/`: fixture/static shell content - `/chat`: mixed - - live API when `NEXT_PUBLIC_ALICEBOT_API_BASE_URL` or `ALICEBOT_API_BASE_URL` plus user/thread ids are present - - explicit local fixtures otherwise + - live submission when `apiBaseUrl` and `userId` are configured + - explicit fixture preview and local summary fallback otherwise - `/approvals`: mixed - - live `GET /v0/approvals` when API base URL and user id are present - - explicit local fixtures otherwise + - live inbox, live detail, and live approve/reject when configured + - explicit fixture fallback for list and detail when live data is unavailable + - explicit route-level loading UI while server reads are in flight - `/tasks`: mixed - - live `GET /v0/tasks` and `GET /v0/tasks/{task_id}/steps` when API base URL and user id are present - - explicit local fixtures otherwise -- `/traces`: fixture-backed - - trace summaries and detail events are local fixtures because the repo does not currently expose a general trace-event listing endpoint in the shipped web scope + - live task list, live task detail, and live task-step reads when configured + - explicit fixture fallback for list, detail, and steps when live data is unavailable + - explicit route-level loading UI while server reads are in flight + +## shipped backend endpoints consumed + +- `POST /v0/approvals/requests` +- `GET /v0/approvals` +- `GET /v0/approvals/{approval_id}` +- `POST /v0/approvals/{approval_id}/approve` +- `POST /v0/approvals/{approval_id}/reject` +- `GET /v0/tasks` +- `GET /v0/tasks/{task_id}` +- `GET /v0/tasks/{task_id}/steps` ## commands run -- `npm install` in `apps/web` +- `npm install --no-package-lock` in `apps/web` +- `npm run test` in `apps/web` +- `npm run build` in `apps/web` + +## tests run + +- `npm run test` in `apps/web` - `npm run build` in `apps/web` ## build and test results -- `npm run build` in `apps/web`: PASS -- Production build output included the intended routes: +- `npm run test`: PASS +- production build completed successfully +- generated routes included: - `/` - `/chat` - `/approvals` - `/tasks` - `/traces` -- `npm run lint`: not run - - current project setup prompts interactively for ESLint initialization instead of providing a stable non-interactive check - -## visual verification notes - -- Desktop behavior: - - left navigation rail remains persistent - - top bar and page headers hold a clear hierarchy without crowding - - approvals, tasks, and traces use split review layouts with bounded inspector panels -- Mobile and narrow-width behavior: - - sidebar collapses into a horizontal mobile navigation row - - grids stack to single-column layouts - - cards, pills, IDs, and attributes use wrapping and containment rules to avoid overflow or clipping -- Screenshots: - - no browser screenshots captured in this sprint report - -## blockers or issues encountered - -- Running `npm run build` caused Next.js to rewrite `apps/web/tsconfig.json` and `apps/web/next-env.d.ts` locally; those generated changes were reverted to keep the sprint diff inside the packet’s scoped file list. -- `npm install` generated `apps/web/package-lock.json`; that file was removed from the final diff for the same reason. - -## deferred scope after this sprint - -- live trace-event listing and deep explainability wiring beyond the current fixture-backed `/traces` screen -- approvals mutation actions from the web UI -- task-step mutations from the web UI -- authentication redesign -- Gmail breadth, Calendar connector UI, runner UI, or any new backend endpoints +- automated coverage now includes: + - API helper request and error-envelope behavior + - approval action-bar UI flow and route refresh behavior + +## concise visual verification notes + +- desktop: split review layouts remain intact for approvals and tasks, the governed request form still uses bounded two-column field groupings, and route-level loading cards now preserve the same shell hierarchy during slow reads +- mobile: the existing responsive breakpoints still collapse the sidebar layouts to one column, and the new loading cards and field groups stack cleanly at narrow widths +- browser screenshots were not captured; these notes are from code-path and responsive-layout verification only + +## blockers/issues + +- `next build` rewrote `apps/web/tsconfig.json` and `apps/web/next-env.d.ts` during type checking; those generated edits were manually reverted to keep the final diff inside the sprint packet’s scoped file list +- `npm run lint` is still not a usable project check because `next lint` drops into interactive ESLint setup instead of running repo-defined rules + +## recommended next step + +Open a follow-up UI sprint only if you want to reduce operator friction around request submission, most likely by introducing a bounded live selector for known thread and tool IDs through already-shipped seams or explicitly approved new scope. + +## intentionally deferred after this sprint + +- approval execution UI +- task-step mutation UI +- general trace-event listing work +- connector expansion +- auth redesign diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md index 69a6218..961b314 100644 --- a/REVIEW_REPORT.md +++ b/REVIEW_REPORT.md @@ -6,33 +6,19 @@ PASS ## criteria met -- The web app no longer renders the placeholder landing view at `/`; it now provides a real operator shell with stable navigation and bounded overview content. -- The exact in-scope routes are present and implemented: - - `/` - - `/chat` - - `/approvals` - - `/tasks` - - `/traces` -- The shared component surface required by the sprint exists and is used across the app shell and route views: - - `app-shell` - - `page-header` - - `section-card` - - `status-badge` - - `empty-state` - - `request-composer` - - `approval-list` - - `task-list` - - `task-step-list` - - `trace-list` -- The UI materially follows `DESIGN_SYSTEM.md`: restrained palette, calm hierarchy, consistent card treatment, stable navigation state, and responsive stacking are all present in the shipped shell. -- The sprint stayed within existing backend concepts and did not widen backend scope. Live web wiring uses only existing shipped endpoints: - - `POST /v0/responses` - - `GET /v0/approvals` - - `GET /v0/tasks` - - `GET /v0/tasks/{task_id}/steps` -- The fixture content was narrowed back into supplement/ecommerce examples, so the earlier Calendar-scope concern is no longer present. -- `BUILD_REPORT.md` now matches Sprint 6A and includes the required screens, shared components, exact files changed, data-backing mode by route, commands run, build results, visual verification notes, and deferred scope. -- Review verification: +- `/chat` now submits governed requests through the existing approval-request seam and keeps fixture fallback explicit when live API config is absent. +- `/approvals` now uses only shipped approval endpoints for list, detail, approve, and reject flows. +- `/tasks` now uses only shipped task endpoints for list, detail, and task-step inspection, and it exposes approval linkage in the task view. +- `/approvals` and `/tasks` now have explicit route-level loading boundaries that preserve the shell layout during slow live reads. +- Automated coverage now exists for the new web workflow layer: + - API helper request/error behavior + - approval action-bar resolution flow and route refresh behavior +- The sprint stayed a UI sprint. No backend endpoint, schema, auth, Gmail, Calendar, runner, or execution-scope expansion was introduced. +- The new files added are within the Sprint 6B in-scope component and helper surface. +- The UI still materially follows `DESIGN_SYSTEM.md`: restrained palette, bounded cards, stable split layouts, readable chips/badges, and clean mobile stacking are preserved. +- `BUILD_REPORT.md` now matches the implemented state, including the loading boundaries, added tests, and actual commands run. +- Verification run for review: + - `npm run test` in `apps/web`: PASS - `npm run build` in `apps/web`: PASS ## criteria missed @@ -41,32 +27,31 @@ PASS ## quality issues -- No blocking implementation issues found in the Sprint 6A UI surface. -- Minor non-blocking issue: `next build` still rewrites `apps/web/tsconfig.json` and `apps/web/next-env.d.ts` during the build. The builder documented and reverted that churn, so it no longer widens the final sprint diff, but it remains a workspace cleanliness annoyance. -- Minor non-blocking issue: `npm run lint` is still not a stable non-interactive check because Next prompts for ESLint initialization instead of using a committed lint config. +- No blocking implementation issues found in the Sprint 6B UI slice. +- Residual non-blocking issue: `npm run lint` is still not a usable repo check because `next lint` drops into interactive ESLint setup. +- Residual non-blocking issue: `next build` still rewrites `apps/web/tsconfig.json` and `apps/web/next-env.d.ts` during verification unless those generated changes are adopted permanently. ## regression risks - Low. -- The main residual risk is operational rather than functional: future reviewers or CI may see local config churn from Next build autoconfiguration unless the web workspace eventually adopts the generated TypeScript settings deliberately. -- The `/traces` route remains fixture-backed by design, so operators should not infer live trace listing coverage beyond what `BUILD_REPORT.md` describes. +- The main residual risk is tooling churn rather than workflow correctness: future builds may continue to touch Next TypeScript config files until the repo either accepts or intentionally normalizes those generated settings. +- Test coverage is now present but still narrow, so additional UI boundaries could regress without broader component or route coverage. ## docs issues - No blocking docs issues remain. -- `BUILD_REPORT.md` now satisfies the packet’s reporting requirements. +- `BUILD_REPORT.md` now reflects the actual loading-state and test coverage added in the follow-up. ## should anything be added to RULES.md? - No required rules change. -- Optional future rule only: if the team wants stricter workspace cleanliness, add a rule that generated framework config churn discovered during build must either be committed intentionally or explicitly documented and reverted before handoff. +- Optional future rule only: require a committed non-interactive lint setup for frontend workspaces before treating `lint` as a standard verification step. ## should anything update ARCHITECTURE.md? -- No. -- The sprint stayed within the documented backend seams and did not reveal an architecture contradiction. +- No. This sprint stayed inside the existing documented backend seams and did not reveal an architecture contradiction. ## recommended next action -- Accept Sprint 6A. -- If the team wants a cleaner frontend workflow next, open a narrow follow-up to stabilize lint setup and decide whether the Next-generated TypeScript config changes should be adopted permanently or continue to be reverted. +- Accept Sprint 6B. +- If the team wants a cleanup follow-up, stabilize the web lint/config story so `npm run lint` becomes a real non-interactive check and `next build` stops creating local config churn. diff --git a/apps/web/app/approvals/loading.tsx b/apps/web/app/approvals/loading.tsx new file mode 100644 index 0000000..2a777b6 --- /dev/null +++ b/apps/web/app/approvals/loading.tsx @@ -0,0 +1,51 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( +
+ + Loading route state +
+ } + /> + +
+ +
+ +
+
+
+
+ + + +
+ +
+
+
+
+
+ +
+
+ ); +} diff --git a/apps/web/app/approvals/page.tsx b/apps/web/app/approvals/page.tsx index f6b7db6..6e650f4 100644 --- a/apps/web/app/approvals/page.tsx +++ b/apps/web/app/approvals/page.tsx @@ -1,163 +1,16 @@ -import { ApprovalList, type ApprovalItem } from "../../components/approval-list"; +import { ApprovalDetail } from "../../components/approval-detail"; +import { ApprovalList } from "../../components/approval-list"; import { PageHeader } from "../../components/page-header"; - -const approvalFixtures: ApprovalItem[] = [ - { - id: "approval-101", - thread_id: "thread-magnesium", - task_step_id: "step-21", - status: "pending", - request: { - thread_id: "thread-magnesium", - tool_id: "tool-purchase", - action: "place_order", - scope: "supplements", - domain_hint: "ecommerce", - risk_hint: "purchase", - attributes: { - merchant: "Thorne", - item: "Magnesium Bisglycinate", - quantity: "1", - budget_note: "Prefer previously approved merchant and package size.", - }, - }, - tool: { - id: "tool-purchase", - tool_key: "merchant_proxy", - name: "Merchant Proxy", - description: "Proxy for governed ecommerce actions.", - version: "0.1.0", - metadata_version: "tool_metadata_v0", - active: true, - tags: ["commerce", "approval"], - action_hints: ["place_order"], - scope_hints: ["supplements"], - domain_hints: ["ecommerce"], - risk_hints: ["purchase"], - metadata: {}, - created_at: "2026-03-15T08:00:00Z", - }, - routing: { - decision: "require_approval", - reasons: [ - { - code: "policy_effect_require_approval", - source: "policy", - message: "Purchases require explicit user approval before execution.", - tool_id: "tool-purchase", - policy_id: "policy-purchase-approval", - consent_key: null, - }, - { - code: "tool_metadata_matched", - source: "tool", - message: "Merchant proxy supports the requested purchase scope.", - tool_id: "tool-purchase", - policy_id: null, - consent_key: null, - }, - ], - trace: { - trace_id: "trace-approval-101", - trace_event_count: 6, - }, - }, - created_at: "2026-03-17T06:50:00Z", - resolution: null, - }, - { - id: "approval-100", - thread_id: "thread-vitamin-d", - task_step_id: "step-14", - status: "approved", - request: { - thread_id: "thread-vitamin-d", - tool_id: "tool-purchase", - action: "place_order", - scope: "supplements", - domain_hint: "ecommerce", - risk_hint: "purchase", - attributes: { - merchant: "Fullscript", - item: "Vitamin D3 + K2", - quantity: "1", - note: "Matched prior merchant and approved dosage plan.", - }, - }, - tool: { - id: "tool-purchase", - tool_key: "merchant_proxy", - name: "Merchant Proxy", - description: "Proxy for governed ecommerce actions.", - version: "0.1.0", - metadata_version: "tool_metadata_v0", - active: true, - tags: ["commerce", "approval"], - action_hints: ["place_order"], - scope_hints: ["supplements"], - domain_hints: ["ecommerce"], - risk_hints: ["purchase"], - metadata: {}, - created_at: "2026-03-14T09:15:00Z", - }, - routing: { - decision: "require_approval", - reasons: [ - { - code: "matched_policy", - source: "policy", - message: "Repeat supplement purchases remain approval-gated even when the merchant and dosage are known.", - tool_id: "tool-purchase", - policy_id: "policy-purchase-approval", - consent_key: null, - }, - ], - trace: { - trace_id: "trace-approval-100", - trace_event_count: 5, - }, - }, - created_at: "2026-03-16T14:10:00Z", - resolution: { - resolved_at: "2026-03-16T14:22:00Z", - resolved_by_user_id: "operator-1", - }, - }, -]; - -function getApiConfig() { - return { - apiBaseUrl: - process.env.NEXT_PUBLIC_ALICEBOT_API_BASE_URL ?? process.env.ALICEBOT_API_BASE_URL ?? "", - userId: process.env.NEXT_PUBLIC_ALICEBOT_USER_ID ?? process.env.ALICEBOT_USER_ID ?? "", - }; -} - -async function loadApprovals(): Promise<{ items: ApprovalItem[]; source: "live" | "fixture" }> { - const { apiBaseUrl, userId } = getApiConfig(); - if (!apiBaseUrl || !userId) { - return { items: approvalFixtures, source: "fixture" }; - } - - try { - const response = await fetch( - `${apiBaseUrl.replace(/\/$/, "")}/v0/approvals?user_id=${encodeURIComponent(userId)}`, - { cache: "no-store" }, - ); - - if (!response.ok) { - throw new Error("approval list request failed"); - } - - const payload = (await response.json()) as { items?: ApprovalItem[] }; - return { - items: payload.items ?? approvalFixtures, - source: "live", - }; - } catch { - return { items: approvalFixtures, source: "fixture" }; - } -} +import { + combinePageModes, + getApiConfig, + getApprovalDetail, + hasLiveApiConfig, + listApprovals, + pageModeLabel, + type ApiSource, +} from "../../lib/api"; +import { approvalFixtures, getFixtureApproval } from "../../lib/fixtures"; type SearchParams = Promise>; @@ -171,23 +24,63 @@ export default async function ApprovalsPage({ string | string[] | undefined >; const selectedId = typeof params.approval === "string" ? params.approval : undefined; - const { items, source } = await loadApprovals(); + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let items = approvalFixtures; + let listSource: ApiSource = "fixture"; + + if (liveModeReady) { + try { + const payload = await listApprovals(apiConfig.apiBaseUrl, apiConfig.userId); + items = payload.items; + listSource = "live"; + } catch { + items = approvalFixtures; + listSource = "fixture"; + } + } + + const selected = items.find((item) => item.id === selectedId) ?? items[0] ?? null; + let detail = selected; + let detailSource: ApiSource = selected ? listSource : "fixture"; + + if (selected && liveModeReady && listSource === "live") { + try { + const payload = await getApprovalDetail(apiConfig.apiBaseUrl, selected.id, apiConfig.userId); + detail = payload.approval; + detailSource = "live"; + } catch { + detail = getFixtureApproval(selected.id) ?? selected; + detailSource = detail === selected ? "live" : "fixture"; + } + } + + const pageMode = combinePageModes(listSource, detail ? detailSource : null); return (
- {source === "live" ? "Live API" : "Fixture-backed"} + {pageModeLabel(pageMode)} {items.length} items
} /> - +
+ + +
); } diff --git a/apps/web/app/chat/page.tsx b/apps/web/app/chat/page.tsx index 6dc68a6..b81b950 100644 --- a/apps/web/app/chat/page.tsx +++ b/apps/web/app/chat/page.tsx @@ -1,53 +1,29 @@ import { PageHeader } from "../../components/page-header"; -import { RequestComposer, type RequestHistoryEntry } from "../../components/request-composer"; +import { RequestComposer } from "../../components/request-composer"; import { SectionCard } from "../../components/section-card"; - -const initialEntries: RequestHistoryEntry[] = [ - { - id: "req-001", - request: "Summarize the open magnesium reorder task and tell me whether an approval is still required.", - response: - "The current task remains in a governed state. The latest task step is waiting on approval resolution before any execution can proceed, and the next operator action is to review the approval inbox rather than trigger another tool call.", - submittedAt: "2026-03-17T08:45:00Z", - source: "fixture", - trace: { - compileTraceId: "trace-ctx-401", - compileTraceEventCount: 9, - responseTraceId: "trace-resp-402", - responseTraceEventCount: 4, - }, - }, -]; - -function getApiConfig() { - return { - apiBaseUrl: - process.env.NEXT_PUBLIC_ALICEBOT_API_BASE_URL ?? process.env.ALICEBOT_API_BASE_URL ?? "", - userId: process.env.NEXT_PUBLIC_ALICEBOT_USER_ID ?? process.env.ALICEBOT_USER_ID ?? "", - threadId: process.env.NEXT_PUBLIC_ALICEBOT_THREAD_ID ?? process.env.ALICEBOT_THREAD_ID ?? "", - }; -} +import { getApiConfig, hasLiveApiConfig } from "../../lib/api"; +import { requestHistoryFixtures } from "../../lib/fixtures"; export default function ChatPage() { const apiConfig = getApiConfig(); - const liveModeReady = Boolean(apiConfig.apiBaseUrl && apiConfig.userId && apiConfig.threadId); + const liveModeReady = hasLiveApiConfig(apiConfig); return (
- {liveModeReady ? "Live API mode" : "Fixture mode"} - Response traces visible + {liveModeReady ? "Live submission enabled" : "Fixture preview mode"} + Approval-request seam only
} />
- +
    -
  • Requests are framed as operator instructions against existing governed seams.
  • -
  • Live mode posts to the shipped response endpoint only when API configuration is present.
  • -
  • Trace references stay attached to each recent response so explainability remains first-class.
  • +
  • Requests are submitted directly to `POST /v0/approvals/requests` using shipped payload fields only.
  • +
  • The operator supplies thread and tool identifiers explicitly instead of relying on hidden web-side routing.
  • +
  • Every resulting summary keeps decision, approval linkage, task status, and trace references visible.
-
Sessions
-
Up to 8 recent sessions
+
Required
+
Thread ID, tool ID, action, scope
-
Events
-
Up to 80 continuity events
+
Optional
+
Domain hint, risk hint
-
Memories
-
Up to 20 admitted memories
+
Attributes
+
JSON object sent unchanged to the backend
-
Entities
-
Up to 12 entities and 20 edges
+
Fallback
+
Fixture preview instead of broken live submission
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2905c41..e59da83 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -583,6 +583,12 @@ code, color: var(--text); } +.button-secondary--danger { + color: var(--danger); + border-color: rgba(141, 68, 64, 0.16); + background: rgba(141, 68, 64, 0.06); +} + .composer-card { display: grid; gap: 24px; @@ -624,6 +630,15 @@ code, gap: 10px; } +.form-field-group { + display: grid; + gap: 16px; +} + +.form-field-group--two-up { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .form-field label { font-size: 0.9rem; font-weight: 600; @@ -661,6 +676,10 @@ code, } .composer-status { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; color: var(--text-soft); font-size: 0.9rem; } @@ -725,6 +744,13 @@ code, gap: 10px; } +.history-entry__state-row, +.approval-action-bar__buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + .split-layout { display: grid; gap: 24px; @@ -799,6 +825,13 @@ code, font-size: 0.82rem; } +.inline-link { + color: var(--accent); + text-decoration: underline; + text-decoration-color: rgba(39, 75, 99, 0.28); + text-underline-offset: 0.16em; +} + .detail-grid { display: grid; gap: 18px; @@ -845,6 +878,47 @@ code, gap: 8px; } +.approval-action-bar, +.approval-action-bar__summary { + display: grid; + gap: 14px; +} + +.loading-card { + min-height: 100%; +} + +.loading-placeholder { + border-radius: 16px; + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.56) 0%, + rgba(255, 255, 255, 0.86) 50%, + rgba(255, 255, 255, 0.56) 100% + ); + background-size: 220% 100%; + animation: loading-sheen 1.4s ease-in-out infinite; + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.loading-placeholder--card { + min-height: 108px; +} + +.loading-placeholder--line { + height: 18px; +} + +.loading-placeholder--wide { + width: 82%; +} + +.loading-placeholder--button { + width: 180px; + height: 44px; +} + .trace-events { margin: 0; padding: 0; @@ -928,4 +1002,18 @@ code, .key-value-grid { grid-template-columns: 1fr; } + + .form-field-group--two-up { + grid-template-columns: 1fr; + } +} + +@keyframes loading-sheen { + 0% { + background-position: 100% 50%; + } + + 100% { + background-position: 0% 50%; + } } diff --git a/apps/web/app/tasks/loading.tsx b/apps/web/app/tasks/loading.tsx new file mode 100644 index 0000000..a872d17 --- /dev/null +++ b/apps/web/app/tasks/loading.tsx @@ -0,0 +1,65 @@ +import { PageHeader } from "../../components/page-header"; +import { SectionCard } from "../../components/section-card"; +import { StatusBadge } from "../../components/status-badge"; + +export default function Loading() { + return ( +
+ + Loading route state +
+ } + /> + +
+ +
+ +
+
+
+
+ + +
+ +
+ +
+
+
+
+ + + +
+ +
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx index 637aee5..7badedb 100644 --- a/apps/web/app/tasks/page.tsx +++ b/apps/web/app/tasks/page.tsx @@ -1,235 +1,23 @@ import { PageHeader } from "../../components/page-header"; -import { SectionCard } from "../../components/section-card"; -import { StatusBadge } from "../../components/status-badge"; -import { TaskList, type TaskItem } from "../../components/task-list"; -import { TaskStepList, type TaskStepItem } from "../../components/task-step-list"; - -const taskFixtures: TaskItem[] = [ - { - id: "task-201", - thread_id: "thread-magnesium", - tool_id: "tool-purchase", - status: "pending_approval", - request: { - thread_id: "thread-magnesium", - tool_id: "tool-purchase", - action: "place_order", - scope: "supplements", - domain_hint: "ecommerce", - risk_hint: "purchase", - attributes: { - merchant: "Thorne", - item: "Magnesium Bisglycinate", - }, - }, - tool: { - id: "tool-purchase", - tool_key: "merchant_proxy", - name: "Merchant Proxy", - description: "Proxy for governed ecommerce actions.", - version: "0.1.0", - metadata_version: "tool_metadata_v0", - active: true, - tags: ["commerce", "approval"], - action_hints: ["place_order"], - scope_hints: ["supplements"], - domain_hints: ["ecommerce"], - risk_hints: ["purchase"], - metadata: {}, - created_at: "2026-03-15T08:00:00Z", - }, - latest_approval_id: "approval-101", - latest_execution_id: null, - created_at: "2026-03-17T06:49:00Z", - updated_at: "2026-03-17T06:50:00Z", - }, - { - id: "task-182", - thread_id: "thread-vitamin-d", - tool_id: "tool-purchase", - status: "approved", - request: { - thread_id: "thread-vitamin-d", - tool_id: "tool-purchase", - action: "place_order", - scope: "supplements", - domain_hint: "ecommerce", - risk_hint: "purchase", - attributes: { - merchant: "Fullscript", - item: "Vitamin D3 + K2", - }, - }, - tool: { - id: "tool-purchase", - tool_key: "merchant_proxy", - name: "Merchant Proxy", - description: "Proxy for governed ecommerce actions.", - version: "0.1.0", - metadata_version: "tool_metadata_v0", - active: true, - tags: ["commerce", "approval"], - action_hints: ["place_order"], - scope_hints: ["supplements"], - domain_hints: ["ecommerce"], - risk_hints: ["purchase"], - metadata: {}, - created_at: "2026-03-14T09:15:00Z", - }, - latest_approval_id: "approval-100", - latest_execution_id: null, - created_at: "2026-03-16T14:00:00Z", - updated_at: "2026-03-16T14:22:00Z", - }, -]; - -const stepFixtures: Record = { - "task-201": [ - { - id: "step-20", - task_id: "task-201", - sequence_no: 1, - kind: "governed_request", - status: "created", - request: { - thread_id: "thread-magnesium", - tool_id: "tool-purchase", - action: "place_order", - scope: "supplements", - domain_hint: "ecommerce", - risk_hint: "purchase", - attributes: { - merchant: "Thorne", - item: "Magnesium Bisglycinate", - package: "90 capsules", - }, - }, - outcome: { - routing_decision: "require_approval", - approval_id: "approval-101", - approval_status: "pending", - execution_id: null, - execution_status: null, - blocked_reason: null, - }, - lineage: { - parent_step_id: null, - source_approval_id: null, - source_execution_id: null, - }, - trace: { - trace_id: "trace-step-20", - trace_kind: "approval_request", - }, - created_at: "2026-03-17T06:49:00Z", - updated_at: "2026-03-17T06:50:00Z", - }, - ], - "task-182": [ - { - id: "step-14", - task_id: "task-182", - sequence_no: 1, - kind: "governed_request", - status: "approved", - request: { - thread_id: "thread-vitamin-d", - tool_id: "tool-purchase", - action: "place_order", - scope: "supplements", - domain_hint: "ecommerce", - risk_hint: "purchase", - attributes: { - merchant: "Fullscript", - item: "Vitamin D3 + K2", - quantity: "1", - }, - }, - outcome: { - routing_decision: "require_approval", - approval_id: "approval-100", - approval_status: "approved", - execution_id: null, - execution_status: null, - blocked_reason: null, - }, - lineage: { - parent_step_id: null, - source_approval_id: null, - source_execution_id: null, - }, - trace: { - trace_id: "trace-step-14", - trace_kind: "approval_resolution", - }, - created_at: "2026-03-16T14:00:00Z", - updated_at: "2026-03-16T14:22:00Z", - }, - ], -}; - -function getApiConfig() { - return { - apiBaseUrl: - process.env.NEXT_PUBLIC_ALICEBOT_API_BASE_URL ?? process.env.ALICEBOT_API_BASE_URL ?? "", - userId: process.env.NEXT_PUBLIC_ALICEBOT_USER_ID ?? process.env.ALICEBOT_USER_ID ?? "", - }; -} - -async function loadTasks(): Promise<{ items: TaskItem[]; source: "live" | "fixture" }> { - const { apiBaseUrl, userId } = getApiConfig(); - if (!apiBaseUrl || !userId) { - return { items: taskFixtures, source: "fixture" }; - } - - try { - const response = await fetch( - `${apiBaseUrl.replace(/\/$/, "")}/v0/tasks?user_id=${encodeURIComponent(userId)}`, - { cache: "no-store" }, - ); - - if (!response.ok) { - throw new Error("task list request failed"); - } - - const payload = (await response.json()) as { items?: TaskItem[] }; - return { - items: payload.items ?? taskFixtures, - source: "live", - }; - } catch { - return { items: taskFixtures, source: "fixture" }; - } -} - -async function loadTaskSteps( - taskId: string, - source: "live" | "fixture", -): Promise<{ items: TaskStepItem[]; source: "live" | "fixture" }> { - if (source === "fixture") { - return { items: stepFixtures[taskId] ?? [], source: "fixture" }; - } - - const { apiBaseUrl, userId } = getApiConfig(); - try { - const response = await fetch( - `${apiBaseUrl.replace(/\/$/, "")}/v0/tasks/${taskId}/steps?user_id=${encodeURIComponent(userId)}`, - { cache: "no-store" }, - ); - - if (!response.ok) { - throw new Error("task step request failed"); - } - - const payload = (await response.json()) as { items?: TaskStepItem[] }; - return { - items: payload.items ?? stepFixtures[taskId] ?? [], - source: "live", - }; - } catch { - return { items: stepFixtures[taskId] ?? [], source: "fixture" }; - } -} +import { TaskList } from "../../components/task-list"; +import { TaskStepList } from "../../components/task-step-list"; +import { TaskSummary } from "../../components/task-summary"; +import { + combinePageModes, + getApiConfig, + getTaskDetail, + getTaskSteps, + hasLiveApiConfig, + listTasks, + pageModeLabel, + type ApiSource, +} from "../../lib/api"; +import { + getFixtureTask, + getFixtureTaskStepSummary, + getFixtureTaskSteps, + taskFixtures, +} from "../../lib/fixtures"; type SearchParams = Promise>; @@ -243,21 +31,65 @@ export default async function TasksPage({ string | string[] | undefined >; const requestedTaskId = typeof params.task === "string" ? params.task : undefined; - const { items, source } = await loadTasks(); - const selectedTask = items.find((item) => item.id === requestedTaskId) ?? items[0] ?? null; - const { items: steps, source: stepSource } = selectedTask - ? await loadTaskSteps(selectedTask.id, source) - : { items: [], source }; + const apiConfig = getApiConfig(); + const liveModeReady = hasLiveApiConfig(apiConfig); + + let items = taskFixtures; + let listSource: ApiSource = "fixture"; + if (liveModeReady) { + try { + const payload = await listTasks(apiConfig.apiBaseUrl, apiConfig.userId); + items = payload.items; + listSource = "live"; + } catch { + items = taskFixtures; + listSource = "fixture"; + } + } + + const selectedFromList = items.find((item) => item.id === requestedTaskId) ?? items[0] ?? null; + let selectedTask = selectedFromList; + let taskSource: ApiSource = selectedTask ? listSource : "fixture"; + + if (selectedFromList && liveModeReady && listSource === "live") { + try { + const payload = await getTaskDetail(apiConfig.apiBaseUrl, selectedFromList.id, apiConfig.userId); + selectedTask = payload.task; + taskSource = "live"; + } catch { + selectedTask = getFixtureTask(selectedFromList.id) ?? selectedFromList; + taskSource = selectedTask === selectedFromList ? "live" : "fixture"; + } + } + + let steps = selectedTask ? getFixtureTaskSteps(selectedTask.id) : []; + let stepSummary = selectedTask ? getFixtureTaskStepSummary(selectedTask.id) : null; + let stepSource: ApiSource = selectedTask ? "fixture" : listSource; + + if (selectedTask && liveModeReady && taskSource === "live") { + try { + const payload = await getTaskSteps(apiConfig.apiBaseUrl, selectedTask.id, apiConfig.userId); + steps = payload.items; + stepSummary = payload.summary; + stepSource = "live"; + } catch { + steps = getFixtureTaskSteps(selectedTask.id); + stepSummary = getFixtureTaskStepSummary(selectedTask.id); + stepSource = "fixture"; + } + } + + const pageMode = combinePageModes(listSource, selectedTask ? taskSource : null, selectedTask ? stepSource : null); return (
- {source === "live" ? "Live API" : "Fixture-backed"} + {pageModeLabel(pageMode)} {items.length} tasks
} @@ -267,50 +99,8 @@ export default async function TasksPage({
- - {selectedTask ? ( -
-
- - - {selectedTask.request.action} / {selectedTask.request.scope} - -
-
-
-
Thread
-
{selectedTask.thread_id}
-
-
-
Latest approval
-
{selectedTask.latest_approval_id ?? "Not linked"}
-
-
-
Latest execution
-
{selectedTask.latest_execution_id ?? "Not executed"}
-
-
-
Data source
-
{stepSource === "live" ? "Live task-step API" : "Local fixture steps"}
-
-
-
- ) : ( -

- No task records are available in the current mode. -

- )} -
- - + +
diff --git a/apps/web/components/approval-actions.test.tsx b/apps/web/components/approval-actions.test.tsx new file mode 100644 index 0000000..bce80a8 --- /dev/null +++ b/apps/web/components/approval-actions.test.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApprovalActions } from "./approval-actions"; + +const { refreshMock, resolveApprovalMock } = vi.hoisted(() => ({ + refreshMock: vi.fn(), + resolveApprovalMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: refreshMock, + }), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + resolveApproval: resolveApprovalMock, + }; +}); + +const pendingApproval = { + id: "approval-1", + thread_id: "thread-1", + task_step_id: "step-1", + status: "pending", + request: { + thread_id: "thread-1", + tool_id: "tool-1", + action: "place_order", + scope: "supplements", + domain_hint: "ecommerce", + risk_hint: "purchase", + attributes: { + quantity: "1", + }, + }, + tool: { + id: "tool-1", + tool_key: "merchant_proxy", + name: "Merchant Proxy", + description: "Proxy", + version: "0.1.0", + metadata_version: "tool_metadata_v0", + active: true, + tags: [], + action_hints: [], + scope_hints: [], + domain_hints: [], + risk_hints: [], + metadata: {}, + created_at: "2026-03-17T00:00:00Z", + }, + routing: { + decision: "require_approval", + reasons: [], + trace: { + trace_id: "trace-1", + trace_event_count: 3, + }, + }, + created_at: "2026-03-17T00:00:00Z", + resolution: null, +}; + +describe("ApprovalActions", () => { + beforeEach(() => { + refreshMock.mockReset(); + resolveApprovalMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("disables live actions in fixture mode", () => { + render(); + + expect(screen.getByRole("button", { name: "Approve" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Reject" })).toBeDisabled(); + expect(screen.getByText(/disabled in fixture mode/i)).toBeInTheDocument(); + }); + + it("submits approval resolution and refreshes the route on success", async () => { + const onResolved = vi.fn(); + resolveApprovalMock.mockResolvedValue({ + approval: { + ...pendingApproval, + status: "approved", + resolution: { + resolved_at: "2026-03-17T01:00:00Z", + resolved_by_user_id: "user-1", + }, + }, + trace: { + trace_id: "trace-2", + trace_event_count: 4, + }, + }); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + + await waitFor(() => { + expect(resolveApprovalMock).toHaveBeenCalledWith( + "https://api.example.com", + "approval-1", + "approve", + "user-1", + ); + }); + + await waitFor(() => { + expect(onResolved).toHaveBeenCalledWith( + expect.objectContaining({ + status: "approved", + }), + ); + }); + + expect(refreshMock).toHaveBeenCalledTimes(1); + expect(screen.getByText(/Approval resolved as approved/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/approval-actions.tsx b/apps/web/components/approval-actions.tsx new file mode 100644 index 0000000..611d956 --- /dev/null +++ b/apps/web/components/approval-actions.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { useRouter } from "next/navigation"; + +import type { ApprovalItem } from "../lib/api"; +import { resolveApproval } from "../lib/api"; +import { StatusBadge } from "./status-badge"; + +type ApprovalActionsProps = { + approval: ApprovalItem; + apiBaseUrl?: string; + userId?: string; + onResolved: (approval: ApprovalItem) => void; +}; + +type FeedbackState = { + tone: "info" | "success" | "danger"; + message: string; +}; + +function actionAvailabilityMessage(liveModeReady: boolean, approval: ApprovalItem) { + if (!liveModeReady) { + return "Approve and reject controls are disabled in fixture mode."; + } + + if (approval.status !== "pending") { + return "This approval has already been resolved. The action bar is now read-only."; + } + + return "Choose approve or reject to resolve the approval through the shipped backend seam."; +} + +export function ApprovalActions({ + approval, + apiBaseUrl, + userId, + onResolved, +}: ApprovalActionsProps) { + const router = useRouter(); + const [feedback, setFeedback] = useState({ + tone: "info", + message: actionAvailabilityMessage(Boolean(apiBaseUrl && userId), approval), + }); + const [pendingAction, setPendingAction] = useState<"approve" | "reject" | null>(null); + + const liveModeReady = Boolean(apiBaseUrl && userId); + const actionLocked = !liveModeReady || approval.status !== "pending" || pendingAction !== null; + + useEffect(() => { + setFeedback({ + tone: "info", + message: actionAvailabilityMessage(liveModeReady, approval), + }); + setPendingAction(null); + }, [approval.id, liveModeReady]); + + async function handleResolve(action: "approve" | "reject") { + if (!apiBaseUrl || !userId) { + return; + } + + setPendingAction(action); + setFeedback({ + tone: "info", + message: action === "approve" ? "Submitting approval resolution..." : "Submitting rejection resolution...", + }); + + try { + const payload = await resolveApproval(apiBaseUrl, approval.id, action, userId); + onResolved(payload.approval); + setFeedback({ + tone: "success", + message: + action === "approve" + ? "Approval resolved as approved. The inbox and downstream task view have been refreshed." + : "Approval resolved as rejected. The inbox and downstream task view have been refreshed.", + }); + router.refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : "Resolution failed"; + setFeedback({ + tone: "danger", + message, + }); + } finally { + setPendingAction(null); + } + } + + return ( +
+
+ +

{feedback.message}

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/components/approval-detail.tsx b/apps/web/components/approval-detail.tsx new file mode 100644 index 0000000..2c933b7 --- /dev/null +++ b/apps/web/components/approval-detail.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import type { ApprovalItem, ApiSource } from "../lib/api"; +import { EmptyState } from "./empty-state"; +import { SectionCard } from "./section-card"; +import { StatusBadge } from "./status-badge"; +import { ApprovalActions } from "./approval-actions"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatAttributeValue(value: unknown) { + if (value == null) { + return "None"; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + return JSON.stringify(value); +} + +type ApprovalDetailProps = { + initialApproval: ApprovalItem | null; + detailSource: ApiSource; + apiBaseUrl?: string; + userId?: string; +}; + +export function ApprovalDetail({ + initialApproval, + detailSource, + apiBaseUrl, + userId, +}: ApprovalDetailProps) { + const [approval, setApproval] = useState(initialApproval); + + useEffect(() => { + setApproval(initialApproval); + }, [initialApproval]); + + if (!approval) { + return ( + + + + ); + } + + return ( + +
+
+ + + {approval.request.action} / {approval.request.scope} + +
+ +
+
+
Thread
+
{approval.thread_id}
+
+
+
Task step
+
{approval.task_step_id ?? "Unlinked"}
+
+
+
Routing decision
+
{approval.routing.decision}
+
+
+
Detail source
+
{detailSource === "live" ? "Live approval detail" : "Fixture detail fallback"}
+
+
+
Routing trace
+
+ {approval.routing.trace.trace_id} · {approval.routing.trace.trace_event_count} events +
+
+
+
Created
+
{formatDate(approval.created_at)}
+
+
+ +
+

Request attributes

+
+ {Object.entries(approval.request.attributes).map(([key, value]) => ( + + {key}: {formatAttributeValue(value)} + + ))} +
+
+ +
+

Routing rationale

+
    + {approval.routing.reasons.map((reason) => ( +
  • {reason.message}
  • + ))} +
+
+ +
+

Resolution

+

+ {approval.resolution + ? `Resolved ${formatDate(approval.resolution.resolved_at)} by ${approval.resolution.resolved_by_user_id}.` + : "Still awaiting explicit operator resolution."} +

+
+ +
+

Approval action bar

+ +
+
+
+ ); +} diff --git a/apps/web/components/approval-list.tsx b/apps/web/components/approval-list.tsx index ec9eea9..9f57221 100644 --- a/apps/web/components/approval-list.tsx +++ b/apps/web/components/approval-list.tsx @@ -1,61 +1,10 @@ import Link from "next/link"; +import type { ApprovalItem } from "../lib/api"; import { EmptyState } from "./empty-state"; import { SectionCard } from "./section-card"; import { StatusBadge } from "./status-badge"; -export type ApprovalItem = { - id: string; - thread_id: string; - task_step_id: string | null; - status: string; - request: { - thread_id: string; - tool_id: string; - action: string; - scope: string; - domain_hint: string | null; - risk_hint: string | null; - attributes: Record; - }; - tool: { - id: string; - tool_key: string; - name: string; - description: string; - version: string; - metadata_version: string; - active: boolean; - tags: string[]; - action_hints: string[]; - scope_hints: string[]; - domain_hints: string[]; - risk_hints: string[]; - metadata: Record; - created_at: string; - }; - routing: { - decision: string; - reasons: Array<{ - code: string; - source: string; - message: string; - tool_id: string | null; - policy_id: string | null; - consent_key: string | null; - }>; - trace: { - trace_id: string; - trace_event_count: number; - }; - }; - created_at: string; - resolution: { - resolved_at: string; - resolved_by_user_id: string; - } | null; -}; - function formatDate(value: string) { return new Intl.DateTimeFormat("en", { month: "short", @@ -65,18 +14,6 @@ function formatDate(value: string) { }).format(new Date(value)); } -function formatAttributeValue(value: unknown) { - if (value == null) { - return "None"; - } - - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return String(value); - } - - return JSON.stringify(value); -} - export function ApprovalList({ items, selectedId, @@ -101,112 +38,42 @@ export function ApprovalList({ ); } - const selected = items.find((item) => item.id === selectedId) ?? items[0]; - return ( -
- -
-
-

{items.length} total approvals

-
-
- {items.map((item) => ( - -
-
- {formatDate(item.created_at)} -

{item.tool.name}

-
- -
-

- {item.request.action} / {item.request.scope} -

-
- Thread {item.thread_id} - {item.request.risk_hint ? Risk {item.request.risk_hint} : null} -
- - ))} -
+ +
+
+

{items.length} total approvals

- - - -
-
- - - {selected.request.action} / {selected.request.scope} - -
- -
-
-
Thread
-
{selected.thread_id}
-
-
-
Task step
-
{selected.task_step_id ?? "Unlinked"}
-
-
-
Routing decision
-
{selected.routing.decision}
-
-
-
Trace
-
- {selected.routing.trace.trace_id} · {selected.routing.trace.trace_event_count} events -
-
-
- -
-

Request attributes

-
- {Object.entries(selected.request.attributes).map(([key, value]) => ( - - {key}: {formatAttributeValue(value)} - - ))} -
-
- -
-

Routing rationale

-
    - {selected.routing.reasons.map((reason) => ( -
  • - {reason.message} -
  • - ))} -
-
- -
-

Resolution

-

- {selected.resolution - ? `Resolved ${formatDate(selected.resolution.resolved_at)} by ${selected.resolution.resolved_by_user_id}.` - : "Still awaiting explicit operator resolution."} -

-
+
+ {items.map((item) => ( + +
+
+ {formatDate(item.created_at)} +

{item.tool.name}

+
+ +
+

+ {item.request.action} / {item.request.scope} +

+
+ Thread {item.thread_id} + {item.task_step_id ? Step linked : null} + {item.request.risk_hint ? Risk {item.request.risk_hint} : null} +
+ + ))}
- -
+
+
); } diff --git a/apps/web/components/request-composer.tsx b/apps/web/components/request-composer.tsx index c1d0b97..a42e9e1 100644 --- a/apps/web/components/request-composer.tsx +++ b/apps/web/components/request-composer.tsx @@ -1,40 +1,21 @@ "use client"; +import Link from "next/link"; import type { FormEvent } from "react"; -import { useState, useTransition } from "react"; - -export type RequestHistoryEntry = { - id: string; - request: string; - response: string; - submittedAt: string; - source: "live" | "fixture"; - trace?: { - compileTraceId: string; - compileTraceEventCount: number; - responseTraceId: string; - responseTraceEventCount: number; - }; -}; +import { useState } from "react"; + +import type { ApprovalRequestPayload, RequestHistoryEntry } from "../lib/api"; +import { submitApprovalRequest } from "../lib/api"; +import { buildFixtureRequestEntry } from "../lib/fixtures"; +import { EmptyState } from "./empty-state"; +import { StatusBadge } from "./status-badge"; type RequestComposerProps = { initialEntries: RequestHistoryEntry[]; apiBaseUrl?: string; userId?: string; - threadId?: string; -}; - -type LiveResponsePayload = { - assistant: { - event_id: string; - text: string; - }; - trace: { - compile_trace_id: string; - compile_trace_event_count: number; - response_trace_id: string; - response_trace_event_count: number; - }; + defaultThreadId?: string; + defaultToolId?: string; }; function formatDate(value: string) { @@ -46,105 +27,134 @@ function formatDate(value: string) { }).format(new Date(value)); } -function buildFixtureEntry(message: string): RequestHistoryEntry { - const excerpt = message.trim().slice(0, 120); - const requestLabel = excerpt.length > 0 ? excerpt : "Operator request"; - const nonce = Date.now().toString(36); - - return { - id: `fixture-${nonce}`, - request: requestLabel, - response: - `Prepared a governed response preview for "${requestLabel}". In live mode this surface returns assistant output together with compile and response trace references from the backend.`, - submittedAt: new Date().toISOString(), - source: "fixture", - trace: { - compileTraceId: `trace-ctx-${nonce}`, - compileTraceEventCount: 5, - responseTraceId: `trace-resp-${nonce}`, - responseTraceEventCount: 3, - }, - }; -} - export function RequestComposer({ initialEntries, apiBaseUrl, userId, - threadId, + defaultThreadId, + defaultToolId, }: RequestComposerProps) { - const [message, setMessage] = useState(""); + const [threadId, setThreadId] = useState(defaultThreadId ?? ""); + const [toolId, setToolId] = useState(defaultToolId ?? ""); + const [action, setAction] = useState("place_order"); + const [scope, setScope] = useState("supplements"); + const [domainHint, setDomainHint] = useState("ecommerce"); + const [riskHint, setRiskHint] = useState("purchase"); + const [attributesText, setAttributesText] = useState( + JSON.stringify( + { + merchant: "Thorne", + item: "Magnesium Bisglycinate", + quantity: "1", + }, + null, + 2, + ), + ); const [entries, setEntries] = useState(initialEntries); - const [statusText, setStatusText] = useState("Ready for a governed operator request."); - const [isPending, startTransition] = useTransition(); + const [statusText, setStatusText] = useState("Ready to submit a governed approval request."); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [isSubmitting, setIsSubmitting] = useState(false); - const liveModeReady = Boolean(apiBaseUrl && userId && threadId); + const liveModeReady = Boolean(apiBaseUrl && userId); async function handleSubmit(event: FormEvent) { event.preventDefault(); - const nextMessage = message.trim(); - if (!nextMessage) { + const nextThreadId = threadId.trim(); + const nextToolId = toolId.trim(); + const nextAction = action.trim(); + const nextScope = scope.trim(); + + if (!nextThreadId || !nextToolId || !nextAction || !nextScope) { + setStatusTone("danger"); + setStatusText("Thread ID, tool ID, action, and scope are all required."); + return; + } + + let attributes: Record; + try { + const parsed = JSON.parse(attributesText); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Attributes must be a JSON object."); + } + attributes = parsed as Record; + } catch (error) { + setStatusTone("danger"); + setStatusText(error instanceof Error ? error.message : "Attributes JSON is invalid."); return; } - setStatusText(liveModeReady ? "Submitting request to the response endpoint..." : "Saving fixture-backed preview..."); + const payload: ApprovalRequestPayload = { + user_id: userId ?? "fixture-user", + thread_id: nextThreadId, + tool_id: nextToolId, + action: nextAction, + scope: nextScope, + domain_hint: domainHint.trim() || null, + risk_hint: riskHint.trim() || null, + attributes, + }; + + setStatusTone("info"); + setStatusText( + liveModeReady + ? "Submitting governed request through the approval-request endpoint..." + : "Preparing a fixture-backed governed request preview...", + ); + setIsSubmitting(true); if (!liveModeReady) { - const entry = buildFixtureEntry(nextMessage); - startTransition(() => { - setEntries((current) => [entry, ...current]); - setMessage(""); - setStatusText("Fixture response added. Configure the web API env vars to switch this view into live mode."); - }); + const entry = buildFixtureRequestEntry(payload); + setEntries((current) => [entry, ...current]); + setStatusTone("success"); + setStatusText( + "Fixture request summary added. Configure the web API base URL and user ID to persist live approvals and tasks.", + ); + setIsSubmitting(false); return; } try { - const response = await fetch(`${apiBaseUrl?.replace(/\/$/, "")}/v0/responses`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - user_id: userId, - thread_id: threadId, - message: nextMessage, - max_sessions: 8, - max_events: 80, - max_memories: 20, - max_entities: 12, - max_entity_edges: 20, - }), - }); - - const payload = (await response.json()) as LiveResponsePayload | { detail?: string }; - if (!response.ok || !("assistant" in payload)) { - throw new Error("detail" in payload && payload.detail ? payload.detail : "Request failed"); - } - + const response = await submitApprovalRequest(apiBaseUrl!, payload); const entry: RequestHistoryEntry = { - id: payload.assistant.event_id, - request: nextMessage, - response: payload.assistant.text, + id: response.trace.trace_id, submittedAt: new Date().toISOString(), source: "live", + threadId: response.request.thread_id, + toolId: response.request.tool_id, + toolName: response.tool.name, + action: response.request.action, + scope: response.request.scope, + domainHint: response.request.domain_hint, + riskHint: response.request.risk_hint, + attributes: response.request.attributes, + decision: response.decision, + taskId: response.task.id, + taskStatus: response.task.status, + approvalId: response.approval?.id ?? null, + approvalStatus: response.approval?.status ?? null, + summary: response.approval + ? "The request persisted an approval and downstream task state through the shipped governed workflow." + : "The request was routed without a persisted approval record and still returned downstream task state.", + reasons: response.reasons.map((reason) => reason.message), trace: { - compileTraceId: payload.trace.compile_trace_id, - compileTraceEventCount: payload.trace.compile_trace_event_count, - responseTraceId: payload.trace.response_trace_id, - responseTraceEventCount: payload.trace.response_trace_event_count, + routingTraceId: response.routing_trace.trace_id, + routingTraceEventCount: response.routing_trace.trace_event_count, + requestTraceId: response.trace.trace_id, + requestTraceEventCount: response.trace.trace_event_count, }, }; - startTransition(() => { - setEntries((current) => [entry, ...current]); - setMessage(""); - setStatusText("Live response received and trace references recorded."); - }); + setEntries((current) => [entry, ...current]); + setStatusTone("success"); + setStatusText("Governed request submitted successfully. Approval and task linkage are now visible below."); } catch (error) { const detail = error instanceof Error ? error.message : "Request failed"; + setStatusTone("danger"); setStatusText(`Unable to submit live request: ${detail}`); + } finally { + setIsSubmitting(false); } } @@ -154,36 +164,128 @@ export function RequestComposer({
{liveModeReady ? "Live operator mode" : "Fixture operator mode"} - Requests stay explicitly governed and recent trace references remain attached to each response. + Requests stay explicitly governed and recent routing plus request traces remain attached to each submission.
+
+
+ + setThreadId(event.target.value)} + placeholder="Thread UUID" + /> +
+
+ + setToolId(event.target.value)} + placeholder="Tool UUID" + /> +
+
+
- +

- Keep requests bounded to existing backend concepts. This surface is optimized for clarity - and review rather than casual back-and-forth. + Submit the shipped approval-request payload directly. This surface is request-oriented, not a freeform chat transcript.

+
+
+ + setAction(event.target.value)} + placeholder="place_order" + /> +
+
+ + setScope(event.target.value)} + placeholder="supplements" + /> +
+
+ + setDomainHint(event.target.value)} + placeholder="ecommerce" + /> +
+
+ + setRiskHint(event.target.value)} + placeholder="purchase" + /> +
+
+
+