feat(web): implement AutopilotView (Wave B) on cocotte ui + getAutopilot -- web/src/views/AutopilotView.tsx

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 21:54:07 -04:00
parent 9ad22c5e6d
commit 185253f044

View file

@ -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 (
<Card>
<VStack $gap={8}>
<Title>Autopilot</Title>
<ErrText>{error}</ErrText>
</VStack>
</Card>
);
}
if (loading && !data) {
return (
<Card>
<VStack $gap={8}>
<Title>Autopilot</Title>
<Muted>Loading live states</Muted>
</VStack>
</Card>
);
}
const items = data?.items ?? [];
return (
<VStack $gap={12}>
<VStack $gap={2}>
<Title>Autopilot</Title>
<Muted>what the AI is doing · live</Muted>
</VStack>
{items.length === 0 ? (
<Muted>No live autopilot activity.</Muted>
) : (
<VStack $gap={8}>
{items.map((item) => (
<AutopilotRow key={item.pid} item={item} />
))}
</VStack>
)}
</VStack>
);
}
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 (
<Card>
<VStack $gap={6}>
<HStack $gap={8} $wrap $align="center">
<StatusDot $status={dotStatus} />
<Button
$variant="ghost"
onClick={open}
style={{ padding: 0, fontSize: '13px', fontWeight: 600 }}
>
{item.handle}
</Button>
<Pill $tone={tone}>{item.state}</Pill>
{timeLabel ? <Muted style={{ fontSize: '10.5px' }}>{timeLabel}</Muted> : null}
<div style={{ flex: 1 }} />
<Pill $tone="warning">{item.campaign}</Pill>
<span
style={{
fontSize: '10.5px',
fontFamily: 'ui-monospace, Menlo, monospace',
color: '#34d399',
}}
>
{item.leaf}
</span>
</HStack>
<Muted style={{ fontStyle: 'italic', fontSize: '11.5px' }}>why {item.why}</Muted>
<HStack $justify="flex-end" $gap={8}>
<Button $variant="ghost" onClick={open} style={{ fontSize: '11.5px', padding: '3px 8px' }}>
Open
</Button>
</HStack>
</VStack>
</Card>
);
}
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 '';
}