PR #3638
Sections
Review

feat: show workflow step progress

main ← feature/investigate-workflow-e56 35 files +892 −147 PR #3638 ↗

Workflow steps now show live progress: a spinner on the moving or starting step and a status line with agent name inside the existing disclosure, derived from task and session state without new backend fields.

Why this change

Users move a task to a new workflow step and see no feedback while the agent prepares and starts. The stepper looks idle until the session reaches running, so users cannot tell if the move is pending, starting, or failed.

What it does

Architecture, end to end

Existing task and session projections feed a pure derivation hook. Every stepper surface reads the same progress map and renders it through the shared marker and details components.

flowchart LR
  Store[AppStore kanban + taskSessions] --> Evidence[resolveWorkflowProgressEvidence]
  Evidence --> Derive[deriveWorkflowStepProgress]
  Derive --> Hook[useWorkflowStepProgress]
  Hook --> Stepper[WorkflowStepper]
  Hook --> Disclosure[MinimalWorkflowStepper / StepDisclosureRow]
  Hook --> Preview[TaskPreviewPanel]
  Hook --> Drawer[TaskManagementDrawer]
  Hook --> Menu[TaskMoveContextMenu]
  Stepper --> Marker[StepCircleIndicator]
  Disclosure --> Details[StepProgressDetails]
  Drawer --> Details
  Menu --> Details

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

deriveWorkflowStepProgress(input: WorkflowStepProgressInput): WorkflowStepProgress
Click for details →

Centralizes all lifecycle mapping so every surface shows the same status from the same evidence.

Status mapping
function progressStatusForEvidence(
  taskState: string | null | undefined,
  primarySessionState: string | null | undefined,
  cancellationPending: boolean,
): WorkflowStepProgressStatus {
  const terminalTaskStatus = progressForTerminalState(taskState);
  if (terminalTaskStatus) return terminalTaskStatus;
  const terminalSessionStatus = progressForTerminalState(primarySessionState);
  const taskIsNotStarted = taskState === "CREATED" || taskState === "TODO";
  if (terminalSessionStatus && !taskIsNotStarted) return terminalSessionStatus;
  if (cancellationPending) return "cancelling";
  if (taskState === "SCHEDULING") return "preparing";
  if (primarySessionState === "STARTING") return "starting";
  if (primarySessionState === "RUNNING") return "running";
  if (taskState === "WAITING_FOR_INPUT" || primarySessionState === "WAITING_FOR_INPUT") return "waiting";
  if (primarySessionState === "IDLE") return "idle_agent";
  if (taskState === "CREATED" || taskState === "TODO" || primarySessionState === "CREATED") return "not_started";
  return "idle";
}
Evidence resolution
export function resolveWorkflowProgressEvidence({
  task,
  sessionsById,
  sessionsForTask,
  agentProfiles,
  preferTaskProjection = false,
}: WorkflowProgressEvidenceInput): WorkflowProgressEvidence {
  if (!task) return { sessionId: null, sessionState: null, cancellationPending: false, agentLabel: null };
  const projectedSession = task.statusSummary?.primary_session;
  const sessionIdFromProjection = projectedSessionId(task);
  const loadedPrimarySession = findPrimarySession(task, sessionIdFromProjection, sessionsById, sessionsForTask);
  const sessionId = loadedPrimarySession?.id ?? sessionIdFromProjection;
  const sessionState = resolvedSessionState(loadedPrimarySession, task, projectedSession, preferTaskProjection);
  const cancellationPending = loadedPrimarySession?.cancellation_pending ?? task.primarySessionCancellationPending ?? false;
  return { sessionId, sessionState, cancellationPending, agentLabel: resolvedAgentLabel(loadedPrimarySession, task, agentProfiles) };
}
Hook output
export function useWorkflowStepProgress({ taskId, currentStepId, movingToStepId, taskProjection, preferTaskProjection = false }) {
  const task = (preferTaskProjection ? (taskProjection ?? taskFromStore) : (taskFromStore ?? taskProjection)) ?? null;
  const evidence = useMemo(() => resolveWorkflowProgressEvidence({ task, sessionsById, sessionsForTask, agentProfiles, preferTaskProjection }), [task, sessionsById, sessionsForTask, agentProfiles]);
  const progressByStepId = useMemo(() => Object.fromEntries([...new Set([currentStepId, movingToStepId].filter(Boolean))].map(stepId => [stepId, deriveWorkflowStepProgress({ stepId, currentStepId, movingToStepId, taskState: task?.state, primarySessionId: evidence.sessionId, primarySessionState: evidence.sessionState, cancellationPending: evidence.cancellationPending, agentLabel: evidence.agentLabel })])), [currentStepId, movingToStepId, task, evidence]);
  return { progressByStepId, agentLabelsByProfileId };
}
StepProgressDetails(props: { progress: WorkflowStepProgress })
Click for details →

Renders the translated lifecycle line and agent label in one place for all disclosures.

Component
export function StepProgressDetails({ progress, agentProfileId, agentLabelsByProfileId = EMPTY_AGENT_LABELS_BY_PROFILE_ID, testId }: { progress: WorkflowStepProgress; agentProfileId?: string; agentLabelsByProfileId?: Readonly<Record<string, string>>; testId?: string; }) {
  const { t } = useTranslation();
  if (progress.status === "idle") return null;
  const agentLabel = progress.agentLabel ?? (agentProfileId ? agentLabelsByProfileId[agentProfileId] : undefined);
  return (
    <div data-testid={testId} role="status" aria-live="polite" className="flex min-w-0 items-center gap-1.5 pl-4 text-[11px] text-muted-foreground">
      <span className="truncate">{t(workflowStepProgressTranslationKey(progress.status))}</span>
      {agentLabel && <span className="truncate text-foreground/75">{agentLabel}</span>}
    </div>
  );
}
Translation keys
export function workflowStepProgressTranslationKey(status: WorkflowStepProgressStatus) {
  switch (status) {
    case "moving": return "task:workflowStepProgressMoving";
    case "preparing": return "task:workflowStepProgressPreparing";
    case "starting": return "task:workflowStepProgressStarting";
    case "cancelling": return "task:workflowStepProgressCancelling";
    case "running": return "task:workflowStepProgressRunning";
    case "waiting": return "task:workflowStepProgressWaiting";
    case "idle_agent": return "task:workflowStepProgressIdle";
    case "not_started": return "task:workflowStepProgressNotStarted";
    case "completed": return "task:workflowStepProgressCompleted";
    case "failed": return "task:workflowStepProgressFailed";
    case "cancelled": return "task:workflowStepProgressCancelled";
    case "idle": return "task:workflowStepProgressNotStarted";
  }
}
StepCircleIndicator(props: { isPending?: boolean })
Click for details →

Shows a spinner inside the existing 8px footprint so labels and connectors do not move.

Marker
export function StepCircleIndicator({ isCurrent, isCompleted, isPending = false, pendingLabel }: { isCurrent: boolean; isCompleted: boolean; isPending?: boolean; pendingLabel?: string; }) {
  let state: StepMarkerState = "upcoming";
  if (isPending) state = "pending";
  else if (isCurrent) state = "current";
  else if (isCompleted) state = "completed";
  if (isPending) {
    return (
      <span data-marker-state={state} role={pendingLabel ? "img" : undefined} aria-label={pendingLabel} className="relative flex h-2 w-2 items-center justify-center shrink-0">
        <IconLoader2 aria-hidden="true" data-marker-visual-size={isCurrent ? "14" : "8"} className={["absolute animate-spin text-primary motion-reduce:animate-none", isCurrent ? "h-3.5 w-3.5" : "h-2 w-2"].join(" ")} />
      </span>
    );
  }
  if (isCurrent) return <span data-marker-state={state} className="relative flex items-center justify-center shrink-0"><span className="absolute h-3.5 w-3.5 rounded-full border-2 border-primary/40" /><span className="h-2 w-2 rounded-full bg-primary" /></span>;
  if (isCompleted) return <span data-marker-state={state} className="relative flex items-center justify-center shrink-0"><span className="h-2 w-2 rounded-full bg-muted-foreground/60" /></span>;
  return <span data-marker-state={state} className="relative flex items-center justify-center shrink-0"><span className="h-2 w-2 rounded-full border border-muted-foreground/40" /></span>;
}
Stepper and hover disclosure wiringapps/web/components/task/workflow-stepper.tsx ↗
WorkflowStepper(props: { steps: Step[]; currentStepId: string | null })
Click for details →

Feeds progress into both the full stepper and the compact disclosure without adding new surfaces.

Hook usage
const { movingToStepId, progressingToStepId, handleMove } = useWorkflowStepMove({ taskId, workflowId, currentStepId, taskState, presentationToken, onMoveStart, onMoveError });
const { progressByStepId, agentLabelsByProfileId } = useWorkflowStepProgress({ taskId, currentStepId, movingToStepId: progressingToStepId ?? movingToStepId });
Step item
<StepCircleIndicator isCurrent={isCurrent} isCompleted={isCompleted} isPending={progress?.isPending} pendingLabel={pendingLabel} />
<span className={cn("text-xs leading-none", getStepLabelClass(isCurrent, isCompleted))}>{step.name}</span>
Hover content
{progress && (
  <StepProgressDetails progress={progress} agentProfileId={step.agent_profile_id} agentLabelsByProfileId={agentLabelsByProfileId} testId={`workflow-step-progress-${step.id}`} />
)}
<StepCapabilityIcons events={step.events} agentProfileId={step.agent_profile_id} />
MinimalWorkflowStepper(props: { sortedSteps: Step[]; currentIndex: number })
Click for details →

Shares the same progress map between the collapsed trigger, the drawer, and the kanban preview.

Disclosure row
function StepDisclosureRow({ step, isCurrent, isCompleted, canMove, isMoving, movePending, isTouchSurface, progress, agentLabelsByProfileId, onMove }) {
  return (
    <div data-testid={`workflow-step-disclosure-row-${step.id}`} aria-current={isCurrent ? "step" : undefined} className="flex flex-col gap-1.5 rounded-md px-2 py-1.5">
      <div className="flex min-h-11 items-center gap-2">
        <StepCircleIndicator isCurrent={isCurrent} isCompleted={isCompleted} isPending={progress?.isPending} pendingLabel={progress?.isPending ? t(workflowStepProgressTranslationKey(progress.status)) : undefined} />
        <span className={cn("min-w-0 truncate text-xs", getStepLabelClass(isCurrent, isCompleted))}>{step.name}</span>
        <StepCapabilityIcons events={step.events} agentProfileId={step.agent_profile_id} />
      </div>
      {progress && <StepProgressDetails progress={progress} agentProfileId={step.agent_profile_id} agentLabelsByProfileId={agentLabelsByProfileId} testId={`workflow-step-progress-${step.id}`} />}
    </div>
  );
}
Preview wiring
function previewPanelStepProps(stepMove: PreviewStepMove) {
  return {
    workflowSteps: stepMove.workflowSteps,
    currentStepId: stepMove.currentStepId,
    taskWorkflowId: stepMove.taskWorkflowId,
    isArchived: stepMove.isArchived,
    movingToStepId: stepMove.movingToStepId,
    progressByStepId: stepMove.progressByStepId,
    agentLabelsByProfileId: stepMove.agentLabelsByProfileId,
    onMoveStep: stepMove.handleMove,
    onDisclosureOpenChange: stepMove.handleDisclosureOpenChange,
    moveError: stepMove.moveError,
  };
}
TaskManagementDrawer(props: TaskManagementMenuProps)
Click for details →

Places the status line outside the disabled button so it remains accessible on touch devices.

Drawer hook
const { progressByStepId, agentLabelsByProfileId } = useWorkflowStepProgress({
  taskId: props.task.id,
  currentStepId: props.task.workflowStepId,
  taskProjection: { id: props.task.id, state: props.task.state, primarySessionId: props.task.primarySessionId, primarySessionState: props.task.sessionState },
  preferTaskProjection: true,
});
Step choices
return steps.map((step) => {
  const progress = progressByStepId[step.id];
  return (
    <div key={step.id} className="min-w-0">
      <Choice testId={`task-context-step-${step.id}`} disabled={disabled || step.id === currentStepId} onClick={() => onSelect(step.id)}>
        <span className="flex min-w-0 items-center gap-2"><span className="min-w-0 flex-1">{step.title}</span>{step.id === currentStepId && <span className="shrink-0 text-xs text-muted-foreground">{t("task:current2")}</span>}</span>
      </Choice>
      {progress && <StepProgressDetails progress={progress} agentProfileId={step.agent_profile_id ?? undefined} agentLabelsByProfileId={agentLabelsByProfileId} testId={`task-context-step-progress-${step.id}`} />}
    </div>
  );
});
Read the changes as a list

Progress derivation hook

apps/web/hooks/domains/kanban/use-workflow-step-progress.ts

Centralizes all lifecycle mapping so every surface shows the same status from the same evidence.

Status mapping
function progressStatusForEvidence(
  taskState: string | null | undefined,
  primarySessionState: string | null | undefined,
  cancellationPending: boolean,
): WorkflowStepProgressStatus {
  const terminalTaskStatus = progressForTerminalState(taskState);
  if (terminalTaskStatus) return terminalTaskStatus;
  const terminalSessionStatus = progressForTerminalState(primarySessionState);
  const taskIsNotStarted = taskState === "CREATED" || taskState === "TODO";
  if (terminalSessionStatus && !taskIsNotStarted) return terminalSessionStatus;
  if (cancellationPending) return "cancelling";
  if (taskState === "SCHEDULING") return "preparing";
  if (primarySessionState === "STARTING") return "starting";
  if (primarySessionState === "RUNNING") return "running";
  if (taskState === "WAITING_FOR_INPUT" || primarySessionState === "WAITING_FOR_INPUT") return "waiting";
  if (primarySessionState === "IDLE") return "idle_agent";
  if (taskState === "CREATED" || taskState === "TODO" || primarySessionState === "CREATED") return "not_started";
  return "idle";
}
Evidence resolution
export function resolveWorkflowProgressEvidence({
  task,
  sessionsById,
  sessionsForTask,
  agentProfiles,
  preferTaskProjection = false,
}: WorkflowProgressEvidenceInput): WorkflowProgressEvidence {
  if (!task) return { sessionId: null, sessionState: null, cancellationPending: false, agentLabel: null };
  const projectedSession = task.statusSummary?.primary_session;
  const sessionIdFromProjection = projectedSessionId(task);
  const loadedPrimarySession = findPrimarySession(task, sessionIdFromProjection, sessionsById, sessionsForTask);
  const sessionId = loadedPrimarySession?.id ?? sessionIdFromProjection;
  const sessionState = resolvedSessionState(loadedPrimarySession, task, projectedSession, preferTaskProjection);
  const cancellationPending = loadedPrimarySession?.cancellation_pending ?? task.primarySessionCancellationPending ?? false;
  return { sessionId, sessionState, cancellationPending, agentLabel: resolvedAgentLabel(loadedPrimarySession, task, agentProfiles) };
}
Hook output
export function useWorkflowStepProgress({ taskId, currentStepId, movingToStepId, taskProjection, preferTaskProjection = false }) {
  const task = (preferTaskProjection ? (taskProjection ?? taskFromStore) : (taskFromStore ?? taskProjection)) ?? null;
  const evidence = useMemo(() => resolveWorkflowProgressEvidence({ task, sessionsById, sessionsForTask, agentProfiles, preferTaskProjection }), [task, sessionsById, sessionsForTask, agentProfiles]);
  const progressByStepId = useMemo(() => Object.fromEntries([...new Set([currentStepId, movingToStepId].filter(Boolean))].map(stepId => [stepId, deriveWorkflowStepProgress({ stepId, currentStepId, movingToStepId, taskState: task?.state, primarySessionId: evidence.sessionId, primarySessionState: evidence.sessionState, cancellationPending: evidence.cancellationPending, agentLabel: evidence.agentLabel })])), [currentStepId, movingToStepId, task, evidence]);
  return { progressByStepId, agentLabelsByProfileId };
}

Status details component

apps/web/components/task/workflow-step-progress-details.tsx

Renders the translated lifecycle line and agent label in one place for all disclosures.

Component
export function StepProgressDetails({ progress, agentProfileId, agentLabelsByProfileId = EMPTY_AGENT_LABELS_BY_PROFILE_ID, testId }: { progress: WorkflowStepProgress; agentProfileId?: string; agentLabelsByProfileId?: Readonly<Record<string, string>>; testId?: string; }) {
  const { t } = useTranslation();
  if (progress.status === "idle") return null;
  const agentLabel = progress.agentLabel ?? (agentProfileId ? agentLabelsByProfileId[agentProfileId] : undefined);
  return (
    <div data-testid={testId} role="status" aria-live="polite" className="flex min-w-0 items-center gap-1.5 pl-4 text-[11px] text-muted-foreground">
      <span className="truncate">{t(workflowStepProgressTranslationKey(progress.status))}</span>
      {agentLabel && <span className="truncate text-foreground/75">{agentLabel}</span>}
    </div>
  );
}
Translation keys
export function workflowStepProgressTranslationKey(status: WorkflowStepProgressStatus) {
  switch (status) {
    case "moving": return "task:workflowStepProgressMoving";
    case "preparing": return "task:workflowStepProgressPreparing";
    case "starting": return "task:workflowStepProgressStarting";
    case "cancelling": return "task:workflowStepProgressCancelling";
    case "running": return "task:workflowStepProgressRunning";
    case "waiting": return "task:workflowStepProgressWaiting";
    case "idle_agent": return "task:workflowStepProgressIdle";
    case "not_started": return "task:workflowStepProgressNotStarted";
    case "completed": return "task:workflowStepProgressCompleted";
    case "failed": return "task:workflowStepProgressFailed";
    case "cancelled": return "task:workflowStepProgressCancelled";
    case "idle": return "task:workflowStepProgressNotStarted";
  }
}

Pending marker without layout shift

apps/web/components/task/workflow-step-marker.tsx

Shows a spinner inside the existing 8px footprint so labels and connectors do not move.

Marker
export function StepCircleIndicator({ isCurrent, isCompleted, isPending = false, pendingLabel }: { isCurrent: boolean; isCompleted: boolean; isPending?: boolean; pendingLabel?: string; }) {
  let state: StepMarkerState = "upcoming";
  if (isPending) state = "pending";
  else if (isCurrent) state = "current";
  else if (isCompleted) state = "completed";
  if (isPending) {
    return (
      <span data-marker-state={state} role={pendingLabel ? "img" : undefined} aria-label={pendingLabel} className="relative flex h-2 w-2 items-center justify-center shrink-0">
        <IconLoader2 aria-hidden="true" data-marker-visual-size={isCurrent ? "14" : "8"} className={["absolute animate-spin text-primary motion-reduce:animate-none", isCurrent ? "h-3.5 w-3.5" : "h-2 w-2"].join(" ")} />
      </span>
    );
  }
  if (isCurrent) return <span data-marker-state={state} className="relative flex items-center justify-center shrink-0"><span className="absolute h-3.5 w-3.5 rounded-full border-2 border-primary/40" /><span className="h-2 w-2 rounded-full bg-primary" /></span>;
  if (isCompleted) return <span data-marker-state={state} className="relative flex items-center justify-center shrink-0"><span className="h-2 w-2 rounded-full bg-muted-foreground/60" /></span>;
  return <span data-marker-state={state} className="relative flex items-center justify-center shrink-0"><span className="h-2 w-2 rounded-full border border-muted-foreground/40" /></span>;
}

Stepper and hover disclosure wiring

apps/web/components/task/workflow-stepper.tsx

Feeds progress into both the full stepper and the compact disclosure without adding new surfaces.

Hook usage
const { movingToStepId, progressingToStepId, handleMove } = useWorkflowStepMove({ taskId, workflowId, currentStepId, taskState, presentationToken, onMoveStart, onMoveError });
const { progressByStepId, agentLabelsByProfileId } = useWorkflowStepProgress({ taskId, currentStepId, movingToStepId: progressingToStepId ?? movingToStepId });
Step item
<StepCircleIndicator isCurrent={isCurrent} isCompleted={isCompleted} isPending={progress?.isPending} pendingLabel={pendingLabel} />
<span className={cn("text-xs leading-none", getStepLabelClass(isCurrent, isCompleted))}>{step.name}</span>
Hover content
{progress && (
  <StepProgressDetails progress={progress} agentProfileId={step.agent_profile_id} agentLabelsByProfileId={agentLabelsByProfileId} testId={`workflow-step-progress-${step.id}`} />
)}
<StepCapabilityIcons events={step.events} agentProfileId={step.agent_profile_id} />

Compact disclosure and preview header

apps/web/components/task/workflow-step-disclosure.tsx

Shares the same progress map between the collapsed trigger, the drawer, and the kanban preview.

Disclosure row
function StepDisclosureRow({ step, isCurrent, isCompleted, canMove, isMoving, movePending, isTouchSurface, progress, agentLabelsByProfileId, onMove }) {
  return (
    <div data-testid={`workflow-step-disclosure-row-${step.id}`} aria-current={isCurrent ? "step" : undefined} className="flex flex-col gap-1.5 rounded-md px-2 py-1.5">
      <div className="flex min-h-11 items-center gap-2">
        <StepCircleIndicator isCurrent={isCurrent} isCompleted={isCompleted} isPending={progress?.isPending} pendingLabel={progress?.isPending ? t(workflowStepProgressTranslationKey(progress.status)) : undefined} />
        <span className={cn("min-w-0 truncate text-xs", getStepLabelClass(isCurrent, isCompleted))}>{step.name}</span>
        <StepCapabilityIcons events={step.events} agentProfileId={step.agent_profile_id} />
      </div>
      {progress && <StepProgressDetails progress={progress} agentProfileId={step.agent_profile_id} agentLabelsByProfileId={agentLabelsByProfileId} testId={`workflow-step-progress-${step.id}`} />}
    </div>
  );
}
Preview wiring
function previewPanelStepProps(stepMove: PreviewStepMove) {
  return {
    workflowSteps: stepMove.workflowSteps,
    currentStepId: stepMove.currentStepId,
    taskWorkflowId: stepMove.taskWorkflowId,
    isArchived: stepMove.isArchived,
    movingToStepId: stepMove.movingToStepId,
    progressByStepId: stepMove.progressByStepId,
    agentLabelsByProfileId: stepMove.agentLabelsByProfileId,
    onMoveStep: stepMove.handleMove,
    onDisclosureOpenChange: stepMove.handleDisclosureOpenChange,
    moveError: stepMove.moveError,
  };
}

Phone drawer and context menus

apps/web/components/task/task-management-drawer.tsx

Places the status line outside the disabled button so it remains accessible on touch devices.

Drawer hook
const { progressByStepId, agentLabelsByProfileId } = useWorkflowStepProgress({
  taskId: props.task.id,
  currentStepId: props.task.workflowStepId,
  taskProjection: { id: props.task.id, state: props.task.state, primarySessionId: props.task.primarySessionId, primarySessionState: props.task.sessionState },
  preferTaskProjection: true,
});
Step choices
return steps.map((step) => {
  const progress = progressByStepId[step.id];
  return (
    <div key={step.id} className="min-w-0">
      <Choice testId={`task-context-step-${step.id}`} disabled={disabled || step.id === currentStepId} onClick={() => onSelect(step.id)}>
        <span className="flex min-w-0 items-center gap-2"><span className="min-w-0 flex-1">{step.title}</span>{step.id === currentStepId && <span className="shrink-0 text-xs text-muted-foreground">{t("task:current2")}</span>}</span>
      </Choice>
      {progress && <StepProgressDetails progress={progress} agentProfileId={step.agent_profile_id ?? undefined} agentLabelsByProfileId={agentLabelsByProfileId} testId={`task-context-step-progress-${step.id}`} />}
    </div>
  );
});

Data and storage

No new backend fields. The hook reads existing projections and maps them to a small status enum.

FieldTypeNotes
statusenummoving, preparing, starting, cancelling, running, waiting, idle_agent, not_started, completed, failed, cancelled, idle
isPendingbooleantrue for moving, preparing, starting; drives the spinner
sessionIdstring | nullprimary session id that owns the current step
agentLabelstring | nullsnapshot label or profile label for the session
progressByStepIdRecord<string, WorkflowStepProgress>only current and moving steps have entries

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • Frontend only: no API, migration, or backend state change.
  • Pure derivation from existing projections; missing data falls back to idle with no spinner.
  • Presentation token and request id guards prevent stale responses from painting wrong progress.

Trade-offs and review notes

Where to look first

  1. Check deriveWorkflowStepProgress precedence: terminal, cancelling, SCHEDULING, STARTING, and the CREATED/TODO guard for stale terminal sessions.
  2. Verify StepCircleIndicator keeps h-2 w-2 footprint and uses 14px visual for current and 8px for non-current pending states.
  3. Confirm StepProgressDetails renders outside the disabled Choice in the drawer and has role status with polite live region.
  4. Review useWorkflowStepMove progressingToStepId clearing on terminal state, superseding move, and presentation token change.