mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
plans
This commit is contained in:
parent
773307eec0
commit
be74f91b7a
2 changed files with 674 additions and 0 deletions
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
date: 2026-04-19
|
||||
topic: web-ui-lifecycle-actions
|
||||
---
|
||||
|
||||
# Expose CLI Lifecycle Actions in the Web UI
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The Fabro web UI today is essentially read-only for run management. The only mutating action exposed is **Preview** (opens a sandbox port URL). Every other run lifecycle action — cancelling a stuck run, cleaning up the board, revisiting archived runs — requires dropping to the CLI.
|
||||
|
||||
Two concrete user problems drive this work:
|
||||
|
||||
1. **Daily friction for CLI users.** People live in the board view and detail pages but have to context-switch to a terminal to manage state.
|
||||
2. **Excludes non-CLI teammates.** PMs, reviewers, and stakeholders can watch runs but cannot participate in managing them, cutting the UI off as a collaboration surface.
|
||||
|
||||
The web UI should own the **everyday lifecycle operations** these users hit. Rarer or more dangerous CLI verbs (force-delete of active runs, checkpoint ops, etc.) can remain CLI-only by design; the goal is user value per surface, not parity for parity's sake.
|
||||
|
||||
Most server infrastructure already exists — `POST /runs/{id}/{cancel,archive,unarchive}` are live, wired to the workflow engine's operations, and emit SSE events. The remaining work is UI surface + a handful of concrete SSE reconciliation gaps (see Dependencies).
|
||||
|
||||
## Requirements
|
||||
|
||||
**Action set**
|
||||
- R1. Expose three lifecycle actions on the run detail page (`/runs/{id}`): **cancel**, **archive**, **unarchive**.
|
||||
- R2. Do not expose `pause`, `unpause`, or `delete` in this first pass. Pause/unpause has no evidenced daily-user need; delete's tab-close-mid-undo failure mode is unacceptable for a non-CLI teammate because observability is destroyed (there's no run to revisit to self-verify).
|
||||
- R3. Do not expose checkpoint operations (resume, rewind, fork) or HITL question answering in this first pass.
|
||||
- R4. Do not expose these actions on the board kanban cards or as bulk selection in this first pass.
|
||||
|
||||
**State-aware visibility**
|
||||
- R5. Only show an action when the run's current status makes it valid:
|
||||
- **cancel (primary)**: visible as a primary affordance when status is `submitted`, `queued`, `starting`, `running`, or `paused`. Not shown as primary when `blocked` — see R6. The server also accepts cancel on `blocked` runs; that path is only reachable via the secondary surface from R6.
|
||||
- **archive**: visible only when status is terminal (`succeeded`, `failed`, `dead`) AND not already archived.
|
||||
- **unarchive**: visible only when `archived`.
|
||||
- R6. When a run is `blocked` (waiting on an HITL question), do not show cancel as a primary action. Instead, show an inline notice with the pending question text (from `GET /api/v1/runs/{id}/questions`, whose `ApiQuestion.text` field is already human-readable) and the instruction: "Answer this question via `fabro` CLI to continue." Cancel remains reachable via an overflow/secondary affordance (e.g., a "…" menu) for users who truly want to abandon the run. This protects the common case (non-CLI teammate accidentally cancelling work that was waiting for them) without fully hiding the escape hatch.
|
||||
- R7. Visibility updates live when the run status changes. The detail page must subscribe to the run's SSE event stream (`GET /runs/{id}/attach`) so action affordances appear/disappear without a manual refresh.
|
||||
|
||||
**Interaction & feedback**
|
||||
|
||||
Two toast patterns, matched to the reversibility of each action:
|
||||
|
||||
- R8. **Cancel uses a client-side deferred toast with a fixed 5-second countdown.** Cancel has partially-irreversible side effects (the agent stops mid-stage) so the undo window guards against misclicks.
|
||||
- Clicking cancel shows a toast with the action description, a 5-second countdown, and an **Undo** button. The cancel affordance enters a disabled/pending state during the window (not hidden — hiding mid-window misrepresents actual run state).
|
||||
- If the user clicks **Undo** before the countdown expires, the pending client-side timer is cancelled and no API call is made.
|
||||
- If the countdown expires, the client fires the API call. **Before firing**, the UI performs a `GET /api/v1/runs/{id}` refetch: if the run's status is no longer one where cancel is valid (e.g., the run completed or was cancelled elsewhere), the pending action is aborted and a brief "Run transitioned — cancel aborted" notice is shown instead. This covers the SSE-channel-unreachable case where R9's event-driven abort cannot fire.
|
||||
- If the tab is closed or navigated away (including SPA navigation to another route) mid-window, the pending action is silently cancelled. Accepted tradeoff of the client-only approach.
|
||||
|
||||
- R9. **Archive and unarchive fire immediately with an inverse-action toast.** Both are fully reversible by the opposite API call, so the 5-second countdown is overhead with no safety benefit. Gmail-archive shape:
|
||||
- Clicking archive fires `POST /runs/{id}/archive` immediately (optimistic UI: the run disappears from terminal views / moves to archived views right away).
|
||||
- On success, show a toast: "Run archived. **Unarchive**" with a visible action button. The toast remains dismissable for ~8 seconds.
|
||||
- Clicking **Unarchive** in the toast fires `POST /runs/{id}/unarchive`. The toast updates to "Run restored" briefly, then dismisses.
|
||||
- Unarchive-triggered-from-the-primary-affordance works identically, with the inverse verb.
|
||||
|
||||
- R10. **Undo-window collisions (cancel only).** While the cancel timer is pending for a run, other primary affordances for that run are disabled (not hidden). If an SSE event arrives for that run that would change its status (another tab, a CLI user, the run finishing naturally), the pending client-side timer is cancelled, the toast is dismissed with a brief notice ("Run transitioned — action cancelled"), and the UI reconciles to the new status. If a newly-valid action (e.g., archive becomes valid because the run just completed) results from the transition, its affordance lights up immediately rather than waiting for the toast to fully dismiss.
|
||||
|
||||
- R11. On a successful async cancel (status unchanged in the response body), the UI relies on SSE for the final status flip. No additional success toast beyond the deferred-toast already shown.
|
||||
|
||||
- R12. On API failure (including 409 precondition failures — e.g., the run transitioned out of a valid state between toast-expiry and API call), show an error toast that includes the server's error message, and refetch the run so the UI reconciles to actual state. For the archive/unarchive fire-immediately path, additionally roll back the optimistic UI change on failure.
|
||||
|
||||
- R13. **Multi-tab behavior (single-client rules apply per tab).** R8/R10 are scoped per-client: each tab runs its own timer. An SSE-delivered status transition in tab B (caused by tab A firing cancel) will cancel tab B's own pending timer for the same run per R10 and surface the transition notice. Cross-tab action coordination beyond what SSE already provides is not a requirement for this pass.
|
||||
|
||||
**Accessibility**
|
||||
- R14. Both toast patterns (deferred cancel toast; immediate archive/unarchive toast) must meet baseline a11y expectations:
|
||||
- Toast container uses `role="status"` with `aria-live="polite"` (assertive interrupts screen-reader output and is wrong here). Announcement names the action, e.g., "Cancel run requested, undoing in 5 seconds" or "Run archived. Press Unarchive to undo."
|
||||
- Toast does **not** steal focus, but is reachable via keyboard (tab order places the action button — Undo or Unarchive — immediately after the triggering affordance).
|
||||
- For the cancel deferred toast, while keyboard focus is on the **Undo** button, the 5-second countdown **pauses** and resumes counting down from the paused value when focus leaves. Focus leaving the Undo button after the countdown would have expired does not auto-fire the action — the user must still explicitly close the toast or navigate away for the countdown to resume its final tick.
|
||||
- Archive/unarchive toasts do not count down (they fire on click); they remain dismissable and focusable for ~8 seconds.
|
||||
- Touch targets (Undo / Unarchive buttons) meet 44×44 CSS px minimum. The detail page is expected to work on tablet and larger; phone-size support is not a requirement for this pass.
|
||||
- The action cluster is keyboard-operable end to end (no mouse-only affordances). Keyboard shortcuts for individual actions are out of scope for this pass.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- A user managing runs day-to-day can complete a full session (cancelling a stuck run, archiving finished ones, revisiting an archived one) without touching the CLI.
|
||||
- A non-CLI teammate can cancel or archive a run in the web UI without onboarding documentation beyond "click the button."
|
||||
- A non-CLI teammate who lands on a `blocked` run understands the run is waiting on a human answer, sees the question text, and does not accidentally cancel work in progress. **Note:** unblocking the teammate so they can actually *answer* the question requires HITL-answering in the web UI, which is deferred — this first pass prevents destruction, not participation. If blocked-teammate-can't-proceed proves to be a real painful pattern in usage, HITL answering should be the next scope to pick up.
|
||||
- When a run transitions state while the detail page is open (e.g., finishes, gets archived from the CLI, gets cancelled by someone else), the available actions update live without a page refresh.
|
||||
- The cancel deferred-toast component (toast container + Undo + pause-on-focus countdown + polite aria-live) is shaped so it can host future destructive actions (delete if we solve the observability problem; force-cancel if ever added) without redesign. The archive/unarchive immediate-inverse toast is a simpler shape that other reversible fire-and-forget actions can reuse.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**Out of scope for this first pass:**
|
||||
- `pause` and `unpause` (no evidenced daily-user pain for the target personas; revisit if a real workflow surfaces).
|
||||
- `delete` (tab-close-silently-cancels destroys observability — there's no run to revisit to self-verify — which is unacceptable for the non-CLI teammate persona; revisit with server-side soft-delete or a different UX).
|
||||
- Force-delete of active runs (`rm --force`).
|
||||
- Checkpoint operations: `resume`, `rewind`, `fork`. These need parameter input (which checkpoint? which branch?) that doesn't fit the uniform button+toast pattern.
|
||||
- Answering HITL questions from the web UI. R6 surfaces the question read-only and points to the CLI.
|
||||
- Board-card actions and bulk multi-select on the board.
|
||||
- Dense table/list view of runs.
|
||||
- Server-side deferred/pending states.
|
||||
- Keyboard shortcuts for individual actions.
|
||||
- Role-based authorization (assumed unchanged from today; all authenticated users can take all actions).
|
||||
- Phone-size responsive layout.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Cancel + archive + unarchive only.** Cancel addresses the clearest daily pain (stuck runs). Archive/unarchive addresses board clutter and is fully reversible by the opposite action — lowest-risk place to validate the interaction pattern. Pause/unpause are deferred for lack of evidenced need; delete is deferred because its client-side-undo failure mode destroys observability for the non-CLI teammate persona.
|
||||
- **Run detail page only, not the board.** Keep the first pass tight.
|
||||
- **Two toast patterns matched to reversibility, not one uniform pattern.** Cancel is partially irreversible → deferred-toast with a bound 5-second countdown. Archive/unarchive are fully reversible via the opposite API call → fire-immediately with an inverse-action toast (Gmail-archive shape). This is more spec than "one pattern for everything" but maps to a real property of the actions and eliminates a pointless friction tax on the reversible ones. The shared toast infrastructure (container, action-button slot, aria-live, keyboard reachability) is reused across both; only the cancel path carries the countdown + focus-pause machinery.
|
||||
- **Pre-fire status recheck on the deferred cancel path.** At countdown expiry, the client refetches `GET /runs/{id}` before firing the cancel API call. This covers the SSE-unreachable failure mode where R10's event-driven abort cannot fire: without the recheck, a dead SSE channel would silently degrade to "timer fires, 409 error, user sees error toast for a race they didn't create." One extra GET per cancel is a cheap premium for reliable UX.
|
||||
- **Blocked runs suppress cancel as a primary affordance and show the pending question.** Protects non-CLI teammates from the worst failure mode (cancelling work that was waiting for them). Cancel remains reachable via a secondary/overflow affordance for users who genuinely want to abandon. This solves the destruction problem but leaves the participation problem for HITL-in-the-web to solve later.
|
||||
- **Client-side deferral, not server-side.** Chosen on shipping speed; does not add new lifecycle states. Accepted tradeoff: the "undo" promise is soft — tab close or SPA nav silently cancels. This is tolerable precisely because the action with the worst silent-cancellation consequence (delete) was scoped out.
|
||||
- **Accessibility is in the requirements, not deferred to implementation.** ARIA semantics, keyboard reachability, focus-pauses-countdown, touch-target sizing, and aria-live politeness are specified rather than left as "standard best practices."
|
||||
- **Pattern reuse is claimed only within this action family.** We do not claim "adding resume/rewind/fork/HITL later is same shape, new button." Those need parameter input (checkpoint choice, answer text) that the button+toast pattern doesn't host. That's fine — these patterns are for fire-and-forget lifecycle mutations, and the rest of the verbs will need their own UX.
|
||||
|
||||
## Dependencies / Assumptions
|
||||
|
||||
- The existing API endpoints (`POST /runs/{id}/{cancel,archive,unarchive}`) are stable and will not require spec changes. Verified against `docs/api-reference/fabro-api.yaml`.
|
||||
- The generated TypeScript client in `lib/packages/fabro-api-client` exposes (or will trivially expose after regeneration) methods for these endpoints.
|
||||
- **SSE coverage is not uniform.** The server emits `run.*` events for status transitions including `run.archived` / `run.unarchived` (`fabro-workflow/src/event.rs`, `fabro-server/src/server.rs`). Known gaps the plan must address:
|
||||
- The board's event allowlist (`apps/fabro-web/app/routes/runs.tsx` `BOARD_STATUS_EVENTS`) does not currently include `run.archived` / `run.unarchived`. Adding them is in scope.
|
||||
- The per-run `/attach` SSE stream terminates on `RunCompleted` / `RunFailed`. Archive/unarchive events fire on already-terminal runs, so the detail page must either reconnect to a non-terminating channel, refetch on the successful archive/unarchive response, or listen at a layer above `/attach`. Planning should pick an approach.
|
||||
- **Run detail page is not yet SSE-subscribed.** `apps/fabro-web/app/routes/run-detail.tsx` currently fetches the run once via the React Router loader and does not subscribe to `/api/v1/runs/{id}/attach`. Wiring this subscription at the detail-page level (the owner of `run.status` that drives R5 visibility) is net-new work for R7. Individual tab components (stage-sidebar, run-files) already have per-run SSE subscriptions that can be used as a pattern.
|
||||
- **No undo-capable toast system exists yet.** The only Toast in `apps/fabro-web` is a read-only live-region banner (`apps/fabro-web/app/routes/run-files/states.tsx`) with local `useState`/`setTimeout`. R8 + R9 + R12 together require a shared toast component with: a countdown, action-button slot, programmatic dismiss, multi-toast coexistence, polite aria-live, and focus-pauses-countdown behavior. This is net-new UI infrastructure.
|
||||
- **Cancel semantics.** For `submitted` and `queued` runs, cancel synchronously flips lifecycle `status` to `failed` with `status_reason: cancelled` and returns that on the response. For `starting`/`running`/`blocked`/`paused` runs, cancel returns 200 with unchanged status and the transition lands asynchronously via the workflow engine. The UI should treat cancel as "request accepted" and rely on SSE for the final status flip — R10 covers this implicitly, but the plan should make the optimistic-UI behavior explicit (e.g., the cancel affordance stays in its disabled/pending state until either the response body carries the synchronous `failed`/`cancelled` result or the SSE-driven reconciliation arrives).
|
||||
- **Per-run `/attach` stream has silent termination paths beyond the terminal-event case.** `attach_event_is_terminal` only matches `RunCompleted | RunFailed`, but the task that drives the stream can also exit without a terminal marker if the store read errors, if the run projection becomes non-active mid-replay, or if cancel lands on a queued run that never transitioned to running (covered by the `cancel_before_run_transitions_to_running_returns_empty_attach_stream` test in `fabro-server/src/server.rs`). R7 and R10 must therefore not assume a terminal event will always land: the plan needs a fallback refetch path for "SSE stream ended without a terminal marker" and for "SSE channel unreachable during an undo window."
|
||||
- Authorization is a non-issue today (single-user / trusted deployment assumption). If multi-tenant auth lands, the action affordances will need to respect it, but that's a separate workstream.
|
||||
|
||||
## Outstanding Questions
|
||||
|
||||
### Deferred to Planning
|
||||
- [Affects R8][Design] Exact visual placement of the action cluster in the detail page header: single "Actions" dropdown vs. inline buttons vs. split primary+overflow. Behavior is specified here; visual placement resolves in design/implementation. Consider how cancel-on-blocked lives in the overflow while the primary slot is occupied by the R6 inline notice.
|
||||
- [Affects R11][Technical] Confirm the 409 error-body shape from the server so error-toast copy can use the server-provided message verbatim.
|
||||
- [Affects R7, R10][Technical] Decide the post-terminal reconciliation mechanism for archive/unarchive: reconnect SSE after terminal close, refetch on 2xx archive/unarchive response, or subscribe on a non-terminating channel. Either the per-run `/attach` contract extends or the UI uses response-driven reconciliation.
|
||||
|
||||
## Next Steps
|
||||
|
||||
→ `/ce:plan` for structured implementation planning
|
||||
548
docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md
Normal file
548
docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
---
|
||||
title: "feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-19
|
||||
updated: 2026-04-20
|
||||
origin: docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md
|
||||
---
|
||||
|
||||
# feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI
|
||||
|
||||
## Overview
|
||||
|
||||
Today the Fabro web UI at `apps/fabro-web` is essentially read-only for run management: only Preview mutates state. This plan adds three lifecycle actions to the run detail page (`/runs/{id}`): **cancel**, **archive**, and **unarchive**.
|
||||
|
||||
The plan also closes three supporting gaps the requirements and review surfaced:
|
||||
|
||||
- a shared action-capable toast system
|
||||
- a run-detail SSE subscription so action visibility updates live
|
||||
- two missing event strings in the board revalidation allowlist
|
||||
|
||||
As of **April 20, 2026**, product explicitly chose to remove the deferred cancel timer and undo window from the earlier requirements doc. In this plan, cancel fires immediately.
|
||||
|
||||
Pause/unpause and delete remain out of scope (see origin: `docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md`).
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Two user pains from the origin document drive this work:
|
||||
|
||||
1. **Daily friction for CLI users** who live in the web UI but must context-switch to a terminal to cancel, archive, or unarchive runs.
|
||||
2. **Exclusion of non-CLI teammates** (PMs, reviewers, stakeholders) who can observe runs but cannot participate in managing them.
|
||||
|
||||
The server endpoints already exist. The remaining work is UI surface, route wiring, SSE reconciliation, and user-facing error handling.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
All IDs reference the origin document, but this plan is the source of truth for implementation. The origin document's deferred-cancel requirements were superseded on **April 20, 2026** when product chose immediate cancel with no undo window.
|
||||
|
||||
- **R1** Expose cancel, archive, and unarchive on the run detail page.
|
||||
- **R2** Do not expose pause, unpause, or delete in this pass.
|
||||
- **R3** Do not expose checkpoint operations (resume, rewind, fork) or HITL question answering in this pass.
|
||||
- **R4** Do not expose these actions on board kanban cards or as bulk selection in this pass.
|
||||
- **R5** State-aware visibility:
|
||||
cancel visible for `submitted|queued|starting|running|paused` as the primary affordance; archive for terminal non-archived runs; unarchive for archived runs.
|
||||
- **R6** Blocked runs hide the primary cancel button and instead show an inline notice with question text plus CLI guidance. Cancel remains reachable through a de-emphasized secondary affordance inside that notice.
|
||||
- **R7** The detail page subscribes to the run's SSE stream so affordances update live without a manual refresh.
|
||||
- **R8** Cancel fires immediately. There is no client-side pending timer, no undo window, and no pre-fire `GET /runs/{id}` recheck in this plan.
|
||||
- **R9** Archive and unarchive fire immediately and surface an inverse-action toast ("Run archived. Unarchive").
|
||||
- **R10** While a lifecycle action request is in flight, only the submitting control disables/spins. There is no cross-action disable window because there is no pending cancel timer.
|
||||
- **R11** There is no optimistic local archived/unarchived flip. All three actions reconcile through the action response, normal route revalidation, and SSE for later lifecycle transitions.
|
||||
- **R12** On 404/409/network failures, show a user-facing mapped error toast and revalidate so the page reconciles to actual server state. Because there is no optimistic local state, there is no rollback layer.
|
||||
- **R13** Multi-tab behavior has no per-client timers. SSE still reconciles non-terminal state transitions across tabs. **Known limitation:** if tab A is showing an already-terminal run and tab B archives/unarchives it, tab A may stay stale because the per-run `/attach` stream has already closed on `RunCompleted`/`RunFailed`. Users recover on navigation/refresh or on a failed action attempt surfaced through the 409/error toast path.
|
||||
- **R14** Accessibility:
|
||||
toasts use `role="status"` and `aria-live="polite"`, do not steal focus, action buttons are keyboard-operable and touch-friendly, and the blocked-run secondary cancel affordance expands to a 48x48 CSS px touch target on coarse pointers. Countdown-specific pause/resume behavior is no longer part of scope.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- Actions exposed: cancel, archive, unarchive. Nothing else.
|
||||
- Surface: run detail page (`/runs/{id}`) only. Not the board, not bulk.
|
||||
- Not exposed: pause, unpause, delete, force-delete, resume/rewind/fork, HITL answering.
|
||||
- No OpenAPI changes.
|
||||
- No new server event types.
|
||||
- No changes to authorization (single-tenant trusted deployment assumption).
|
||||
- No phone-size responsive layout work; tablet and up only.
|
||||
- No keyboard shortcuts for individual actions.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- **Board SSE + revalidation allowlist:** `apps/fabro-web/app/routes/runs.tsx` (`BOARD_STATUS_EVENTS` at lines 63-79). `run.archived` and `run.unarchived` are missing. Test pattern in `apps/fabro-web/app/routes/runs.test.tsx`.
|
||||
- **Existing per-run SSE subscriptions:** `apps/fabro-web/app/routes/run-files.tsx` and `apps/fabro-web/app/components/stage-sidebar.tsx`. Both parse `msg.data` JSON and gate on `payload.event`; they do **not** use `EventSource` message type names.
|
||||
- **Run detail page header and Preview button:** `apps/fabro-web/app/routes/run-detail.tsx`. The route already uses React Router `useFetcher` plus a route `action` for Preview, so lifecycle actions can follow the same pattern instead of introducing a second mutation model.
|
||||
- **Run detail loader shape:** the loader receives raw API `summary.status` and maps it onto `run.lifecycleStatus` in loader data. UI visibility logic in this plan keys off `run.lifecycleStatus`; loader-only branching is described explicitly as checking `summary.status` before mapping.
|
||||
- **Existing toast primitive:** `apps/fabro-web/app/routes/run-files/states.tsx` has a simple read-only live-region toast. It is a useful visual/a11y baseline but is not reusable for stacked toasts with action buttons.
|
||||
- **UI primitives:** `apps/fabro-web/app/components/ui.tsx` exports `PRIMARY_BUTTON_CLASS` and `SECONDARY_BUTTON_CLASS`. No generic `<Button>` or app-level toast system exists today.
|
||||
- **API helpers:** `apps/fabro-web/app/api.ts` exports both `apiJson` and `apiFetch`. `apiJson` discards the response body on non-2xx. That is incompatible with lifecycle actions because these flows need the server error envelope for 404/409 handling. Lifecycle mutation helpers in this plan therefore use `apiFetch` and parse the body manually.
|
||||
- **Status taxonomy:** `apps/fabro-web/app/data/runs.ts` exports `RunStatus`, `runStatusDisplay`, and `mapRunSummaryToRunItem`. Use those status strings rather than open-coding new ones.
|
||||
- **Blocked question data:** `GET /api/v1/runs/{id}/questions` returns a `PaginatedApiQuestionList` in `docs/api-reference/fabro-api.yaml`. This plan intentionally shows the **first** pending question's `text` when the run is blocked; the blocked notice is informational only and does not attempt multi-question navigation or answering.
|
||||
- **Server cancel semantics:** for `submitted`/`queued`, cancel may synchronously return a terminal failed/cancelled state; for `starting`/`running`/`blocked`/`paused`, the response may keep the same lifecycle status and the eventual transition lands later via SSE.
|
||||
- **Per-run `/attach` stream limitations:** the stream closes on terminal run events and has a few other silent-termination paths. This plan accepts the existing already-terminal stale-tab limitation for archive/unarchive instead of changing the server subscription contract.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
None. `docs/solutions/` is not seeded in this repo. Capture learnings from this work with `/ce:compound` after implementation.
|
||||
|
||||
### External References
|
||||
|
||||
Not needed. Local patterns cover React Router actions, SSE subscriptions, and route-level data loading.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Immediate cancel, no undo.** Product explicitly removed the deferred timer on April 20, 2026. Cancel now behaves like a normal immediate mutation with passive feedback.
|
||||
- **One mutation pattern for all three lifecycle actions.** Cancel, archive, and unarchive all use React Router `useFetcher` plus the `run-detail.tsx` route `action`, extending the existing Preview route-action pattern with an `intent` dispatch instead of mixing `fetcher` and direct click-handler fetches.
|
||||
- **Lifecycle mutation helpers use `apiFetch`, not `apiJson`.** These flows need access to the error response body for 404/409 mapping. `run-actions.ts` owns that parsing and returns or throws typed shapes the route action can consume.
|
||||
- **Archive/unarchive are not optimistic.** The UI shows normal in-flight disabled state during submission, then relies on action completion + route revalidation to reflect the archived state. This is simpler and matches current app patterns.
|
||||
- **Shared app-shell toast provider is still the right shape.** With the cancel undo window removed, the earlier focus-order and route-lifetime problems disappear. The provider only needs to support passive toasts and inverse-action toasts.
|
||||
- **The new shared SSE hook is for code reuse, not socket deduplication.** `useRunEventSource` standardizes payload parsing, allowlist gating, debounce behavior, and cleanup across call sites. It still opens one `EventSource` per caller; the plan no longer claims otherwise.
|
||||
- **Blocked-run question text is loaded by the run-detail route, not by a component-local fetch.** When the loader sees raw API `summary.status === "blocked"` before mapping that value to `run.lifecycleStatus`, it fetches the first pending question and returns `blockedQuestionText` alongside the run summary. `blocked-run-notice.tsx` stays presentational.
|
||||
- **Blocked-run secondary cancel is an inline text affordance, not an overflow menu.** This satisfies the product intent of a de-emphasized escape hatch while avoiding unnecessary menu infrastructure.
|
||||
- **Post-terminal archive/unarchive reconciliation stays response-driven.** The plan does not reconnect `/attach` after terminal closure. Success responses and normal route revalidation handle the local tab; the known already-terminal stale-tab limitation is explicitly accepted.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Cancel interaction model:** immediate-fire, no undo, per April 20, 2026 product decision.
|
||||
- **Mutation transport:** all lifecycle actions use `useFetcher` plus route `action` dispatch in `run-detail.tsx`.
|
||||
- **Error-body parsing:** use `apiFetch` inside `run-actions.ts`; do not use `apiJson` for lifecycle mutations.
|
||||
- **Blocked question source:** fetch the first pending question in the run-detail loader when raw API `summary.status` is `blocked` before it is mapped to `run.lifecycleStatus`.
|
||||
- **Blocked cancel affordance shape:** inline muted text link inside the blocked notice rather than a menu.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact toast z-index and portal strategy if stacking interacts with the legacy toast in `run-files/states.tsx`.
|
||||
- Exact user-facing copy for each mapped 4xx/409 condition. Minimum set:
|
||||
cancel-409, archive-409, unarchive-409, 404, and generic network failure.
|
||||
- Exact visual treatment of the archive/unarchive inverse-action toast when the user clicks the toast action immediately after the first mutation settles.
|
||||
- Whether unarchive success uses a symmetric inverse-action toast (`Archive`) or a short passive "Run restored." toast.
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> This section is directional guidance for implementation and review, not code to copy verbatim.
|
||||
|
||||
### Module layout (new + modified, repo-relative)
|
||||
|
||||
```text
|
||||
apps/fabro-web/app/
|
||||
├── components/
|
||||
│ ├── toast.tsx NEW Toast, ToastProvider, useToast, ToastRoot
|
||||
│ └── blocked-run-notice.tsx NEW Presentational blocked notice
|
||||
├── lib/
|
||||
│ ├── sse.ts NEW useRunEventSource(runId, { allowlist, debounceMs, onEvent? })
|
||||
│ └── run-actions.ts NEW cancel/archive/unarchive request helpers, status predicates, error mapping
|
||||
├── layouts/
|
||||
│ └── app-shell.tsx MOD Mount ToastProvider once
|
||||
└── routes/
|
||||
├── run-detail.tsx MOD Loader adds blockedQuestionText; action dispatches lifecycle intents; UI renders actions and toast integration
|
||||
├── run-detail.test.tsx NEW or extend if created during implementation
|
||||
├── runs.tsx MOD Extend BOARD_STATUS_EVENTS
|
||||
└── runs.test.tsx MOD Add allowlist cases
|
||||
```
|
||||
|
||||
### Immediate cancel flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI as Cancel Button
|
||||
participant Action as Route action
|
||||
participant API as POST /cancel
|
||||
participant Toast
|
||||
participant SSE as useRunEventSource
|
||||
|
||||
User->>UI: click Cancel
|
||||
UI->>Action: submit intent=cancel
|
||||
Action->>API: POST /runs/{id}/cancel
|
||||
alt 200 with terminal failed/cancelled state
|
||||
API-->>Action: RunStatusResponse
|
||||
Action-->>UI: { ok: true, run }
|
||||
UI->>Toast: "Run cancelled."
|
||||
else 200 with unchanged in-progress state
|
||||
API-->>Action: RunStatusResponse
|
||||
Action-->>UI: { ok: true, run }
|
||||
UI->>Toast: "Cancellation requested."
|
||||
SSE-->>UI: later run.failed / equivalent transition
|
||||
else 404 / 409 / network failure
|
||||
API-->>Action: ErrorResponse
|
||||
Action-->>UI: { ok: false, error }
|
||||
UI->>Toast: mapped error copy
|
||||
end
|
||||
```
|
||||
|
||||
### Archive / unarchive inverse-action flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI as Archive Button
|
||||
participant Action as Route action
|
||||
participant API as POST /archive
|
||||
participant Toast
|
||||
|
||||
User->>UI: click Archive
|
||||
UI->>Action: submit intent=archive
|
||||
Action->>API: POST /runs/{id}/archive
|
||||
alt 200
|
||||
API-->>Action: RunStatusResponse
|
||||
Action-->>UI: { ok: true, run }
|
||||
UI->>Toast: "Run archived." + Unarchive action
|
||||
opt user clicks Unarchive in toast
|
||||
User->>UI: click Unarchive toast action
|
||||
UI->>Action: submit intent=unarchive
|
||||
end
|
||||
else 404 / 409 / network failure
|
||||
API-->>Action: ErrorResponse
|
||||
Action-->>UI: { ok: false, error }
|
||||
UI->>Toast: mapped error copy
|
||||
end
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
U1[Unit 1: Board allowlist additions]
|
||||
U2[Unit 2: Shared toast infrastructure]
|
||||
U3[Unit 3: Shared SSE hook and migration]
|
||||
U4[Unit 4: Lifecycle action helpers and route action dispatch]
|
||||
U5[Unit 5: Immediate cancel UI]
|
||||
U6[Unit 6: Archive/unarchive UI]
|
||||
U7[Unit 7: Blocked-run notice]
|
||||
|
||||
U2 --> U5
|
||||
U2 --> U6
|
||||
U3 --> U5
|
||||
U3 --> U6
|
||||
U3 --> U7
|
||||
U4 --> U5
|
||||
U4 --> U6
|
||||
U4 --> U7
|
||||
U5 --> U7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 1: Extend `BOARD_STATUS_EVENTS` with `run.archived` and `run.unarchived`**
|
||||
|
||||
**Goal:** Ensure the board revalidates when a run is archived or unarchived from any source.
|
||||
|
||||
**Requirements:** R7, R13.
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/fabro-web/app/routes/runs.tsx`
|
||||
- Modify: `apps/fabro-web/app/routes/runs.test.tsx`
|
||||
|
||||
**Approach:**
|
||||
- Add `run.archived` and `run.unarchived` to `BOARD_STATUS_EVENTS`.
|
||||
- Leave debounce/revalidator wiring unchanged.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing allowlist structure in `apps/fabro-web/app/routes/runs.tsx`
|
||||
- Existing allowlist tests in `apps/fabro-web/app/routes/runs.test.tsx`
|
||||
|
||||
**Test scenarios:**
|
||||
- `shouldRefreshBoardForEvent("run.archived")` returns `true`.
|
||||
- `shouldRefreshBoardForEvent("run.unarchived")` returns `true`.
|
||||
- `shouldRefreshBoardForEvent("run.created")` remains `false`.
|
||||
|
||||
**Verification:**
|
||||
- `runs.test.tsx` passes.
|
||||
- Manual: archive a terminal run from outside the board and confirm `/runs` updates without a manual refresh.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 2: Shared toast infrastructure (`ToastProvider`, `useToast`)**
|
||||
|
||||
**Goal:** Introduce a stackable app-level toast system for passive toasts, error toasts, and inverse-action toasts.
|
||||
|
||||
**Requirements:** R9, R12, R14.
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/fabro-web/app/components/toast.tsx`
|
||||
- Create: `apps/fabro-web/app/components/toast.test.tsx`
|
||||
- Modify: `apps/fabro-web/app/layouts/app-shell.tsx`
|
||||
|
||||
**Approach:**
|
||||
- Export `ToastProvider`, `useToast()`, and a provider-owned root container.
|
||||
- `useToast()` exposes `push`, `dismiss`, and `clear`.
|
||||
- Supported toast shapes:
|
||||
passive info toast, error toast, and action toast with a single button.
|
||||
- Root renders a stacked bottom-right container with `role="status"` and `aria-live="polite"`.
|
||||
- Do not steal focus on mount.
|
||||
- Error toasts are sticky until dismissed.
|
||||
- Non-error toasts may auto-dismiss with a short TTL.
|
||||
- Action buttons meet the minimum touch target via padding or explicit size classes.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing `Toast` live-region semantics in `apps/fabro-web/app/routes/run-files/states.tsx`
|
||||
|
||||
**Test scenarios:**
|
||||
- `push({ message })` renders a toast with the message.
|
||||
- `push({ action: { label, onClick } })` renders an actionable button and fires `onClick`.
|
||||
- Error toasts do not auto-dismiss.
|
||||
- Multiple toasts stack in insertion order.
|
||||
- `dismiss()` removes a toast and leaves the rest reflowed.
|
||||
- The provider mounted in `app-shell.tsx` is accessible from descendant route components.
|
||||
|
||||
**Verification:**
|
||||
- `toast.test.tsx` passes.
|
||||
- `bun run typecheck` passes.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 3: Shared SSE hook (`useRunEventSource`) and migration of existing call sites**
|
||||
|
||||
**Goal:** Standardize run-scoped SSE parsing and revalidation behavior across the app.
|
||||
|
||||
**Requirements:** R7, R13.
|
||||
|
||||
**Dependencies:** None runtime; Units 5 and 6 consume this.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/fabro-web/app/lib/sse.ts`
|
||||
- Create: `apps/fabro-web/app/lib/sse.test.ts`
|
||||
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
|
||||
- Modify: `apps/fabro-web/app/routes/run-files.tsx`
|
||||
- Modify: `apps/fabro-web/app/components/stage-sidebar.tsx`
|
||||
|
||||
**Approach:**
|
||||
- Export `useRunEventSource(runId, { allowlist, debounceMs = 300, onEvent? })`.
|
||||
- Internally:
|
||||
open `/api/v1/runs/${runId}/attach?since_seq=1`, parse `msg.data`, read `payload.event`, and gate both `revalidator.revalidate()` and `onEvent(payload)` on the allowlist.
|
||||
- Cleanup closes the `EventSource` and pending debounce timer.
|
||||
- The plan intentionally describes this as code reuse and behavior standardization, not socket deduplication.
|
||||
- `run-detail.tsx` should subscribe to the lifecycle events that can change button visibility for the current run.
|
||||
- `run-files.tsx` and `stage-sidebar.tsx` keep their current event sets and debounce timing.
|
||||
- `run-detail.tsx` is the subscriber that makes the blocked notice in Unit 7 disappear when the run leaves `blocked`.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing SSE shapes in `apps/fabro-web/app/routes/run-files.tsx`
|
||||
- Existing SSE shapes in `apps/fabro-web/app/components/stage-sidebar.tsx`
|
||||
|
||||
**Test scenarios:**
|
||||
- Allowlisted `payload.event` triggers debounced revalidation.
|
||||
- Non-allowlisted events are ignored.
|
||||
- `onEvent` receives the parsed payload for allowlisted events.
|
||||
- Unmount closes the source and clears any timer.
|
||||
- The migrated `run-files.tsx` still refreshes for `checkpoint.completed`.
|
||||
- The migrated `stage-sidebar.tsx` still refreshes for stage events.
|
||||
|
||||
**Verification:**
|
||||
- `sse.test.ts` passes.
|
||||
- Existing `run-files` and `stage-sidebar` tests continue to pass.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 4: Lifecycle action helpers and route action dispatch**
|
||||
|
||||
**Goal:** Centralize request handling, error parsing, and status predicates for cancel/archive/unarchive.
|
||||
|
||||
**Requirements:** R5, R8, R9, R11, R12.
|
||||
|
||||
**Dependencies:** None.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/fabro-web/app/lib/run-actions.ts`
|
||||
- Create: `apps/fabro-web/app/lib/run-actions.test.ts`
|
||||
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
|
||||
|
||||
**Approach:**
|
||||
- `run-actions.ts` owns:
|
||||
`cancelRun(id, request)`, `archiveRun(id, request)`, `unarchiveRun(id, request)`,
|
||||
`mapError(error, action)`,
|
||||
and `canCancel`, `canArchive`, `canUnarchive`.
|
||||
- These helpers use `apiFetch`, not `apiJson`, so the error body is still available on 404/409.
|
||||
- Introduce a small typed error shape such as:
|
||||
`{ status: number; errors: ErrorResponseEntry[] }`.
|
||||
- If the error body is absent or non-JSON (for example, a proxy HTML error page), parse fallback should return `{ status, errors: [] }` so `mapError` can fall back to generic copy instead of throwing.
|
||||
- Extend the route `action` in `run-detail.tsx` to dispatch on `intent=preview|cancel|archive|unarchive`.
|
||||
- Because Preview is no longer the implicit default path, add a hidden `intent=preview` input to the existing Preview form so the dispatch routes it explicitly.
|
||||
- Return a typed action result payload for lifecycle submissions so the component can toast on settle.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing `useFetcher` + route `action` pattern already used for Preview in `run-detail.tsx`
|
||||
|
||||
**Test scenarios:**
|
||||
- `cancelRun`, `archiveRun`, and `unarchiveRun` parse 200 responses correctly.
|
||||
- 404 and 409 responses preserve the parsed error envelope.
|
||||
- Non-JSON error bodies fall back to `{ status, errors: [] }` rather than throwing during parsing.
|
||||
- `mapError` returns user-facing copy for cancel/archive/unarchive conflict states.
|
||||
- Status predicates use lifecycle status strings from `data/runs.ts`, not a new local taxonomy.
|
||||
- The route `action` dispatches correctly for each lifecycle `intent`.
|
||||
- Preview still submits through the same route action via `intent=preview`; cover that regression in `run-detail.test.tsx`.
|
||||
|
||||
**Verification:**
|
||||
- `run-actions.test.ts` passes.
|
||||
- `bun run typecheck` passes.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 5: Immediate cancel UI**
|
||||
|
||||
**Goal:** Add the cancel button to the run detail header using the same route-action/fetcher pattern as the other lifecycle actions.
|
||||
|
||||
**Requirements:** R5, R8, R10, R11, R12.
|
||||
|
||||
**Dependencies:** Units 2, 3, and 4.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
|
||||
- Modify/Create: `apps/fabro-web/app/routes/run-detail.test.tsx`
|
||||
|
||||
**Approach:**
|
||||
- Render cancel when `canCancel(run.lifecycleStatus)` is true and the run is not blocked.
|
||||
- Use `cancelFetcher.Form` with `intent=cancel`.
|
||||
- Disable only the cancel control while its submission is in flight.
|
||||
- On successful settle:
|
||||
if the returned run is already terminal failed/cancelled, show a passive "Run cancelled." toast;
|
||||
otherwise show "Cancellation requested." and rely on SSE + normal revalidation for the final flip.
|
||||
- On 404/409/network failure, show `mapError(error, "cancel")` in an error toast.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Preview button layout and fetcher pattern in `apps/fabro-web/app/routes/run-detail.tsx`
|
||||
- `SECONDARY_BUTTON_CLASS` from `apps/fabro-web/app/components/ui.tsx`
|
||||
|
||||
**Test scenarios:**
|
||||
- Cancel renders for `submitted`, `queued`, `starting`, `running`, and `paused`.
|
||||
- Cancel is hidden for `blocked`, `succeeded`, `failed`, `dead`, and `archived`.
|
||||
- Submitting cancel sends `intent=cancel` through the route action.
|
||||
- While the cancel submission is pending, only that button disables.
|
||||
- A terminal success response shows "Run cancelled."
|
||||
- An async success response shows "Cancellation requested."
|
||||
- 404/409 responses show the mapped error toast.
|
||||
|
||||
**Verification:**
|
||||
- `run-detail.test.tsx` passes with cancel cases.
|
||||
- Manual: cancel a long-running run from `/runs/{id}` and confirm the toast and later lifecycle update behavior.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 6: Archive / unarchive UI**
|
||||
|
||||
**Goal:** Add archive and unarchive controls plus the inverse-action toast flow.
|
||||
|
||||
**Requirements:** R5, R9, R10, R11, R12.
|
||||
|
||||
**Dependencies:** Units 2, 3, and 4.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
|
||||
- Modify: `apps/fabro-web/app/routes/run-detail.test.tsx`
|
||||
|
||||
**Approach:**
|
||||
- Render archive when `canArchive(run.lifecycleStatus)` is true.
|
||||
- Render unarchive when `canUnarchive(run.lifecycleStatus)` is true.
|
||||
- Use dedicated fetchers or one intent-aware fetcher for both actions.
|
||||
- No optimistic local state; rely on standard submission state and route revalidation.
|
||||
- On archive success, push an action toast with `Unarchive`.
|
||||
- The toast action should capture the route-scoped fetcher submission at push time, e.g. `onClick: () => unarchiveFetcher.submit(...)`, rather than trying to host a route `<Form>` inside the app-shell toast tree.
|
||||
- On unarchive success, show the success-toast variant chosen in Deferred to Implementation below.
|
||||
- On 404/409/network failure, show the mapped error toast.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Same route-action/fetcher pattern as Unit 5
|
||||
|
||||
**Test scenarios:**
|
||||
- Archive renders only for terminal non-archived runs.
|
||||
- Unarchive renders only for archived runs.
|
||||
- Submitting archive/unarchive dispatches the correct `intent`.
|
||||
- Buttons disable while their own submission is pending.
|
||||
- Archive success shows "Run archived." with an Unarchive action.
|
||||
- Clicking the toast's Unarchive action submits the unarchive flow.
|
||||
- 404/409 failures show mapped error toasts.
|
||||
|
||||
**Verification:**
|
||||
- `run-detail.test.tsx` passes with archive/unarchive cases.
|
||||
- Manual: archive a terminal run from the detail page, then restore it from the toast action.
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 7: Blocked-run notice + secondary cancel affordance**
|
||||
|
||||
**Goal:** Show non-CLI users why the run is blocked without presenting cancel as the main action.
|
||||
|
||||
**Requirements:** R6, R14.
|
||||
|
||||
**Dependencies:** Units 3, 4, and 5.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/fabro-web/app/components/blocked-run-notice.tsx`
|
||||
- Create: `apps/fabro-web/app/components/blocked-run-notice.test.tsx`
|
||||
- Modify: `apps/fabro-web/app/routes/run-detail.tsx`
|
||||
- Modify: `apps/fabro-web/app/routes/run-detail.test.tsx`
|
||||
|
||||
**Approach:**
|
||||
- In the run-detail loader:
|
||||
when raw API `summary.status === "blocked"` before it is mapped to `run.lifecycleStatus`, fetch the first pending question from `/api/v1/runs/{id}/questions?page[limit]=1&page[offset]=0` and return `blockedQuestionText`.
|
||||
- `blocked-run-notice.tsx` is presentational: it renders the question text when available, otherwise fallback copy, plus the muted "Cancel run anyway." secondary control.
|
||||
- The secondary control reuses the same cancel submission path as Unit 5.
|
||||
- The control uses the coarse-pointer hit-area expansion pattern from R14.
|
||||
- Loader-branch tests for `blockedQuestionText` live in `run-detail.test.tsx`; `blocked-run-notice.test.tsx` stays focused on presentational behavior.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Visual banner treatment inspired by `InlineErrorBanner` in `apps/fabro-web/app/routes/run-files/states.tsx`
|
||||
|
||||
**Test scenarios:**
|
||||
- Blocked runs render the question text when the loader returns it.
|
||||
- Blocked runs render fallback copy when no question is available.
|
||||
- Blocked runs do not show the primary cancel button.
|
||||
- Clicking "Cancel run anyway." submits the same cancel path as Unit 5.
|
||||
- The notice disappears when the lifecycle status is no longer blocked after revalidation/SSE.
|
||||
|
||||
**Verification:**
|
||||
- `blocked-run-notice.test.tsx` passes.
|
||||
- Manual: open a blocked run, verify the question text and secondary cancel affordance.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction model:** lifecycle actions now share one route-action/fetcher model instead of mixing fetcher submissions and direct handler fetches.
|
||||
- **Error propagation:** lifecycle mutation helpers preserve and parse server error bodies via `apiFetch`.
|
||||
- **State reconciliation:** immediate action responses, normal route revalidation, and live SSE updates remain the only state sources. There is no local optimistic layer and no client-side pending cancel timer.
|
||||
- **Known limitation retained:** archive/unarchive on already-terminal runs can leave another open tab stale until refresh because `/attach` has already closed. This plan documents the limitation rather than changing the server contract.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Toast provider overlaps the legacy fixed-position toast in `run-files/states.tsx`. | Start with simple stacking and adjust z-index or migrate the older toast later if visual overlap proves real. |
|
||||
| The shared SSE refactor is over-read as socket deduplication work. | The plan states explicitly that Unit 3 is code reuse and behavior standardization only. |
|
||||
| Error mapping drifts from server copy. | Keep the mapped strings in one place in `run-actions.ts`; only use server detail text as a fallback for unexpected cases. |
|
||||
| Blocked runs may have multiple pending questions. | The plan intentionally shows only the first question because this surface is informational, not an answering workflow. |
|
||||
| `scripts/refresh-fabro-spa.sh` is forgotten before commit. | Call it out in implementation and PR notes whenever `apps/fabro-web` changes ship. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- **PR description** should call out:
|
||||
scope = cancel/archive/unarchive only;
|
||||
immediate cancel with no undo;
|
||||
new `ToastProvider`;
|
||||
new run-detail SSE subscription;
|
||||
`scripts/refresh-fabro-spa.sh` was run.
|
||||
- **Accessibility audit before merge:**
|
||||
1. Tab through the detail-page action row and confirm cancel/archive/unarchive are keyboard-operable.
|
||||
2. Trigger cancel and confirm the passive toast appears without stealing focus.
|
||||
3. Trigger archive and confirm the inverse-action toast button is reachable by keyboard and touch-friendly.
|
||||
4. Open a blocked run and confirm the secondary "Cancel run anyway." affordance meets the coarse-pointer hit-area requirement.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- **Origin document:** [docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md](docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md)
|
||||
- Key repo files:
|
||||
`apps/fabro-web/app/routes/run-detail.tsx`,
|
||||
`apps/fabro-web/app/routes/runs.tsx`,
|
||||
`apps/fabro-web/app/routes/run-files.tsx`,
|
||||
`apps/fabro-web/app/components/stage-sidebar.tsx`,
|
||||
`apps/fabro-web/app/routes/run-files/states.tsx`,
|
||||
`apps/fabro-web/app/api.ts`,
|
||||
`apps/fabro-web/app/components/ui.tsx`,
|
||||
`apps/fabro-web/app/layouts/app-shell.tsx`
|
||||
- API spec:
|
||||
`docs/api-reference/fabro-api.yaml`
|
||||
- Related plan:
|
||||
`docs/plans/2026-04-19-001-feat-archived-run-status-plan.md`
|
||||
Loading…
Add table
Reference in a new issue