SPB Git

Repository Public

sections.tsx 11.3 KB 215 lines tsx

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
Files main
Menu

Theme