SPB Git

spb/worthdoing Public

Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL

TypeScript 91.5% SQL 5.8% CSS 2.2%
6.3 KB · 147 lines tsx
Raw Blame History
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/components/wd/Timeline.tsx6 * Description: Live investigation timeline — renders real AgentEvents as they stream in.7 */8"use client";910import { memo } from "react";11import { cn } from "@/lib/utils";12import type { UiAgentEvent } from "@/lib/ui/api";1314type Tone = "verdict" | "signal" | "rust" | "tele" | "ink" | "soft";1516const TONE_DOT: Record<Tone, string> = {17  verdict: "bg-verdict",18  signal: "bg-signal",19  rust: "bg-rust",20  tele: "bg-tele",21  ink: "bg-ink",22  soft: "bg-ink-soft/50",23};2425function describe(e: UiAgentEvent): { tone: Tone; label: string; body?: string; mono?: boolean } | null {26  const p = e.payload as Record<string, never> & Record<string, unknown>;27  switch (e.type) {28    case "investigation.started":29      return { tone: "ink", label: "Investigation started", body: String(p.objective ?? "") };30    case "agent.plan":31      return { tone: "ink", label: "Agent", body: String(p.text ?? "") };32    case "phase.changed":33      return { tone: "signal", label: `Phase → ${String(p.to)}` };34    case "search.started":35      return { tone: "tele", label: `Searching (${String(p.intent)})`, body: `“${String(p.query)}”`, mono: true };36    case "search.completed":37      return { tone: "tele", label: `Search returned ${String(p.resultCount)} results`, body: `“${String(p.query)}”`, mono: true };38    case "scrape.started":39      return { tone: "tele", label: "Reading", body: String(p.url), mono: true };40    case "scrape.completed": {41      const cached = p.fromCache ? " (cache)" : "";42      return { tone: "tele", label: `Read ${String(p.wordCount)} words${cached}`, body: String(p.title ?? p.url), mono: false };43    }44    case "scrape.failed":45      return { tone: "rust", label: "Scrape failed", body: String(p.url), mono: true };46    case "crawl.started":47      return { tone: "tele", label: `Crawling up to ${String(p.limit)} pages`, body: String(p.url), mono: true };48    case "crawl.completed":49      return { tone: "tele", label: `Crawled ${String(p.pageCount)} pages`, body: String(p.url), mono: true };50    case "crawl.failed":51      return { tone: "rust", label: "Crawl failed", body: String(p.url), mono: true };52    case "extract.started":53      return { tone: "tele", label: "Extracting structured data", body: (p.urls as string[])?.join(", "), mono: true };54    case "extract.completed":55      return { tone: "tele", label: "Extraction complete" };56    case "extract.failed":57      return { tone: "rust", label: "Extraction failed" };58    case "hypothesis.created":59      return { tone: "verdict", label: `Hypothesis formed · ${Math.round(Number(p.confidence) * 100)}%`, body: String(p.title) };60    case "hypothesis.updated": {61      const delta = Number(p.confidenceDelta ?? 0);62      const arrow = delta > 0 ? "▲" : delta < 0 ? "▼" : "→";63      const tone: Tone = delta < 0 ? "signal" : "verdict";64      return {65        tone,66        label: `Hypothesis ${arrow} ${Math.round(Number(p.confidence) * 100)}% (${delta >= 0 ? "+" : ""}${Math.round(delta * 100)})`,67        body: `${String(p.title)} — ${String(p.rationale ?? "")}`,68      };69    }70    case "hypothesis.rejected":71      return { tone: "rust", label: "Hypothesis rejected", body: `${String(p.title)} — ${String(p.reason ?? "")}` };72    case "evidence.saved": {73      const kind = String(p.kind);74      const tone: Tone = kind === "contradict" ? "rust" : kind === "support" ? "verdict" : "soft";75      return { tone, label: `Evidence saved (${kind})`, body: String(p.summary) };76    }77    case "opportunity.created":78      return {79        tone: "verdict",80        label: `Opportunity detected · Worth ${String(p.worthScore)}`,81        body: String(p.title),82      };83    case "report.started":84      return { tone: "ink", label: "Writing report", body: String(p.title) };85    case "report.completed":86      return { tone: "ink", label: `Report complete (${String(p.citations)} citations)`, body: String(p.title) };87    case "budget.updated":88      return null; // reflected in the stats bar, not the feed89    case "agent.error":90      return { tone: "rust", label: `Error in ${String(p.tool ?? "step")}`, body: String(p.message ?? "") };91    case "investigation.completed":92      return { tone: "ink", label: `Investigation complete — ${String(p.outcome ?? "")}`, body: String(p.conclusion ?? "") };93    case "investigation.failed":94      return { tone: "rust", label: "Investigation failed", body: String(p.message ?? "") };95    case "report.delta":96      return null; // streamed into the report pane, not the feed97    default:98      return null;99  }100}101102export const Timeline = memo(function Timeline({ events }: { events: UiAgentEvent[] }) {103  const items = events104    .map((e) => ({ e, d: describe(e) }))105    .filter((x): x is { e: UiAgentEvent; d: NonNullable<ReturnType<typeof describe>> } => x.d !== null);106107  if (items.length === 0) {108    return (109      <div className="flex flex-col items-center gap-2 py-16 text-center">110        <span className="wd-live-dot h-2 w-2 rounded-full bg-verdict" />111        <p className="font-mono text-xs text-ink-soft">waiting for the agent’s first move…</p>112      </div>113    );114  }115116  return (117    <ol className="relative space-y-0">118      {items.map(({ e, d }) => (119        <li key={e.seq} className="wd-enter relative flex gap-3 pb-4 last:pb-0">120          <div className="flex flex-col items-center">121            <span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", TONE_DOT[d.tone])} />122            <span className="mt-1 w-px flex-1 bg-line" />123          </div>124          <div className="min-w-0 flex-1 pb-1">125            <div className="flex flex-wrap items-baseline gap-x-2">126              <span className="text-[13px] font-medium text-ink">{d.label}</span>127              <span className="font-mono text-[10px] text-ink-soft/70">128                {new Date(e.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}129              </span>130            </div>131            {d.body && (132              <p133                className={cn(134                  "mt-0.5 break-words text-[13px] leading-relaxed text-ink-soft",135                  d.mono && "font-mono text-xs",136                )}137              >138                {d.body.length > 600 ? `${d.body.slice(0, 600)}…` : d.body}139              </p>140            )}141          </div>142        </li>143      ))}144    </ol>145  );146});147