diff --git a/web/src/views/AutopilotView.tsx b/web/src/views/AutopilotView.tsx
new file mode 100644
index 0000000..ef91d9b
--- /dev/null
+++ b/web/src/views/AutopilotView.tsx
@@ -0,0 +1,127 @@
+import { usePoll, formatTime } from '../usePoll';
+import { getAutopilot, type AutopilotItem } from '../api';
+import { navigate } from '../useHashRoute';
+import { Button, Card, ErrText, HStack, Muted, Pill, StatusDot, Title, VStack } from '../ui';
+
+/** Live per-thread autopilot states (Wave B unit).
+ * Title + scrollable list of live states backed by GET /autopilot (runner/scheduler/tasks).
+ * Each row: StatusDot + clickable handle (navigates to prospect detail), state badge (Pill colored per drafting/deciding/sending/sent/paused),
+ * eta or at, campaign (warning Pill), leaf (mono), why, open action (ghost Button).
+ * Renders using *only* cocotte ui/ primitives (no legacy classes, no shared file edits).
+ * Real data; 4s poll for live feel. Matches prototype AUTOPILOT live activity (buildAutopilot) shape + tasks for states.
+ */
+export function AutopilotView(): JSX.Element {
+ const { data, error, loading } = usePoll<{ items: AutopilotItem[] }>(() => getAutopilot(20), 4000);
+
+ if (error) {
+ return (
+
+
+ Autopilot
+ {error}
+
+
+ );
+ }
+
+ if (loading && !data) {
+ return (
+
+
+ Autopilot
+ Loading live states…
+
+
+ );
+ }
+
+ const items = data?.items ?? [];
+
+ return (
+
+
+ Autopilot
+ what the AI is doing · live
+
+
+ {items.length === 0 ? (
+ No live autopilot activity.
+ ) : (
+
+ {items.map((item) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function AutopilotRow({ item }: { item: AutopilotItem }): JSX.Element {
+ const open = () => navigate('prospect', item.handle);
+ const tone = getToneForState(item.state);
+ const dotStatus = getDotForState(item.state);
+ const timeLabel = getEtaOrAt(item);
+
+ return (
+
+
+
+
+
+ {item.state}
+ {timeLabel ? {timeLabel} : null}
+
+ {item.campaign}
+
+ {item.leaf}
+
+
+ why → {item.why}
+
+
+
+
+
+ );
+}
+
+function getToneForState(state: string): 'accent' | 'neutral' | 'success' | 'warning' | 'error' {
+ const s = (state || '').toLowerCase();
+ if (s === 'sending') return 'warning';
+ if (s === 'sent') return 'success';
+ if (s === 'paused') return 'neutral';
+ if (s === 'deciding' || s === 'drafting') return 'accent';
+ return 'neutral';
+}
+
+function getDotForState(state: string): 'up' | 'degraded' | 'down' {
+ const s = (state || '').toLowerCase();
+ if (s === 'sent') return 'up';
+ if (s === 'paused') return 'down';
+ return 'degraded';
+}
+
+function getEtaOrAt(item: AutopilotItem): string {
+ if (item.eta != null) {
+ return `~${item.eta}s`;
+ }
+ if (item.at) {
+ return formatTime(item.at);
+ }
+ return '';
+}