{truncateMiddle(session.workDir, 48)}
diff --git a/dashboard/src/components/session/StallBadge.tsx b/dashboard/src/components/session/StallBadge.tsx
new file mode 100644
index 00000000..475b2640
--- /dev/null
+++ b/dashboard/src/components/session/StallBadge.tsx
@@ -0,0 +1,123 @@
+/**
+ * components/session/StallBadge.tsx — Issue #4802: typed stall pill.
+ *
+ * Renders a pill from a typed `StallEventPayload` (mirror of server
+ * `src/stall-events.ts`). Path 2 defensive: works on free-form emits
+ * (legacy `status.stall` event with `detail: string`) by falling back to
+ * a generic "Stalled" label.
+ *
+ * Pill text: ErrorClass label (e.g. "Transient 5xx", "Permission Timeout")
+ * Pill sub-label: "X/Y (auto-recovering…)" or "X/Y — intervention required"
+ * Kill-switch overlay icon: when `recoveryDisabled === true`
+ * Tooltip: composed metadata (errorClass + statusCode + timestamps + sub-label)
+ *
+ * Color:
+ * - `transient_5xx` → amber (retry-eligible, expected behavior)
+ * - others → red (more severe, requires attention)
+ */
+
+import type { StallEventPayload } from '../../api/schemas';
+import {
+ formatStallClassLabel,
+ formatStallSubLabel,
+ isRecoveryExhausted,
+ isRecoveryDisabled,
+ formatStallTooltip,
+} from '../../utils/stallClassLabels';
+
+export interface StallBadgeProps {
+ payload: Partial
;
+ className?: string;
+}
+
+const STALL_COLOR_CLASSES: Record<'amber' | 'red', string> = {
+ amber: 'border-amber-500/40 bg-amber-500/10 text-amber-200',
+ red: 'border-red-500/40 bg-red-500/10 text-red-200',
+};
+
+/**
+ * Compact "kill-switch" indicator icon (operator paused auto-recovery for
+ * this session). Rendered as an inline overlay on the stall pill.
+ */
+function KillSwitchIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+/**
+ * Stall pill with errorClass label + sub-label + kill-switch overlay.
+ *
+ * Returns null when the payload has no useful stall data to display
+ * (no errorClass AND no recoveryDisabled AND no recovery counter). This
+ * mirrors the SendContinueButton L36 pattern: always-conditional component
+ * integration — never render a "Stalled" pill for healthy sessions.
+ *
+ * Caller should still guard with a presence check on the upstream event
+ * (e.g. `{stallPayload && }` in SessionHeader), but the
+ * component itself is defensive against empty payloads.
+ */
+export function StallBadge({ payload, className }: StallBadgeProps) {
+ // No useful stall data: empty payload, no errorClass, no kill-switch,
+ // and no recovery counter → do not render a misleading "Stalled" pill.
+ const hasErrorClass = payload.errorClass !== undefined && payload.errorClass !== null;
+ const hasRecoveryCounter =
+ (payload.recoveryAttemptCount ?? 0) > 0 || (payload.recoveryMaxAttempts ?? 0) > 0;
+ const hasMeaningfulData =
+ hasErrorClass || payload.recoveryDisabled === true || hasRecoveryCounter;
+ if (!hasMeaningfulData) return null;
+
+ const label = formatStallClassLabel(payload.errorClass);
+ const subLabel = formatStallSubLabel(payload);
+ const exhausted = isRecoveryExhausted(payload);
+ const disabled = isRecoveryDisabled(payload);
+ const tooltip = formatStallTooltip(payload);
+
+ // Color: amber for transient_5xx (retry-eligible), red for others (more severe).
+ const colorKey: 'amber' | 'red' =
+ payload.errorClass === 'transient_5xx' ? 'amber' : 'red';
+ const colorClasses = STALL_COLOR_CLASSES[colorKey];
+
+ // Subtle visual cue when cap is reached: slight ring outline.
+ const ringClass = exhausted ? 'ring-1 ring-amber-400/40' : '';
+
+ return (
+
+ {label}
+ {subLabel && (
+ {subLabel}
+ )}
+ {disabled && (
+
+ )}
+
+ );
+}
+
+export default StallBadge;
diff --git a/dashboard/src/pages/SessionDetailPage.tsx b/dashboard/src/pages/SessionDetailPage.tsx
index c8724f65..b7f185af 100644
--- a/dashboard/src/pages/SessionDetailPage.tsx
+++ b/dashboard/src/pages/SessionDetailPage.tsx
@@ -23,6 +23,7 @@ import { useSessionApproval } from '../hooks/useSessionApproval';
import { SessionHeader } from '../components/session/SessionHeader';
import { CliShortcutsPanel } from '../components/session/CliShortcutsPanel';
import { PauseControlBar } from '../components/session/PauseControlBar';
+import { SendContinueButton } from '../components/session/SendContinueButton';
import { DriverControlBar } from '../components/session/DriverControlBar';
import { useSessionParticipants } from '../hooks/useSessionParticipants';
import { useSessionTimeline } from '../hooks/useSessionTimeline';
@@ -322,6 +323,10 @@ export default function SessionDetailPage() {
onResume={() => resume()}
/>
+ {id && (
+
+ )}
+
) => void;
setHealth: (healthMap: Record) => void;
+ // Issue #4802: Per-session typed stall payloads (mirror of src/stall-events.ts).
+ // Keyed by session ID. The most recent typed stall event for a session.
+ stallMap: Record;
+ setStallMap: (stallMap: Record) => void;
+ clearStallEntry: (sessionId: string) => void;
+
// Global metrics
metrics: GlobalMetrics | null;
setMetrics: (metrics: GlobalMetrics) => void;
@@ -143,6 +150,16 @@ export const useStore = create((set) => ({
areHealthMapsEqual(state.healthMap, healthMap) ? state : { healthMap }
)),
+ // Issue #4802: typed stall map
+ stallMap: {},
+ setStallMap: (stallMap: Record) => set({ stallMap }),
+ clearStallEntry: (sessionId) => set((state) => {
+ if (!(sessionId in state.stallMap)) return state;
+ const next = { ...state.stallMap };
+ delete next[sessionId];
+ return { stallMap: next };
+ }),
+
// Metrics
metrics: null,
setMetrics: (metrics) => set((state) => (areMetricsEqual(state.metrics, metrics) ? state : { metrics })),
diff --git a/dashboard/src/utils/stallClassLabels.ts b/dashboard/src/utils/stallClassLabels.ts
new file mode 100644
index 00000000..154f42e6
--- /dev/null
+++ b/dashboard/src/utils/stallClassLabels.ts
@@ -0,0 +1,115 @@
+/**
+ * utils/stallClassLabels.ts — Issue #4802: stall pill label + state helpers.
+ *
+ * Server emits ErrorClass as a bounded enum (src/stall-events.ts). Renderer
+ * maps each value to a display label. The bounded enum is the single source
+ * of truth — no free-form strings, no concatenation, no prompt-injection
+ * surface for the pill.
+ *
+ * Path 2 defensive: if the typed payload is missing fields (pre-F-9), the
+ * renderer falls back to a generic "Stalled" label and hides the sub-label
+ * and AC3b button. Safe default.
+ */
+
+import type { ErrorClass, StallEventPayload } from '../api/schemas';
+
+/**
+ * Display labels for each ErrorClass. The bounded enum is enforced by
+ * `ErrorClassSchema` in api/schemas.ts — adding a new label requires adding
+ * a new ErrorClass value (schema PR that gets reviewed).
+ */
+export const STALL_CLASS_LABELS: Record = {
+ transient_5xx: 'Transient 5xx',
+ permission_timeout: 'Permission Timeout',
+ jsonl_stall: 'JSONL Stall',
+ thinking_stall: 'Thinking Stall',
+ unknown_stall: 'Unknown Stall',
+ extended_working: 'Extended Working',
+};
+
+/** Generic fallback when errorClass is not present in the wire payload (pre-F-9). */
+export const STALL_GENERIC_LABEL = 'Stalled';
+
+/** Format ErrorClass to display label. Falls back to STALL_GENERIC_LABEL if missing. */
+export function formatStallClassLabel(errorClass: ErrorClass | undefined | null): string {
+ if (!errorClass) return STALL_GENERIC_LABEL;
+ return STALL_CLASS_LABELS[errorClass] ?? STALL_GENERIC_LABEL;
+}
+
+/**
+ * Compute "exhausted" state — server doesn't yet emit recoveryExhausted, so
+ * the renderer derives it from the existing recoveryAttemptCount /
+ * recoveryMaxAttempts fields. When both are 0 (Path 2 default), exhaustion
+ * is unknown (not enough info to tell), so the AC3b button stays hidden.
+ *
+ * Post-F-9 (or if server adds recoveryExhausted), this can be replaced with
+ * `payload.recoveryExhausted === true`.
+ */
+export function isRecoveryExhausted(payload: Partial): boolean {
+ const attempt = payload.recoveryAttemptCount ?? 0;
+ const max = payload.recoveryMaxAttempts ?? 0;
+ if (max <= 0) return false; // unknown — keep button hidden
+ return attempt >= max;
+}
+
+/**
+ * Compute sub-label for the stall pill.
+ * - When max > 0: "X/Y (auto-recovering…)" (State A) or "X/Y — intervention required" (State B)
+ * - When max === 0: return null (sub-label hidden, Path 2 default)
+ */
+export function formatStallSubLabel(payload: Partial): string | null {
+ const attempt = payload.recoveryAttemptCount ?? 0;
+ const max = payload.recoveryMaxAttempts ?? 0;
+ if (max <= 0) return null; // Path 2 default — no sub-label
+ const exhausted = isRecoveryExhausted(payload);
+ return exhausted
+ ? `${attempt}/${max} — intervention required`
+ : `${attempt}/${max} (auto-recovering…)`;
+}
+
+/**
+ * Compute the kill-switch overlay state. When recoveryDisabled is true,
+ * the pill renders an overlay icon indicating the operator has paused
+ * auto-recovery for this session.
+ */
+export function isRecoveryDisabled(payload: Partial): boolean {
+ return payload.recoveryDisabled === true;
+}
+
+/**
+ * Format a tooltip line for the stall pill. Composed of:
+ * - errorClass label
+ * - statusCode (if present and only for transient_5xx)
+ * - lastErrorAt (ISO timestamp)
+ * - stallDurationMs (formatted as "stalled Xm")
+ * - sub-label (X/Y recovery counter)
+ *
+ * Tooltip is metadata-only — never includes the raw `detail` field from
+ * the legacy free-form `status.stall` event, per F-6 redaction discipline.
+ */
+export function formatStallTooltip(payload: Partial): string {
+ const parts: string[] = [];
+
+ const errorClassLabel = formatStallClassLabel(payload.errorClass);
+ parts.push(errorClassLabel);
+
+ if (payload.statusCode !== undefined && payload.errorClass === 'transient_5xx') {
+ parts.push(`(${payload.statusCode})`);
+ }
+
+ if (payload.lastErrorAt) {
+ parts.push(`since ${payload.lastErrorAt}`);
+ }
+
+ if (payload.stallDurationMs !== undefined && payload.stallDurationMs > 0) {
+ const minutes = Math.round(payload.stallDurationMs / 60000);
+ parts.push(`stalled ${minutes}m`);
+ }
+
+ const subLabel = formatStallSubLabel(payload);
+ if (subLabel) {
+ parts.push(subLabel);
+ }
+
+ return parts.join(' — ');
+}