Repository Public
1'use client';2import Link from 'next/link';3import { motion } from 'motion/react';4import type { AgentTask, TerminalSession } from '@cr/shared';5import { ArrowRight, Check, Sparkles, SquareTerminal, X } from 'lucide-react';6import { useCluster, useSessions, useSystem, useTasks } from '@/lib/queries';7import { useNow } from '@/hooks/use-viewport';8import { useOpenSession } from '@/hooks/use-nav';9import { sortSessions } from '@/hooks/use-current';10import { fmtDuration, fmtRelative, fmtTime } from '@/lib/format';11import { SESSION_STATE, TASK_ACTIVE } from '@/lib/states';12import { LIVE_SESSION_STATES } from '@cr/shared';13import { Badge, Dot } from '@/components/ui/badge';14import { RingGauge } from '@/components/ui/ring-gauge';15import { Skeleton } from '@/components/ui/misc';16import { cn } from '@/lib/cn';17import { useBrand } from '@/stores/branding';1819export const stagger = (i: number) => ({ initial: { opacity: 0, y: 12 }, animate: { opacity: 1, y: 0 }, transition: { type: 'spring' as const, stiffness: 420, damping: 34, delay: Math.min(i, 8) * 0.045 } });2021export function SectionTitle({ children, href, hrefLabel }: { children: React.ReactNode; href?: string; hrefLabel?: string }) {22 return (23 <div className="mb-2.5 flex items-baseline justify-between px-1">24 <h2 className="text-[17px] font-bold tracking-tight md:text-sm md:font-semibold">{children}</h2>25 {href && (26 <Link href={href} className="flex items-center gap-0.5 text-[13px] font-medium text-accent md:text-xs">27 {hrefLabel ?? 'See all'} <ArrowRight className="size-3.5" aria-hidden />28 </Link>29 )}30 </div>31 );32}3334export function KindIcon({ kind, className }: { kind: TerminalSession['kind']; className?: string }) {35 const brand = useBrand();36 const Icon = kind === 'claude' ? Sparkles : SquareTerminal;37 return (38 <span className={cn('flex size-9 shrink-0 items-center justify-center rounded-[12px]', kind === 'claude' ? 'bg-brand' : 'bg-surface-2 text-text-2', className)} aria-label={kind === 'claude' ? brand.agentName : 'Shell'} role="img">39 <Icon className="size-[18px]" aria-hidden />40 </span>41 );42}4344export function StateChip({ session }: { session: TerminalSession }) {45 const m = SESSION_STATE[session.state];46 return (47 <Badge tone={m.tone} className="h-6 gap-1.5 px-2.5 text-xs" data-state={session.state}>48 <span className="relative flex size-2">49 {m.pulse && <span className={cn('anim-ping absolute inset-0 rounded-full', session.state === 'WAITING_FOR_INPUT' ? 'bg-coral' : 'bg-accent')} />}50 <Dot tone={m.tone} className="relative" />51 </span>52 {m.label}53 </Badge>54 );55}5657/** Sessions that the agent CLI flagged as waiting (hook signal). Coral = "this one needs you". */58export function NeedsYou() {59 const sessions = (useSessions().data ?? []).filter((s) => s.state === 'WAITING_FOR_INPUT');60 const open = useOpenSession();61 const now = useNow(15_000);62 if (sessions.length === 0) return null;63 return (64 <section aria-label="Needs you" data-testid="needs-you">65 <SectionTitle>Needs you</SectionTitle>66 <ul className="flex flex-col gap-2.5">67 {sessions.map((s, i) => (68 <motion.li key={s.id} {...stagger(i)}>69 <button onClick={() => open(s.id)} className="pressable flex w-full items-center gap-3 rounded-md border border-coral/35 bg-coral/10 p-3.5 text-left shadow-card">70 <span className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-coral text-white">71 <span className="anim-ping absolute inset-0 rounded-full bg-coral" aria-hidden />72 <Sparkles className="relative size-5" aria-hidden />73 </span>74 <span className="min-w-0 flex-1">75 <span className="block truncate text-[15px] font-semibold text-text">{s.title}</span>76 <span className="block truncate text-[13px] text-coral-ink">{s.waitingReason ?? 'Waiting for your input'}</span>77 <span className="tnum block text-xs text-muted">{s.projectName ?? 'No project'} · {fmtRelative(s.lastActivityAt ?? s.createdAt, now)}</span>78 </span>79 <ArrowRight className="size-5 shrink-0 text-coral-ink" aria-hidden />80 </button>81 </motion.li>82 ))}83 </ul>84 </section>85 );86}8788export function SessionCard({ session: s, now, onOpen, className }: { session: TerminalSession; now: number; onOpen: () => void; className?: string }) {89 return (90 <button onClick={onOpen} data-testid="session-card" data-session-id={s.id} className={cn('card pressable flex flex-col gap-3 p-4 text-left', className)}>91 <span className="flex w-full items-center justify-between gap-2">92 <KindIcon kind={s.kind} />93 <StateChip session={s} />94 </span>95 <span className="min-w-0">96 <span className="block truncate text-[15px] font-semibold tracking-tight text-text">{s.title}</span>97 <span className="tnum block truncate text-[13px] text-muted">{s.projectName ?? 'No project'} · {fmtRelative(s.lastActivityAt ?? s.createdAt, now)}</span>98 </span>99 </button>100 );101}102103/** Live sessions as a horizontal, snap-scrolling carousel. */104export function JumpBackIn({ empty }: { empty: React.ReactNode }) {105 const q = useSessions();106 const open = useOpenSession();107 const now = useNow(15_000);108 const live = sortSessions(q.data ?? []).filter((s) => LIVE_SESSION_STATES.includes(s.state) || s.state === 'RECOVERABLE');109 return (110 <section aria-label="Jump back in" data-testid="jump-back-in">111 <SectionTitle href="/sessions/">Jump back in</SectionTitle>112 {q.isPending ? (113 <div className="flex gap-3 overflow-hidden">{[0, 1].map((i) => <Skeleton key={i} className="h-[124px] w-[240px] shrink-0 rounded-md" />)}</div>114 ) : live.length === 0 ? (115 empty116 ) : (117 <ul className="no-scrollbar -mx-5 flex snap-x snap-mandatory gap-3 overflow-x-auto px-5 pb-3 pt-0.5 md:mx-0 md:grid md:grid-cols-2 md:overflow-visible md:px-0 xl:grid-cols-3">118 {live.map((s, i) => (119 <motion.li key={s.id} {...stagger(i)} className="w-[240px] shrink-0 snap-start md:w-auto">120 <SessionCard session={s} now={now} onOpen={() => open(s.id)} className="w-full" />121 </motion.li>122 ))}123 </ul>124 )}125 </section>126 );127}128129const elapsed = (t: AgentTask, now: number) => (t.startedAt ? ((t.endedAt ? new Date(t.endedAt).getTime() : now) - new Date(t.startedAt).getTime()) / 1000 : null);130131/** Running tasks: elapsed time ticks, the bar is an indeterminate shimmer (no invented percentage). */132export function TaskActivity({ empty }: { empty?: React.ReactNode }) {133 const q = useTasks();134 const now = useNow(1000);135 const all = q.data ?? [];136 const running = all.filter((t) => TASK_ACTIVE.includes(t.state));137 const recent = all.filter((t) => !TASK_ACTIVE.includes(t.state) && t.endedAt && now - new Date(t.endedAt).getTime() < 3600_000).slice(0, 2);138 if (q.isPending) return null;139 if (running.length + recent.length === 0) return empty ? <section aria-label="Tasks"><SectionTitle href="/tasks/">Tasks</SectionTitle>{empty}</section> : null;140 return (141 <section aria-label="Tasks" data-testid="task-activity">142 <SectionTitle href="/tasks/">Tasks</SectionTitle>143 <ul className="flex flex-col gap-2.5">144 {[...running, ...recent].map((t, i) => {145 const active = TASK_ACTIVE.includes(t.state);146 const ok = t.state === 'SUCCEEDED';147 return (148 <motion.li key={t.id} {...stagger(i)}>149 <Link href={`/tasks/?t=${t.id}`} className="card pressable block p-3.5">150 <span className="flex items-center gap-3">151 <span className={cn('flex size-9 shrink-0 items-center justify-center rounded-full', active ? 'bg-accent/12 text-accent' : ok ? 'bg-success-vivid/15 text-success' : 'bg-error-vivid/12 text-error')}>152 {active ? (153 <span className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden />154 ) : ok ? (155 <svg viewBox="0 0 24 24" className="size-5" aria-hidden><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" pathLength={1} className="anim-draw" /></svg>156 ) : (157 <X className="size-4" aria-hidden />158 )}159 </span>160 <span className="min-w-0 flex-1">161 <span className="block truncate text-[15px] font-semibold text-text">{t.title}</span>162 <span className="tnum block truncate text-[13px] text-muted">163 {active ? `Running on ${t.host} · ${fmtDuration(elapsed(t, now))}` : `${ok ? 'Succeeded' : t.state === 'CANCELLED' ? 'Cancelled' : 'Did not succeed'} · took ${fmtDuration(elapsed(t, now))}`}164 </span>165 </span>166 {!active && ok && <Check className="sr-only" />}167 </span>168 {active && <span className="mt-3 block h-1.5 overflow-hidden rounded-full bg-surface-2" role="progressbar" aria-label={`${t.title} is running`}><span className="shimmer-accent block h-full w-full rounded-full" /></span>}169 </Link>170 </motion.li>171 );172 })}173 </ul>174 </section>175 );176}177178/** Host health at a glance: real measurements with their collection time; unknown = dashed ring. */179export function HostStrip() {180 const sys = useSystem();181 const cluster = useCluster();182 const h = sys.data?.host;183 const c = cluster.data;184 return (185 <section aria-label="Host health" data-testid="host-strip">186 <SectionTitle href="/system/" hrefLabel="System">Host</SectionTitle>187 <div className="card p-4">188 {sys.isPending ? (189 <Skeleton className="h-[86px] rounded-sm" />190 ) : sys.isError || !h ? (191 <p className="py-4 text-center text-[13px] text-muted">Host metrics are unavailable right now.</p>192 ) : (193 <>194 <div className="grid grid-cols-3 gap-2">195 <RingGauge label="CPU" fraction={h.cpuPct == null ? null : h.cpuPct / 100} />196 <RingGauge label="Memory" fraction={h.memUsedMb != null && h.memTotalMb ? h.memUsedMb / h.memTotalMb : null} />197 <RingGauge label="Disk used" caption="Disk" fraction={h.diskFreeGb != null && h.diskTotalGb ? 1 - h.diskFreeGb / h.diskTotalGb : null} />198 </div>199 <p className="tnum mt-3 flex items-center justify-between gap-3 border-t border-line pt-3 text-xs text-muted">200 <span className="min-w-0 truncate font-medium text-text-2">{h.name}</span>201 <span className="shrink-0 whitespace-nowrap">as of {fmtTime(sys.data?.collectedAt)}</span>202 </p>203 </>204 )}205 {c && (206 <Link href="/cluster/" className="mt-2 flex items-center justify-between rounded-sm bg-surface-2 px-3 py-2.5 text-[13px]">207 <span className="text-text-2">{c.source === 'none' ? 'Cluster telemetry not connected' : 'Cluster'}</span>208 <span className="tnum flex items-center gap-1 font-semibold text-text">{c.source === 'none' ? 'Host only' : `${c.totals.online} of ${c.totals.nodes} online`} <ArrowRight className="size-3.5 text-muted" aria-hidden /></span>209 </Link>210 )}211 </div>212 </section>213 );214}215