Add src/lib/time.ts formatEastern() — parses both backend timestamp shapes (the 'YYYY-MM-DD HH:MM:SS UTC' snapshot string and ISO offset transition times), renders them in America/New_York (DST-aware: EDT/EST) as 24h 'YYYY-MM-DD HH:MM:SS ZZZ'. Wire into TopBar (snapshot clock + tooltip) and the TaskDrawer timeline (entered_at). Adds time.test.ts (8 cases incl. DST + day boundary). 44 frontend tests green.
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import { MessageSquare, X, ChevronDown } from "lucide-react";
|
|
import type { TaskDetail } from "../api";
|
|
import { Markdown } from "./Markdown";
|
|
import { Badge, statusVariant } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleTrigger,
|
|
CollapsibleContent,
|
|
} from "@/components/ui/collapsible";
|
|
import { cn } from "@/lib/utils";
|
|
import { formatEastern } from "@/lib/time";
|
|
|
|
interface Props {
|
|
detail: TaskDetail | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
function fmtDuration(s: number | null): string {
|
|
if (s == null) return "—";
|
|
if (s < 60) return `${s.toFixed(1)}s`;
|
|
if (s < 3600) return `${(s / 60).toFixed(1)}m`;
|
|
return `${(s / 3600).toFixed(1)}h`;
|
|
}
|
|
|
|
function fmtCost(usd: number): string {
|
|
return usd > 0 ? `$${usd.toFixed(4)}` : "—";
|
|
}
|
|
|
|
// Right-hand drawer: a task's journey through the pipeline (vertical timeline of
|
|
// task_transitions) plus Q&A, review verdicts, plan summary, and total spend.
|
|
export function TaskDrawer({ detail, loading, error, onClose }: Props) {
|
|
if (!detail && !loading && !error) return null;
|
|
|
|
const isWaiting = detail?.ok && detail.status === "waiting_human";
|
|
const isClarifyGate =
|
|
isWaiting && detail?.current_phase === "clarify";
|
|
|
|
return (
|
|
<section
|
|
className="flex flex-col w-[380px] h-full bg-card border-l border-border"
|
|
aria-label="task detail"
|
|
role="complementary"
|
|
>
|
|
{/* Sticky header */}
|
|
<div className="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-card border-b border-border">
|
|
<h2 className="text-sm font-semibold text-foreground">Task history</h2>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Close detail"
|
|
onClick={onClose}
|
|
className="h-7 w-7"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{loading && (
|
|
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
|
|
Loading…
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="m-3 rounded-md bg-status-failed-bg text-status-failed text-sm px-3 py-2">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{detail && detail.ok && (
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-3 space-y-3">
|
|
{/* Summary card */}
|
|
<Card className="bg-background">
|
|
<CardContent className="pt-3 pb-3 space-y-1.5">
|
|
<p className="text-sm font-medium text-foreground leading-snug">
|
|
{detail.task}
|
|
</p>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Badge variant={statusVariant(detail.status)}>
|
|
{detail.status}
|
|
</Badge>
|
|
<code className="text-xs font-mono text-muted-foreground">
|
|
{detail.short_id}
|
|
</code>
|
|
<span className="text-xs text-muted-foreground">
|
|
spend {fmtCost(detail.total_usd)}
|
|
</span>
|
|
</div>
|
|
{detail.partial && (
|
|
<p className="text-xs text-muted-foreground bg-muted rounded px-2 py-1">
|
|
Partial history — recorded since the v3 ledger; earlier
|
|
steps are reconstructed.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Gate panel — waiting_human informational only */}
|
|
{isWaiting && (
|
|
<Card className="bg-status-waiting-bg border border-status-waiting/30">
|
|
<CardContent className="pt-3 pb-3">
|
|
<div className="flex items-start gap-2">
|
|
<MessageSquare className="h-4 w-4 mt-0.5 shrink-0 text-status-waiting" />
|
|
<div className="space-y-1 min-w-0">
|
|
<p className="text-sm font-semibold text-status-waiting">
|
|
Input requested
|
|
</p>
|
|
<p className="text-xs text-status-waiting/80 leading-snug">
|
|
{isClarifyGate
|
|
? "The pipeline needs clarification before planning."
|
|
: "The pipeline is awaiting a human decision to proceed."}
|
|
</p>
|
|
<p className="text-xs font-medium text-status-waiting mt-1">
|
|
Answer in Slack{" "}
|
|
<span className="font-mono">#agent-team</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Timeline */}
|
|
<div>
|
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
|
Timeline
|
|
</p>
|
|
<ol className="relative space-y-0">
|
|
{detail.timeline.map((step, i) => {
|
|
const open = step.exited_at == null;
|
|
const isLast = i === detail.timeline.length - 1;
|
|
return (
|
|
<li key={i} className="flex gap-2">
|
|
{/* Dot + connector */}
|
|
<div className="flex flex-col items-center">
|
|
<span
|
|
className={cn(
|
|
"mt-1 h-2 w-2 rounded-full shrink-0 border-2",
|
|
open
|
|
? "bg-status-active border-status-active"
|
|
: "bg-muted border-border",
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
{!isLast && (
|
|
<span className="w-px flex-1 bg-border mt-0.5 mb-0.5 min-h-[12px]" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Step body */}
|
|
<div className="pb-3 flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium text-foreground">
|
|
{step.to_phase}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"text-xs shrink-0",
|
|
open
|
|
? "text-status-active font-medium"
|
|
: "text-muted-foreground",
|
|
)}
|
|
>
|
|
{open ? "in progress" : fmtDuration(step.duration_s)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
|
{step.entered_at && (
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{formatEastern(step.entered_at)}
|
|
</span>
|
|
)}
|
|
{step.status && (
|
|
<Badge variant={statusVariant(step.status)} className="text-[10px] py-0 px-1.5">
|
|
{step.status}
|
|
</Badge>
|
|
)}
|
|
{step.cost_usd > 0 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{fmtCost(step.cost_usd)}
|
|
</span>
|
|
)}
|
|
{step.note && (
|
|
<span className="text-xs text-muted-foreground italic">
|
|
{step.note}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
{detail.timeline.length === 0 && (
|
|
<li className="text-sm text-muted-foreground py-2">
|
|
No transitions recorded.
|
|
</li>
|
|
)}
|
|
</ol>
|
|
</div>
|
|
|
|
{/* Q&A section */}
|
|
{detail.qa_history.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<CollapsibleSection
|
|
title={`Q&A (${detail.qa_history.length})`}
|
|
defaultOpen
|
|
>
|
|
<QASection entries={detail.qa_history} />
|
|
</CollapsibleSection>
|
|
</>
|
|
)}
|
|
|
|
{/* Review verdicts */}
|
|
{detail.review_verdicts.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<CollapsibleSection
|
|
title={`Review verdicts (${detail.review_verdicts.length})`}
|
|
defaultOpen={true}
|
|
>
|
|
<VerdictsSection entries={detail.review_verdicts} />
|
|
</CollapsibleSection>
|
|
</>
|
|
)}
|
|
|
|
{/* Plan */}
|
|
{detail.plan && (
|
|
<>
|
|
<Separator />
|
|
<CollapsibleSection title="Plan" defaultOpen={true}>
|
|
<PlanSection plan={detail.plan} />
|
|
</CollapsibleSection>
|
|
</>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
|
|
{detail && !detail.ok && (
|
|
<div className="m-3 rounded-md bg-status-failed-bg text-status-failed text-sm px-3 py-2">
|
|
{detail.error ?? "Could not load task."}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// Collapsible section wrapper used for Q&A / Verdicts / Plan
|
|
function CollapsibleSection({
|
|
title,
|
|
defaultOpen,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
defaultOpen: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Collapsible defaultOpen={defaultOpen}>
|
|
<CollapsibleTrigger className="flex items-center justify-between w-full group">
|
|
<h3 className="m-0 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
{title}
|
|
</h3>
|
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="mt-2">{children}</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
function asText(v: unknown): string {
|
|
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2);
|
|
}
|
|
|
|
// Q&A: each entry is the answer text for a turn (a string), or an older
|
|
// {turn, question_id, answer} dict. Render each as a turn card with the answer
|
|
// as markdown so numbered sub-answers / bold render readably.
|
|
function QASection({ entries }: { entries: unknown[] }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{entries.map((entry, i) => {
|
|
const obj =
|
|
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null;
|
|
const turn = obj && "turn" in obj ? Number(obj.turn) : i;
|
|
const answer = obj && "answer" in obj ? asText(obj.answer) : asText(entry);
|
|
return (
|
|
<Card key={i} className="bg-background">
|
|
<CardHeader className="pb-1 pt-2 px-3">
|
|
<CardTitle className="text-xs font-semibold text-muted-foreground">
|
|
Turn {turn + 1}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pb-2 px-3 text-sm prose-sm">
|
|
<Markdown>{answer}</Markdown>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Review verdicts: each {verdict, round_index, outcome, findings} → a card with a
|
|
// verdict badge + round + outcome header and the findings rendered as markdown.
|
|
function VerdictsSection({ entries }: { entries: unknown[] }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{entries.map((entry, i) => {
|
|
const v =
|
|
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
|
|
const verdict = asText(v.verdict) || "verdict";
|
|
const round = "round_index" in v ? Number(v.round_index) : i + 1;
|
|
const outcome = asText(v.outcome);
|
|
const findings = asText(v.findings);
|
|
return (
|
|
<Card key={i} className="bg-background">
|
|
<CardHeader className="pb-1 pt-2 px-3">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs text-muted-foreground">
|
|
Round {round}
|
|
</span>
|
|
<Badge variant={statusVariant(verdict)}>
|
|
{verdict.replace(/_/g, " ")}
|
|
</Badge>
|
|
{outcome && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{outcome.replace(/_/g, " ")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
{findings && (
|
|
<CardContent className="pb-2 px-3 text-sm prose-sm">
|
|
<Markdown>{findings}</Markdown>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Plan: {summary, phases:[{name, steps:[...]}]} → summary (markdown) + a phase
|
|
// list with nested steps. Any extra scalar keys render as a small label/value
|
|
// list; an unrecognized shape falls back to a collapsed JSON view.
|
|
function PlanSection({ plan }: { plan: Record<string, unknown> }) {
|
|
const summary = asText(plan.summary);
|
|
const phases = Array.isArray(plan.phases) ? plan.phases : null;
|
|
const extra = Object.entries(plan).filter(
|
|
([k, val]) =>
|
|
k !== "summary" && k !== "phases" && (typeof val !== "object" || val === null),
|
|
);
|
|
|
|
if (!summary && !phases) {
|
|
return (
|
|
<pre className="text-xs bg-muted rounded p-2 overflow-auto font-mono">
|
|
{JSON.stringify(plan, null, 2)}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{summary && (
|
|
<div className="text-sm prose-sm">
|
|
<Markdown>{summary}</Markdown>
|
|
</div>
|
|
)}
|
|
{phases && (
|
|
<ol className="space-y-2">
|
|
{phases.map((p, i) => {
|
|
const ph =
|
|
p && typeof p === "object" ? (p as Record<string, unknown>) : {};
|
|
const name = asText(ph.name) || `Phase ${i + 1}`;
|
|
const steps = Array.isArray(ph.steps) ? ph.steps : [];
|
|
return (
|
|
<li key={i}>
|
|
<p className="text-xs font-semibold text-foreground mb-1">
|
|
{name}
|
|
</p>
|
|
{steps.length > 0 && (
|
|
<ul className="space-y-0.5 pl-3">
|
|
{steps.map((s, j) => (
|
|
<li
|
|
key={j}
|
|
className="text-xs text-muted-foreground list-disc"
|
|
>
|
|
{asText(s)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
)}
|
|
{extra.length > 0 && (
|
|
<dl className="space-y-1">
|
|
{extra.map(([k, val]) => (
|
|
<div key={k} className="flex gap-2 text-xs">
|
|
<dt className="text-muted-foreground font-medium shrink-0">{k}</dt>
|
|
<dd className="text-foreground truncate">{asText(val)}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|