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>
);
});