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%
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