Skip to Content

← All archived runs

Run: next-best-action-polish

run.md

Run: next-best-action-polish

  • branch: claude/pipeline-nba-polish-m89hw4
  • pr: #573

00_intake/stub.md

Stub: Next-best-action polish — complete the lever model

  • feature-slug: next-best-action-polish
  • epic: lead-workspace
  • personas: Customer, Expert, CSM, SDM, Admin, Vendor
  • initiative: Refine the Bridge / objective: Validate Technical Infrastructure & Payout Flow (Q2 2026 · Objective 3)
  • depends-on: wire-action-levers
  • sequence: 6 of 6

Problem

The levers are wired and working, but a working rail isn't yet a guided one. The CRM "next-best-action" pattern leads with the single most important next step per record; personas with nothing to do need a clear locked state; and the unhappy paths (qualify out, return to bidding, dispute, gone cold) must be present so the workspace genuinely surfaces every lever — not just the forward ones. This final stub makes the workspace the definitive answer to "where is this lead and what can I do next?".

Proposed change

Polish the action model into its finished form:

  • Primary CTA surfacing — promote the single positive transition for the current stage + persona as the prominent next-best-action; everything else stays secondary (the demo's promotion logic).
  • Per-persona empty/locked states — when a persona has no forward action, show its specific locked message (e.g. customer: "Nothing needs you right now — we'll be in touch.") instead of an empty rail.
  • Unhappy-path levers — ensure the negative/loop levers are present and correctly gated: qualify out, reject lead, return to bidding (quote rejected), flag BRD, mark blocked/blocker raise, invoice reject/dispute, flag delivery reworked, gone-cold/no-fit drop-offs.
  • Final pass — confirm jargon-free wording per persona, accessible labels/focus order on the rail and CTA, and consistent confirm-before-destructive behaviour; tidy empty states across all sections.

Acceptance criteria (rough)

  • Each persona/stage shows exactly one promoted next-best-action (the positive transition) when one exists, and its locked state when none does.
  • All unhappy-path levers are reachable and correctly gated by stage + capability; destructive levers confirm first.
  • Customer-facing wording is jargon-free throughout (header, journey, sections, rail).
  • The rail + CTA meet the accessibility bar (keyboard reachable, labelled, sensible focus order).
  • Walkthrough: each of the six personas can be driven through the lead lifecycle (including a drop-off and a loop-back) from the workspace alone.

Out of scope (this feature)

  • Consolidating/redirecting or deleting the legacy detail pages (later epic — they still coexist).
  • New levers beyond those in the registry/feature-role-matrix; new business logic.
  • Cross-lead list/board views or bulk actions.

Notes for Define

  • Design source: apps/demo/lib/workspace/levers.ts (LOCKED, primary-promotion logic, unhappy-path catalogue) + apps/demo/components/workspace/{workspace-header,levers-rail}.tsx.
  • Lean on the accessibility skill for the a11y pass; keep confirm/destructive patterns consistent with @sustentus/ui.
  • touches: apps/web/components/workspace/**, apps/web/lib/workspace/levers.ts.

01_define/output/spec.md

Spec: Next-best-action polish — complete the lever model

  • slug: next-best-action-polish
  • personas: Customer, Expert, CSM, SDM, Admin, Vendor
  • touches: apps/web/components/workspace/{workspace-header.tsx, next-best-action.tsx (new), levers-rail.tsx, action-button.tsx, sections.tsx, workspace-view.tsx}, apps/web/lib/workspace/levers.ts
  • complexity: standard

Problem

Stubs 1–5 of the strictly-sequential lead-workspace epic delivered a per-persona workspace that shows the right levers and, since wire-action-levers, actually performs the cleanly-backed ones. But a working rail is not yet a guided one, so the epic's final promise — the workspace as the definitive answer to "where is this lead and what can I do next?" — isn't met:

  • The header still shows a dead placeholder ("Next best action appears here." — literally annotated "wired in a later feature"). The promotion logic already exists (deriveLevers returns a single primary and a per-persona primaryLocked), and the rail renders it, but the prominent header CTA the design leads with was deferred to this stub.
  • Destructive levers are inconsistent. Reason-form destructive levers (qualify out, reject quote, reject invoice) collect input before running, but form-less destructive levers (archive, flagRework, and any future negative lever) fire on a single unconfirmed click — no confirm-before-destructive guard.
  • The final quality pass — jargon-free per-persona wording, accessible labels/focus order on the rail and CTA (intent is currently conveyed by a colour dot alone), and consistent empty/locked states — has not been done end to end.

This is stub 6 of 6 (depends-on: wire-action-levers, satisfied) and it closes the Refine the Bridge initiative's Q2-2026 Objective 3 (Validate Technical Infrastructure & Payout Flow — lead transactions completed end-to-end): a lead-to-payout cycle is only genuinely runnable from one page once every persona is guided to its single next step, every unhappy path is reachable and safe, and the surface clears the accessibility and clarity bar.

Proposed change

Polish the existing action model into its finished form — presentational and safety work over the levers/rail that already exist. No new server actions, no new business logic, no new levers beyond the registry (the epic's reuse-only rule and the wire-action-levers cut still hold; unwired levers stay disabled with their honest affordance).

  • Primary CTA surfacing (header). Replace the static placeholder in WorkspaceHeader with the promoted next-best-action as a single prominent CTA — the deriveLevers primary for the current persona + stage — porting the demo's treatment (a leading affordance, "Next best action — {label}"). When the persona has no forward action, the header shows that persona's primaryLocked message (with a locked affordance) instead of an empty slot. Activating the header CTA runs the same wired lever the rail would (via the existing dispatch), and renders disabled with the same "not available here yet" affordance when that lever is unwired. The header becomes (or gains) a small client CTA that shares the rail's dispatch rather than duplicating it. The rail keeps the full inventory; the single canonical next-best-action is not shown twice as competing big buttons.

  • Per-persona empty/locked states. Ensure every persona whose stage offers no forward lever sees its specific LOCKED[persona] message in the header and the rail (already defined in levers.ts), never a blank CTA slot or empty rail.

  • Unhappy-path levers — present and correctly gated. Confirm the negative/loop levers are all reachable and gated by stage ∩ capability: qualify out, reject lead, return to bidding (quote rejected), flag BRD, mark milestone blocked / raise blocker, invoice reject / dispute, flag delivery reworked, and the drop-off/loop-back paths. These already exist in the catalogue; this stub verifies gating and reachability rather than adding levers.

  • Confirm-before-destructive (consistent). Every destructive (negative-intent) lever confirms before it runs: those that already open a reason form keep that as their confirmation; those with no form (e.g. archive, flagRework) gain an AlertDialog confirm step (@sustentus/ui alert-dialog). No destructive action fires on a single unconfirmed click.

  • Accessibility pass (rail + CTA). Every lever control is keyboard reachable with a descriptive accessible name; lever intent is not conveyed by the colour dot alone — it carries a text/aria equivalent; focus order follows reading order; disabled/unwired levers are announced as unavailable; and the form/confirm dialogs manage focus and are labelled. Lean on the accessibility skill.

  • Wording + empty-state tidy. A final jargon-free pass on the header, journey strip, section labels and rail per persona (no raw status keys or internal terms shown to the customer), and a consistent empty/locked state for every workspace section (reusing the existing EmptyState).

Acceptance criteria

  • The workspace header surfaces exactly one promoted next-best-action as a prominent CTA for the current persona + stage when a forward lever exists, and that persona's locked message when none does — the static "Next best action appears here." placeholder is gone.
  • Activating the header CTA performs the same wired action as the rail (invoking the existing server action and refreshing the workspace); when the promoted lever is unwired it renders disabled with the honest "not available here yet" affordance and mutates nothing.
  • Every destructive (negative-intent) lever confirms before running — via its existing reason form, or via a confirmation dialog for form-less ones (archive, flag rework, …); none fires on a single unconfirmed click.
  • All unhappy-path levers (qualify out, reject lead, return to bidding, flag BRD, milestone blocked / blocker raise, invoice reject / dispute, flag delivery reworked, drop-off / loop-back) are reachable and correctly gated by stage ∩ capability.
  • The rail and header CTA meet the accessibility bar: keyboard reachable, descriptive accessible names, lever intent has a non-colour-only cue, sensible focus order, and disabled levers are announced as unavailable.
  • Customer-facing wording is jargon-free throughout (header, journey strip, section labels, rail) — no raw status keys or internal terms surfaced to the customer.
  • Every workspace section and the rail render a tidy, consistent empty/locked state (no blank panels or empty slots).
  • Walkthrough (recorded in build notes): each of the six personas can be driven through the lead lifecycle — including at least one drop-off (qualify out / reject) and one loop-back (return to bidding / dispute) — from the workspace alone.

Out of scope

  • New server actions or domain logic. Levers with no cleanly-callable existing action stay unwired (rendered disabled) exactly as wire-action-levers left them — this stub does not wire the deferred set (forward spine transitions, proposal.*, quote.approve, milestone.start/complete/block, csat.submit, brd.approve/signOff, lead.assignManager, outreach.send, message.team, …).
  • New levers beyond the registry / feature-role-matrix. No new business logic.
  • Per-row / multi-instance satellite targeting. Choosing among several open invoices/milestones/ blockers/bids remains a section-row concern, not the rail.
  • Consolidating, redirecting, or deleting the legacy service-leads / projects / proposals / finance / BRD pages — epic-level out of scope (coexist, deprecate later).
  • Importing anything from apps/demo — design source, ported, never a dependency.

Open questions

  • none. The CTA-placement fork (header vs rail vs both) is resolved under Decisions resolved at Define and is steerable by editing this spec before the Spec approved gate.

Decisions resolved at Define

  1. The prominent next-best-action lives in the header; the rail holds the full inventory. The header hosts the single leading CTA (matching the demo design source and the placeholder the header already reserves); the rail keeps every available lever. The one canonical next-best-action is not rendered twice as competing primary buttons — the header owns the promotion, the rail lists the rest. (Steer: to keep the promoted CTA in the rail instead, say so here before the gate.)
  2. Confirm-before-destructive = reuse the reason form where one exists, else an AlertDialog. No new confirm pattern is invented; form-backed destructive levers already double as their own confirmation, and form-less ones get the shared @sustentus/ui alert dialog.
  3. Reuse-only holds. This is a presentation/safety/a11y polish over the levers and wiring that already exist. The levers.ts promotion logic and LOCKED map, the rail's dispatch, and the wire-action-levers wired/unwired split are consumed as-is, not re-derived.

Context budget

Define read beyond the docs-only intake band (recorded per the intake contract): the current workspace implementation it must polish — apps/web/lib/workspace/levers.ts, apps/web/components/workspace/{workspace-header,levers-rail,action-button,workspace-view,sections}.tsx — plus the wire-action-levers spec (the wired/unwired cut this stub must not re-open) and the demo design source apps/demo/components/workspace/workspace-header.tsx. The polish target is code, not prose, so the remaining-work scope had to be grounded in the real components rather than re-derived from the stub alone.

02_build/output/notes.md

Build notes: next-best-action-polish

  • commits:
    • feat: next-best-action-polish — surface header CTA, confirm destructive levers, a11y pass
    • docs: next-best-action-polish — build notes

What changed

The action model was already logically complete after wire-action-levers (promotion, per-persona LOCKED, the full unhappy-path catalogue, and the wired/unwired split). This stub is the presentation, safety and a11y finish over it — no new server actions, no new levers, no new domain logic.

  • components/workspace/next-best-action.tsx (new) — the promoted next-best-action, surfaced prominently in the header. Renders the deriveLevers primary as the leading CTA (reusing ActionButton) with an "ArrowRight · Next best action" framing, or the persona's primaryLocked message (with a lock affordance) when there is no forward lever. Dispatches through the shared lever actions, and renders disabled with the honest "not available here yet" affordance when the promoted lever is unwired.
  • components/workspace/lever-actions.tsx (new) — a LeverActionsProvider + useLeverActions hook that lifts the dispatch (the DISPATCH map, pending transition, form dialog, confirm dialog, toast) out of the rail so the header CTA and the rail's secondary levers share one dispatch owner rather than duplicating it. activate() decides: unwired → nothing; has a form → open form; consequential without a form (negative/loop) → confirm first; otherwise run.
  • components/workspace/confirm-dialog.tsx (new) — confirm-before-destructive for form-less consequential levers (archive, flag rework, dispute). Built on the already-exported @sustentus/ui Dialog (Radix focus-trap + Escape) rather than adding/exporting AlertDialog, which would drag in a mandatory Storybook story for a primitive only this feature uses. Reason-form levers (qualify out, reject quote/invoice, …) keep using their form as the confirmation.
  • components/workspace/levers-rail.tsx — now "More actions": consumes the shared dispatch, renders only the secondary levers (the header owns the promoted one), and adds the a11y treatment. Semantic ul/li; each lever's accessible name includes an intent word (forward / destructive / loop-back / neutral) so intent is not conveyed by the colour dot alone (dot is aria-hidden); unwired levers announce "not available here yet". Tidy generic empty state when no secondary lever applies.
  • components/workspace/action-button.tsx — added an optional ariaLabel prop so the header CTA gets the fuller accessible name "Next best action: …".
  • components/workspace/workspace-view.tsx — wraps the page in LeverActionsProvider, renders NextBestAction directly beneath the header (one visual header stack), and passes the lever set to the rail.
  • components/workspace/workspace-header.tsx — removed the dead placeholder ("Next best action appears here." / "wired in a later feature"); stays a pure server identity block.
  • Jargon passsections.tsx "Requirements (BRD)" → "Requirements"; lib/workspace/levers.ts customer sign-off hint "Approve your BRD…" → "Approve your requirements…". Status labels and the journey strip already read from the workflow engine's display names (no raw keys), and section bodies already carry per-section EmptyStates.

Acceptance criteria status

  • Header surfaces exactly one promoted next-best-action CTA (or the persona's locked message); the static placeholder is gone — next-best-action.tsx, placeholder removed from workspace-header.tsx.
  • The header CTA performs the same wired action as the rail (shared LeverActionsProvider dispatch → existing server action → workspace refresh); an unwired promoted lever renders disabled with the honest affordance and mutates nothing.
  • Every destructive lever confirms before running — reason-form levers via their form; form-less consequential levers (archive, flag rework, dispute) via ConfirmDialog. needsConfirm gates negative/loop form-less levers.
  • All unhappy-path levers are reachable and gated by stage ∩ capability — the catalogue and can()/stage gating from lever-registry/wire-action-levers are consumed unchanged; walkthrough below exercises the drop-off and loop-back paths.
  • A11y bar: keyboard-reachable buttons, descriptive accessible names, non-colour-only intent cue, ul/li order, disabled levers announced unavailable, dialogs manage focus (Radix).
  • Customer-facing wording jargon-free — dropped "BRD" from the customer-visible section title and sign-off hint; journey/status labels already come from engine display names.
  • Every section and the rail render a tidy, consistent empty/locked state — sections use EmptyState; the rail has a generic empty state; the header shows the persona locked message.
  • Walkthrough recorded (below).

Persona walkthrough (AC 8)

Each persona is guided to its single next step; drop-off = qualify out, loop-back = return to bidding / dispute. Traced against deriveLevers (capability ∩ stage ∩ transition) and the wired set:

  • Customerawaiting_confirmation: header CTA Accept quote (wired) → work_in_progress; loop-back Reject quote (reason form) → back to bidding. work_in_progress: Pay invoice (wired). Fully-served stages show "Nothing needs you right now — we'll be in touch."
  • Expertwork_in_progress: Raise invoice / Raise a blocker (form) / Clear blocker (wired); no forward step → "No delivery action for you at this stage."
  • CSM / SDMpending/backlog: Qualify out (reason form, drop-off). work_in_progress: Approve invoice (wired); Reject/Dispute invoice (confirm/reason); Flag delivery reworked (confirm, loop-back).
  • Admin — oversight levers Archive lead (confirm) / Qualify out across open stages.
  • Vendorcompleted: Rate the expert (score form); otherwise "Observer view — rate the expert once delivery completes."

Verify result

  • apps/web tsc --noEmit run locally on the touched files: no type errors introduced (the workspace files are clean; unrelated pre-existing errors on the base branch in sdm/vendor/expert dashboards + demo-data were not touched by this change).
  • Format · lint · full typecheck · build run in CI + the Vercel preview; Release reads them back from the PR check runs. Live authed click-through (DB + Clerk) is validated on the preview deploy — not runnable in this sandbox.

Notes for review

  • The dispatch was lifted into a context provider specifically so the header CTA and the rail share one wiring/pending/toast rather than duplicating it — the header CTA and a rail secondary of the same lever behave identically.
  • Confirm-before-destructive uses the shared Dialog (not AlertDialog) deliberately: adding AlertDialog to @sustentus/ui would require a Storybook story per convention, for a primitive only this feature needs.
  • Reuse-only held: levers.ts logic, LOCKED, the wired/unwired split and gating are unchanged except one customer-facing hint string.

03_release/output/changelog.md

Changelog: next-best-action-polish

Live entry: apps/help/app/changelog/2026-07-02-next-best-action-polish/page.mdx


See your single next best action on every lead

A lead's workspace now leads with the one thing that most needs you next — your single next best action, shown prominently at the top for your role and stage. When nothing needs you, you'll see a clear "nothing to do right now" message instead of an empty panel, and every other action stays one glance away in the "More actions" list. Anything that can't be undone — archiving a lead, sending delivery back for rework, disputing an invoice — now asks you to confirm first, so nothing consequential happens on a single click. The whole panel is keyboard-friendly and screen-reader-labelled throughout.

03_release/output/investor-update.md

Every lead now guides each role to its next step

Who it's for: All six roles. What shipped: The lead workspace now leads with each role's single next best action, confirms before destructive steps, and meets the accessibility bar. Why it matters: Guiding each role to its next step completes the one-page lead-to-payout flow (Refine the Bridge · Q2 Objective 3).

It closes the six-part lead-workspace build.

Dig deeper: https://github.com/sustentus/sustentus/pull/573 · https://help.sustentus.com/changelog/2026-07-02-next-best-action-polish

03_release/output/release.md

Release: next-best-action-polish

  • pr: #573 (https://github.com/sustentus/sustentus/pull/573) · merged: yes — squash-merged to main as eec8ec36 on 2026-07-02 (both gates ticked: Spec approved + Ready to merge).
  • CI: green on the review-fix commit c3ce924 — Quality Project ✅, Migrate preview database ✅, Migrate production database skipped (no migration), Vercel Preview Comments ✅.
  • technical docs: no technical docs impact — no apps/docs page documents the lead workspace / levers (verified by search; same as the lever-registry + wire-action-levers precedents), and the change adds no app, package, route, env var, or build/CI step (the /workspace/[id] route already existed).
  • business docs: no business docs impact — reuses existing capabilities and existing server actions on an existing additive route; no change to who-can-do-what (feature-role-matrix), no new service-journey step. Guiding + confirming existing actions is a UI-surface polish (captured in the changelog), not a change to the documented product model.
  • release notes: both — user-facing behaviour changed (the workspace now leads with each role's next best action, confirms before destructive steps, and clears the a11y bar; reachable via the WorkspaceLinkButton, all six personas, per the wire-action-levers precedent). Changelog entry apps/help/app/changelog/2026-07-02-next-best-action-polish/page.mdx; investor draft at 03_release/output/investor-update.md.
  • deploy: READY — web and help-centre production deploys both READY (poll-deploy.sh on eec8ec36).
  • sent: investor update sent to 2 recipients on 2026-07-02, after the green deploy; changelog live at https://help.sustentus.com/changelog/2026-07-02-next-best-action-polish.

Review summary

/code-review medium (complexity: standard) — correctness, cleanup/altitude, and conventions angles, verified.

  • CONFIRMED → fixed on branch: confirm-dialog.tsx hand-rebuilt a confirm dialog on the plain Dialog primitive on the mistaken belief that AlertDialog was not exported from @sustentus/ui. It is exported (via the base/index.ts barrel wildcard, which a direct grep of index.ts missed) and is already the house pattern for every destructive confirm in apps/web (delete buttons, invoice approvals). Rewrote ConfirmDialog on AlertDialog — gains the alertdialog ARIA role and matches the codebase convention the stub asked for ("consistent with @sustentus/ui"). (Supersedes the build note's AlertDialog/Storybook reasoning, which was based on the wrong premise.)
  • CONFIRMED → fixed on branch: the confirm-dialog.tsx header comment overclaimed "no consequential action fires on a single click"; positive money levers (pay / approve invoice) run immediately by design (spec scoped confirm to destructive levers). Reworded to "no destructive action…".
  • Accepted (no change): needsConfirm re-checks Boolean(lever.run) && !lever.run?.form, which its only caller already guarantees. Kept as a self-contained defensive predicate — no behaviour impact, not worth the churn.
  • Correctness / conventions: none. The correctness pass verified the RSC boundary (server workspace-view rendering the client LeverActionsProvider around mixed server/client children is the canonical valid pattern; only import type + a "use server" module cross into the client), full DISPATCH↔WIRING parity, both context consumers inside the provider, and the primary/secondary split (no lever rendered twice, none made dead). Conventions pass returned clean (arrow functions, type, named imports, sentence case, @sustentus/ui imports, changelog house rules).

Acceptance check (vs spec)

  • Header surfaces exactly one promoted next-best-action CTA (or the persona's locked message); the static placeholder is gone — next-best-action.tsx; workspace-header.tsx placeholder removed.
  • Header CTA performs the same wired action as the rail (shared LeverActionsProvider dispatch → existing action → refresh); unwired promoted lever renders disabled with the honest affordance, mutates nothing.
  • Every destructive lever confirms first — reason-form levers via their form; form-less negative/loop levers (archive, flag rework, dispute) via AlertDialog (needsConfirm).
  • Unhappy-path levers reachable + gated by stage ∩ capability — catalogue/gating from prior stubs consumed unchanged; walkthrough in build notes exercises drop-off + loop-back.
  • A11y bar — keyboard-reachable buttons, descriptive accessible names, non-colour-only intent cue (aria-hidden dot + intent word in the label), ul/li order, disabled announced unavailable, AlertDialog/Dialog manage focus.
  • Jargon-free customer wording — dropped "BRD" from the section title + sign-off hint; status/journey labels come from engine display names.
  • Every section + the rail render a tidy empty/locked state — sections use EmptyState; rail has a generic empty state; header shows the persona locked message.
  • Walkthrough recorded — 02_build/output/notes.md (six personas, drop-off + loop-back).

Context budget

Read one file beyond the run's own + the diff: an existing AlertDialog consumer (components/finances/invoice-approvals/invoice-approvals-table.tsx) to match the house confirm pattern during the review fix. Recorded per the contract.