This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/web/src/components/TaskDrawer.tsx
Adam Moussa 05c92be874 feat(agent-team): render all dashboard times as Eastern 24h (EDT/EST)
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.
2026-06-24 18:56:56 -04:00

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>
);
}