1191 lines
41 KiB
TypeScript
1191 lines
41 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { useDeferredValue, useState } from 'react';
|
|
|
|
import { Alert, AlertDescription, AlertTitle } from '../cadence-ui/components/alert';
|
|
import { Badge } from '../cadence-ui/components/badge';
|
|
import { Button } from '../cadence-ui/components/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '../cadence-ui/components/card';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '../cadence-ui/components/dialog';
|
|
import { Input } from '../cadence-ui/components/input';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../cadence-ui/components/tabs';
|
|
import { Textarea } from '../cadence-ui/components/textarea';
|
|
import { cn } from '../cadence-ui/lib/cn';
|
|
import {
|
|
type BlockedQueueItem,
|
|
type BlockedTask,
|
|
type Message,
|
|
type Run,
|
|
type RunDetail,
|
|
type RunListItem,
|
|
type Task,
|
|
type ThreadDetail,
|
|
getRunDetail,
|
|
getThreadDetail,
|
|
listBlockedQueue,
|
|
listRuns,
|
|
} from '../lib/api';
|
|
import {
|
|
formatDateTime,
|
|
formatJson,
|
|
hasStructuredData,
|
|
pluralize,
|
|
statusLabel,
|
|
uniqueCount,
|
|
} from '../lib/format';
|
|
|
|
const terminalStatuses = new Set(['cancelled', 'completed', 'done', 'failed']);
|
|
|
|
export function RunsPage() {
|
|
const runsQuery = useQuery({
|
|
queryKey: ['runs'],
|
|
queryFn: listRuns,
|
|
refetchInterval: 15_000,
|
|
});
|
|
const [search, setSearch] = useState('');
|
|
const deferredSearch = useDeferredValue(search.trim().toLowerCase());
|
|
|
|
const runs = runsQuery.data ?? [];
|
|
const filteredRuns = runs.filter((item) => matchesRun(item, deferredSearch));
|
|
const totalRuns = runs.length;
|
|
const activeRuns = runs.filter((item) => !terminalStatuses.has(item.run.status)).length;
|
|
const blockedTasks = runs.reduce((sum, item) => sum + (item.task_counts.blocked ?? 0), 0);
|
|
const totalTasks = runs.reduce((sum, item) => sum + item.total_tasks, 0);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHero
|
|
eyebrow="Runs overview"
|
|
title="Watch the scheduler shape work across every active run."
|
|
description="This surface is read-only for now: scan run health, task distribution, and where blocked questions are starting to accumulate."
|
|
>
|
|
<Input
|
|
aria-label="Filter runs"
|
|
className="w-full md:max-w-sm"
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder="Filter by run id, goal, or summary"
|
|
value={search}
|
|
/>
|
|
</PageHero>
|
|
|
|
<MetricsGrid>
|
|
<MetricCard
|
|
label="Tracked runs"
|
|
value={String(totalRuns)}
|
|
caption={`${activeRuns} currently active`}
|
|
tone="default"
|
|
/>
|
|
<MetricCard
|
|
label="Total tasks"
|
|
value={String(totalTasks)}
|
|
caption={pluralize(totalTasks, 'task')}
|
|
tone="subtle"
|
|
/>
|
|
<MetricCard
|
|
label="Blocked tasks"
|
|
value={String(blockedTasks)}
|
|
caption={blockedTasks > 0 ? 'Need operator attention' : 'No current blockers'}
|
|
tone={blockedTasks > 0 ? 'accent' : 'subtle'}
|
|
/>
|
|
</MetricsGrid>
|
|
|
|
{runsQuery.isLoading ? (
|
|
<LoadingState count={4} />
|
|
) : runsQuery.isError ? (
|
|
<QueryErrorState
|
|
description="The runs endpoint did not return data. Make sure the operator API is running and the dev server can reach it."
|
|
onRetry={() => void runsQuery.refetch()}
|
|
title="Unable to load orchestration runs"
|
|
/>
|
|
) : filteredRuns.length === 0 ? (
|
|
<EmptyState
|
|
description={
|
|
deferredSearch
|
|
? 'No runs matched the current filter. Try a run id, goal phrase, or summary keyword.'
|
|
: 'No runs are present yet. Once the scheduler has state, this page will show it here.'
|
|
}
|
|
title={deferredSearch ? 'No matching runs' : 'No runs yet'}
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{filteredRuns.map((item) => (
|
|
<RunSummaryCard key={item.run.run_id} item={item} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function BlockedQueuePage() {
|
|
const blockedQuery = useQuery({
|
|
queryKey: ['blocked-queue'],
|
|
queryFn: listBlockedQueue,
|
|
refetchInterval: 10_000,
|
|
});
|
|
const [search, setSearch] = useState('');
|
|
const deferredSearch = useDeferredValue(search.trim().toLowerCase());
|
|
|
|
const blockedItems = blockedQuery.data ?? [];
|
|
const filteredItems = blockedItems.filter((item) => matchesBlockedItem(item, deferredSearch));
|
|
const impactedRuns = uniqueCount(blockedItems, (item) => item.run.run_id);
|
|
const waitingWorkers = uniqueCount(blockedItems, (item) => item.attempt.assigned_to);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHero
|
|
eyebrow="Blocked queue"
|
|
title="Surface every worker question before it stalls the run."
|
|
description="This queue aggregates blocked tasks across all runs using the current read-only API. It is the best place to spot response bottlenecks."
|
|
>
|
|
<Input
|
|
aria-label="Filter blocked tasks"
|
|
className="w-full md:max-w-sm"
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder="Filter by run, task, worker, or question"
|
|
value={search}
|
|
/>
|
|
</PageHero>
|
|
|
|
<MetricsGrid>
|
|
<MetricCard
|
|
label="Blocked tasks"
|
|
value={String(blockedItems.length)}
|
|
caption={blockedItems.length > 0 ? 'Awaiting operator input' : 'Queue is clear'}
|
|
tone={blockedItems.length > 0 ? 'accent' : 'subtle'}
|
|
/>
|
|
<MetricCard
|
|
label="Impacted runs"
|
|
value={String(impactedRuns)}
|
|
caption={pluralize(impactedRuns, 'run')}
|
|
tone="default"
|
|
/>
|
|
<MetricCard
|
|
label="Workers waiting"
|
|
value={String(waitingWorkers)}
|
|
caption={pluralize(waitingWorkers, 'worker')}
|
|
tone="subtle"
|
|
/>
|
|
</MetricsGrid>
|
|
|
|
{blockedQuery.isLoading ? (
|
|
<LoadingState count={3} />
|
|
) : blockedQuery.isError ? (
|
|
<QueryErrorState
|
|
description="The queue view composes run summaries with per-run blocked endpoints. Check that the operator API is reachable."
|
|
onRetry={() => void blockedQuery.refetch()}
|
|
title="Unable to load the blocked queue"
|
|
/>
|
|
) : filteredItems.length === 0 ? (
|
|
<EmptyState
|
|
description={
|
|
deferredSearch
|
|
? 'No blocked tasks matched the current filter.'
|
|
: 'No blocked tasks are present. This is where waiting questions will appear once runs start blocking.'
|
|
}
|
|
title={deferredSearch ? 'No matching blocked tasks' : 'Queue is clear'}
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{filteredItems.map((item) => (
|
|
<BlockedTaskCard key={`${item.run.run_id}-${item.task.task_id}-${item.attempt.attempt_no}`} item={item} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RunDetailPage({ runId }: { runId: string }) {
|
|
const runQuery = useQuery({
|
|
queryKey: ['run', runId],
|
|
queryFn: () => getRunDetail(runId),
|
|
refetchInterval: 10_000,
|
|
});
|
|
const [search, setSearch] = useState('');
|
|
const deferredSearch = useDeferredValue(search.trim().toLowerCase());
|
|
|
|
if (runQuery.isLoading) {
|
|
return <LoadingState count={4} />;
|
|
}
|
|
|
|
if (runQuery.isError) {
|
|
return (
|
|
<QueryErrorState
|
|
description="The selected run could not be loaded. It may have been removed, or the API may not be reachable."
|
|
onRetry={() => void runQuery.refetch()}
|
|
title={`Unable to load run ${runId}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const detail = runQuery.data;
|
|
if (!detail) {
|
|
return (
|
|
<QueryErrorState
|
|
description="The API returned an empty run detail payload."
|
|
onRetry={() => void runQuery.refetch()}
|
|
title={`Missing run data for ${runId}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const filteredTasks = detail.tasks.filter((task) => matchesTask(task, deferredSearch));
|
|
const blockedByTaskID = new Map(detail.blocked_tasks.map((item) => [item.task.task_id, item] as const));
|
|
const groupedTasks = groupTasks(filteredTasks);
|
|
const statusEntries = sortTaskCounts(detail.task_counts);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card tone={detail.blocked_tasks.length > 0 ? 'accent' : 'default'}>
|
|
<CardHeader className="gap-4">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge tone="primary">{detail.run.run_id}</Badge>
|
|
<StatusBadge status={detail.run.status} />
|
|
<Badge tone="neutral" variant="outline">
|
|
{detail.total_tasks} total tasks
|
|
</Badge>
|
|
</div>
|
|
<CardTitle className="type-display text-[clamp(2.2rem,4vw,4rem)] leading-[0.92] tracking-[-0.05em]">
|
|
{detail.run.goal}
|
|
</CardTitle>
|
|
<CardDescription className="max-w-3xl text-base leading-7">
|
|
{detail.run.summary}
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<CompactStat label="Created" value={formatDateTime(detail.run.created_at)} />
|
|
<CompactStat label="Updated" value={formatDateTime(detail.run.updated_at)} />
|
|
<CompactStat
|
|
label="Blocked now"
|
|
value={String(detail.blocked_tasks.length)}
|
|
/>
|
|
<CompactStat label="Run id" value={detail.run.run_id} />
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardFooter className="flex flex-wrap justify-between gap-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
{statusEntries.map(([status, count]) => (
|
|
<Badge key={status} tone={badgeToneForStatus(status)} variant="outline">
|
|
{statusLabel(status)} · {count}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button asChild size="sm" variant="ghost">
|
|
<Link to="/">Back to runs</Link>
|
|
</Button>
|
|
<Button asChild size="sm" variant="subtle">
|
|
<Link to="/blocked">Global blocked queue</Link>
|
|
</Button>
|
|
</div>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
{detail.blocked_tasks.length > 0 ? (
|
|
<Alert
|
|
icon={<SignalIcon />}
|
|
variant="warning"
|
|
>
|
|
<AlertTitle>{detail.blocked_tasks.length} blocked {pluralize(detail.blocked_tasks.length, 'task')}</AlertTitle>
|
|
<AlertDescription>
|
|
This run currently has work waiting on a reply. Open the blocked tab for the question summaries and thread links.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Tabs defaultValue="tasks">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<TabsList>
|
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
<TabsTrigger value="tasks">Task board</TabsTrigger>
|
|
<TabsTrigger value="blocked">
|
|
Blocked
|
|
<span className="ml-1 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_16%,transparent)] px-1.5 py-0.5 text-[0.68rem] text-[var(--color-primary)]">
|
|
{detail.blocked_tasks.length}
|
|
</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<Input
|
|
aria-label="Filter run tasks"
|
|
className="w-full lg:max-w-sm"
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder="Filter tasks by id, title, summary, or owner"
|
|
value={search}
|
|
/>
|
|
</div>
|
|
|
|
<TabsContent value="overview">
|
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.8fr)]">
|
|
<Card tone="subtle">
|
|
<CardHeader>
|
|
<CardTitle>Run posture</CardTitle>
|
|
<CardDescription>
|
|
High-level task distribution across the current run state.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
<MetricCard
|
|
label="Total tasks"
|
|
value={String(detail.total_tasks)}
|
|
caption={pluralize(detail.total_tasks, 'task')}
|
|
tone="default"
|
|
/>
|
|
<MetricCard
|
|
label="Blocked"
|
|
value={String(detail.blocked_tasks.length)}
|
|
caption={detail.blocked_tasks.length > 0 ? 'Needs answer' : 'No blockers'}
|
|
tone={detail.blocked_tasks.length > 0 ? 'accent' : 'subtle'}
|
|
/>
|
|
<MetricCard
|
|
label="Status buckets"
|
|
value={String(statusEntries.length)}
|
|
caption={pluralize(statusEntries.length, 'state')}
|
|
tone="subtle"
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Status mix</CardTitle>
|
|
<CardDescription>Current task counts grouped by orchestration status.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3">
|
|
{statusEntries.map(([status, count]) => (
|
|
<div
|
|
className="flex items-center justify-between rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-surface)_78%,white_22%)] px-4 py-3"
|
|
key={status}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<StatusBadge status={status} />
|
|
<span className="text-sm text-[var(--color-foreground)]">{statusLabel(status)}</span>
|
|
</div>
|
|
<span className="text-sm font-semibold text-[var(--color-foreground)]">{count}</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tasks">
|
|
{filteredTasks.length === 0 ? (
|
|
<EmptyState
|
|
description={
|
|
deferredSearch
|
|
? 'No tasks matched the current filter.'
|
|
: 'This run has no tasks yet.'
|
|
}
|
|
title={deferredSearch ? 'No matching tasks' : 'No tasks in this run'}
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{groupedTasks.map(([status, tasks]) => (
|
|
<Card key={status} tone={status === 'blocked' ? 'accent' : 'subtle'}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<CardTitle>{statusLabel(status)}</CardTitle>
|
|
<CardDescription>{pluralize(tasks.length, 'task')}</CardDescription>
|
|
</div>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3">
|
|
{tasks.map((task) => (
|
|
<TaskCard
|
|
blockedTask={blockedByTaskID.get(task.task_id)}
|
|
key={task.task_id}
|
|
task={task}
|
|
/>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="blocked">
|
|
{detail.blocked_tasks.length === 0 ? (
|
|
<EmptyState
|
|
description="No tasks in this run are currently waiting on a reply."
|
|
title="No blocked tasks"
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{detail.blocked_tasks.map((item) => (
|
|
<BlockedTaskCard
|
|
item={{
|
|
...item,
|
|
run: detail.run,
|
|
}}
|
|
key={`${item.task.task_id}-${item.attempt.attempt_no}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ThreadTimelinePage({ threadId }: { threadId: string }) {
|
|
const threadQuery = useQuery({
|
|
queryKey: ['thread', threadId],
|
|
queryFn: () => getThreadDetail(threadId),
|
|
refetchInterval: 5_000,
|
|
});
|
|
|
|
if (threadQuery.isLoading) {
|
|
return <LoadingState count={3} />;
|
|
}
|
|
|
|
if (threadQuery.isError) {
|
|
return (
|
|
<QueryErrorState
|
|
description="The selected thread could not be loaded. Make sure the thread id is still valid and the operator API can reach the shared SQLite database."
|
|
onRetry={() => void threadQuery.refetch()}
|
|
title={`Unable to load thread ${threadId}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const detail = threadQuery.data;
|
|
if (!detail) {
|
|
return (
|
|
<QueryErrorState
|
|
description="The API returned an empty thread payload."
|
|
onRetry={() => void threadQuery.refetch()}
|
|
title={`Missing thread data for ${threadId}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const artifactCount = detail.messages.reduce((sum, message) => sum + message.artifacts.length, 0);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card tone={detail.thread.status === 'blocked' ? 'accent' : 'default'}>
|
|
<CardHeader className="gap-4">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge tone="primary">{detail.thread.thread_id}</Badge>
|
|
<StatusBadge status={detail.thread.status} />
|
|
<PriorityBadge priority={detail.thread.priority} />
|
|
</div>
|
|
<CardTitle className="type-display text-[clamp(2.1rem,3.6vw,3.4rem)] leading-[0.94] tracking-[-0.05em]">
|
|
{detail.thread.subject}
|
|
</CardTitle>
|
|
<CardDescription className="max-w-3xl text-base leading-7">
|
|
Leader-to-worker timeline for task {detail.thread.task_id} in run {detail.thread.run_id}.
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<CompactStat label="Created by" value={detail.thread.created_by} />
|
|
<CompactStat label="Assigned to" value={detail.thread.assigned_to} />
|
|
<CompactStat label="Opened" value={formatDateTime(detail.thread.created_at)} />
|
|
<CompactStat label="Updated" value={formatDateTime(detail.thread.updated_at)} />
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardFooter className="flex flex-wrap justify-between gap-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button asChild size="sm" variant="ghost">
|
|
<Link params={{ runId: detail.thread.run_id }} to="/runs/$runId">
|
|
Back to run
|
|
</Link>
|
|
</Button>
|
|
<Button asChild size="sm" variant="subtle">
|
|
<Link to="/blocked">Blocked queue</Link>
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="outline">{pluralize(detail.messages.length, 'message')}</Badge>
|
|
<Badge variant="outline">{pluralize(artifactCount, 'artifact')}</Badge>
|
|
</div>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
{detail.thread.status === 'blocked' ? (
|
|
<Alert icon={<SignalIcon />} variant="warning">
|
|
<AlertTitle>Thread is blocked</AlertTitle>
|
|
<AlertDescription>
|
|
The active worker is waiting on an answer in this thread. Review the latest question before replying through the CLI or future operator actions.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{detail.messages.length === 0 ? (
|
|
<EmptyState
|
|
description="This thread exists, but it does not have any messages yet."
|
|
title="No timeline messages"
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{detail.messages.map((message, index) => (
|
|
<ThreadMessageCard
|
|
index={index}
|
|
key={message.message_id}
|
|
message={message}
|
|
total={detail.messages.length}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PageHero({
|
|
eyebrow,
|
|
title,
|
|
description,
|
|
children,
|
|
}: {
|
|
eyebrow: string;
|
|
title: string;
|
|
description: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="grid gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(18rem,0.75fr)]">
|
|
<Card tone="accent">
|
|
<CardHeader className="gap-4">
|
|
<div className="space-y-3">
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-[var(--color-primary)]">
|
|
{eyebrow}
|
|
</p>
|
|
<CardTitle className="type-display text-[clamp(2.3rem,5vw,4.6rem)] leading-[0.9] tracking-[-0.06em]">
|
|
{title}
|
|
</CardTitle>
|
|
<CardDescription className="max-w-3xl text-base leading-7">
|
|
{description}
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
<Card tone="subtle">
|
|
<CardHeader>
|
|
<CardTitle>Operator filter</CardTitle>
|
|
<CardDescription>
|
|
Narrow the current surface without leaving the page.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>{children}</CardContent>
|
|
</Card>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function MetricsGrid({ children }: { children: React.ReactNode }) {
|
|
return <section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{children}</section>;
|
|
}
|
|
|
|
function MetricCard({
|
|
label,
|
|
value,
|
|
caption,
|
|
tone,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
caption: string;
|
|
tone: 'accent' | 'default' | 'subtle';
|
|
}) {
|
|
return (
|
|
<Card tone={tone}>
|
|
<CardContent className="space-y-3 p-6">
|
|
<p className="text-[0.72rem] font-semibold uppercase tracking-[0.24em] text-[var(--color-muted-foreground)]">
|
|
{label}
|
|
</p>
|
|
<div className="flex items-end justify-between gap-3">
|
|
<p className="type-display text-[clamp(2rem,4vw,3.2rem)] leading-none tracking-[-0.05em] text-[var(--color-foreground)]">
|
|
{value}
|
|
</p>
|
|
<span className="text-sm leading-6 text-[var(--color-muted-foreground)]">{caption}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function RunSummaryCard({ item }: { item: RunListItem }) {
|
|
const blockedCount = item.task_counts.blocked ?? 0;
|
|
|
|
return (
|
|
<Card interactive tone={blockedCount > 0 ? 'accent' : 'default'}>
|
|
<CardHeader className="gap-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge tone="primary">{item.run.run_id}</Badge>
|
|
<StatusBadge status={item.run.status} />
|
|
</div>
|
|
<span className="text-xs text-[var(--color-muted-foreground)]">
|
|
Updated {formatDateTime(item.run.updated_at)}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<CardTitle className="type-display text-[2rem] leading-[0.96] tracking-[-0.05em]">
|
|
{item.run.goal}
|
|
</CardTitle>
|
|
<CardDescription className="text-base leading-7">{item.run.summary}</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="grid gap-3 sm:grid-cols-2">
|
|
<CompactStat label="Tasks" value={String(item.total_tasks)} />
|
|
<CompactStat label="Blocked" value={String(blockedCount)} />
|
|
{sortTaskCounts(item.task_counts).map(([status, count]) => (
|
|
<div
|
|
className="flex items-center justify-between rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-surface)_82%,white_18%)] px-4 py-3"
|
|
key={status}
|
|
>
|
|
<span className="text-sm text-[var(--color-muted-foreground)]">{statusLabel(status)}</span>
|
|
<span className="text-sm font-semibold text-[var(--color-foreground)]">{count}</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
|
|
<CardFooter className="gap-2">
|
|
<Button asChild size="sm" variant="primary">
|
|
<Link params={{ runId: item.run.run_id }} to="/runs/$runId">
|
|
Open run
|
|
</Link>
|
|
</Button>
|
|
<Button asChild size="sm" variant="ghost">
|
|
<Link to="/blocked">Blocked queue</Link>
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function TaskCard({
|
|
task,
|
|
blockedTask,
|
|
}: {
|
|
task: Task;
|
|
blockedTask?: BlockedTask;
|
|
}) {
|
|
return (
|
|
<article className="space-y-4 rounded-[1.25rem] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-card)_86%,white_14%)] p-4 shadow-[var(--shadow-xs)]">
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge tone="primary" variant="outline">
|
|
{task.task_id}
|
|
</Badge>
|
|
<StatusBadge status={task.status} />
|
|
<PriorityBadge priority={task.priority} />
|
|
</div>
|
|
<h3 className="text-base font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
|
|
{task.title}
|
|
</h3>
|
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">{task.summary}</p>
|
|
</div>
|
|
|
|
<dl className="grid gap-2 text-sm text-[var(--color-muted-foreground)]">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<dt>Owner</dt>
|
|
<dd className="font-medium text-[var(--color-foreground)]">{task.default_to || 'Unassigned'}</dd>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<dt>Latest attempt</dt>
|
|
<dd className="font-medium text-[var(--color-foreground)]">{task.latest_attempt_no || 0}</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
{blockedTask ? (
|
|
<div className="rounded-[1rem] border border-[color-mix(in_oklch,var(--color-warning)_24%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-warning)_12%,var(--color-card))] p-3">
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-[color-mix(in_oklch,var(--color-warning)_72%,var(--color-foreground))]">
|
|
Waiting on reply
|
|
</p>
|
|
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
|
|
{blockedTask.question.summary}
|
|
</p>
|
|
<p className="mt-1 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
{blockedTask.question.body}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{blockedTask ? (
|
|
<Button asChild size="sm" variant="primary">
|
|
<Link params={{ threadId: blockedTask.attempt.thread_id }} to="/threads/$threadId">
|
|
Open timeline
|
|
</Link>
|
|
</Button>
|
|
) : null}
|
|
{hasStructuredData(task.acceptance_json) ? (
|
|
<JsonDialog
|
|
description="Acceptance data is shown exactly as returned by the HTTP API."
|
|
label="Acceptance JSON"
|
|
title={`Acceptance for ${task.task_id}`}
|
|
value={task.acceptance_json}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function BlockedTaskCard({ item }: { item: BlockedQueueItem }) {
|
|
return (
|
|
<Card tone="accent">
|
|
<CardHeader className="gap-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge tone="primary">{item.run.run_id}</Badge>
|
|
<Badge variant="outline">{item.task.task_id}</Badge>
|
|
<PriorityBadge priority={item.task.priority} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<CardTitle>{item.task.title}</CardTitle>
|
|
<CardDescription className="text-base leading-7">{item.task.summary}</CardDescription>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2 text-sm text-[var(--color-muted-foreground)]">
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={item.task.status} />
|
|
<span>attempt {item.attempt.attempt_no}</span>
|
|
</div>
|
|
<span>assigned to {item.attempt.assigned_to}</span>
|
|
<span>{formatDateTime(item.question.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(16rem,0.7fr)]">
|
|
<div className="space-y-3 rounded-[1.25rem] border border-[color-mix(in_oklch,var(--color-warning)_26%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-warning)_10%,var(--color-card))] p-4">
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-[color-mix(in_oklch,var(--color-warning)_72%,var(--color-foreground))]">
|
|
Latest question
|
|
</p>
|
|
<h3 className="text-base font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
|
|
{item.question.summary}
|
|
</h3>
|
|
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
{item.question.body}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-3 text-sm">
|
|
<CompactStat label="From" value={item.question.from_agent} />
|
|
<CompactStat label="To" value={item.question.to_agent} />
|
|
<CompactStat label="Thread" value={item.attempt.thread_id} />
|
|
</div>
|
|
</CardContent>
|
|
|
|
<CardFooter className="flex flex-wrap gap-2">
|
|
<Button asChild size="sm" variant="primary">
|
|
<Link params={{ runId: item.run.run_id }} to="/runs/$runId">
|
|
Open run
|
|
</Link>
|
|
</Button>
|
|
<Button asChild size="sm" variant="subtle">
|
|
<Link params={{ threadId: item.attempt.thread_id }} to="/threads/$threadId">
|
|
Thread timeline
|
|
</Link>
|
|
</Button>
|
|
{hasStructuredData(item.question.payload_json) ? (
|
|
<JsonDialog
|
|
description="This is the raw question payload returned by the API."
|
|
label="Question payload"
|
|
title={`Payload for ${item.task.task_id}`}
|
|
value={item.question.payload_json}
|
|
/>
|
|
) : null}
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ThreadMessageCard({
|
|
index,
|
|
message,
|
|
total,
|
|
}: {
|
|
index: number;
|
|
message: Message;
|
|
total: number;
|
|
}) {
|
|
return (
|
|
<div className="grid gap-3 md:grid-cols-[2.5rem_minmax(0,1fr)]">
|
|
<div className="hidden md:flex md:flex-col md:items-center">
|
|
<div className="mt-3 h-3.5 w-3.5 rounded-full border-2 border-[var(--color-primary)] bg-[var(--color-card)]" />
|
|
{index < total - 1 ? (
|
|
<div className="mt-2 w-px flex-1 bg-[color-mix(in_oklch,var(--color-border)_72%,transparent)]" />
|
|
) : null}
|
|
</div>
|
|
|
|
<Card tone="subtle">
|
|
<CardHeader className="gap-3">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge tone="primary" variant="outline">
|
|
{message.kind}
|
|
</Badge>
|
|
<Badge variant="outline">{message.from_agent}</Badge>
|
|
<span className="text-sm text-[var(--color-muted-foreground)]">to</span>
|
|
<Badge variant="outline">{message.to_agent}</Badge>
|
|
</div>
|
|
<span className="text-xs text-[var(--color-muted-foreground)]">
|
|
{formatDateTime(message.created_at)}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<CardTitle>{message.summary}</CardTitle>
|
|
<CardDescription className="text-sm leading-6">
|
|
{message.message_id}
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="grid gap-4">
|
|
<p className="whitespace-pre-wrap text-sm leading-7 text-[var(--color-foreground)]">
|
|
{message.body}
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{hasStructuredData(message.payload_json) ? (
|
|
<JsonDialog
|
|
description="Raw message payload as returned by the backend."
|
|
label="Payload JSON"
|
|
title={`Payload for ${message.message_id}`}
|
|
value={message.payload_json}
|
|
/>
|
|
) : null}
|
|
{message.artifacts.length > 0 ? (
|
|
<Badge tone="warning" variant="outline">
|
|
{pluralize(message.artifacts.length, 'artifact')}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
|
|
{message.artifacts.length > 0 ? (
|
|
<div className="grid gap-3">
|
|
{message.artifacts.map((artifact) => (
|
|
<div
|
|
className="flex flex-col gap-3 rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-card)_90%,white_10%)] px-4 py-3 lg:flex-row lg:items-center lg:justify-between"
|
|
key={artifact.artifact_id}
|
|
>
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-[var(--color-foreground)]">{artifact.path}</p>
|
|
<p className="text-xs uppercase tracking-[0.16em] text-[var(--color-muted-foreground)]">
|
|
{artifact.kind}
|
|
</p>
|
|
</div>
|
|
{hasStructuredData(artifact.metadata_json) ? (
|
|
<JsonDialog
|
|
description="Artifact metadata emitted by the worker."
|
|
label="Artifact metadata"
|
|
title={`Artifact ${artifact.artifact_id}`}
|
|
value={artifact.metadata_json}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QueryErrorState({
|
|
title,
|
|
description,
|
|
onRetry,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
onRetry: () => void;
|
|
}) {
|
|
return (
|
|
<Alert icon={<SignalIcon />} variant="destructive">
|
|
<AlertTitle>{title}</AlertTitle>
|
|
<AlertDescription className="flex flex-col gap-3">
|
|
<span>{description}</span>
|
|
<div>
|
|
<Button onClick={onRetry} size="sm" variant="subtle">
|
|
Retry request
|
|
</Button>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
function EmptyState({
|
|
title,
|
|
description,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
}) {
|
|
return (
|
|
<Card tone="subtle">
|
|
<CardContent className="flex min-h-64 flex-col items-center justify-center gap-4 p-10 text-center">
|
|
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-[color-mix(in_oklch,var(--color-border)_76%,transparent)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] text-[var(--color-primary)]">
|
|
<InboxIcon />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h2 className="type-display text-[2rem] leading-none tracking-[-0.04em] text-[var(--color-foreground)]">
|
|
{title}
|
|
</h2>
|
|
<p className="mx-auto max-w-2xl text-sm leading-7 text-[var(--color-muted-foreground)]">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function LoadingState({ count }: { count: number }) {
|
|
return (
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
{Array.from({ length: count }, (_, index) => (
|
|
<Card key={index} tone="subtle">
|
|
<CardContent className="grid gap-4 p-6">
|
|
<div className="h-3 w-28 animate-pulse rounded-full bg-[color-mix(in_oklch,var(--color-border)_82%,white_18%)]" />
|
|
<div className="h-12 w-3/4 animate-pulse rounded-[1rem] bg-[color-mix(in_oklch,var(--color-border)_72%,white_28%)]" />
|
|
<div className="h-4 w-full animate-pulse rounded-full bg-[color-mix(in_oklch,var(--color-border)_70%,white_30%)]" />
|
|
<div className="h-4 w-5/6 animate-pulse rounded-full bg-[color-mix(in_oklch,var(--color-border)_70%,white_30%)]" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompactStat({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div className="rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-surface)_82%,white_18%)] px-4 py-3">
|
|
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-[var(--color-muted-foreground)]">
|
|
{label}
|
|
</p>
|
|
<p className="mt-2 break-all text-sm font-medium text-[var(--color-foreground)]">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JsonDialog({
|
|
label,
|
|
title,
|
|
description,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
title: string;
|
|
description: string;
|
|
value: unknown;
|
|
}) {
|
|
const formatted = formatJson(value);
|
|
const rows = Math.min(Math.max(formatted.split('\n').length, 8), 18);
|
|
|
|
return (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" variant="ghost">
|
|
{label}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent size="lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
<Textarea
|
|
className="type-mono text-xs"
|
|
readOnly
|
|
rows={rows}
|
|
value={formatted}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
return (
|
|
<Badge tone={badgeToneForStatus(status)} variant="subtle">
|
|
{statusLabel(status)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function PriorityBadge({ priority }: { priority: string }) {
|
|
const normalized = priority.toLowerCase();
|
|
const tone =
|
|
normalized === 'high'
|
|
? 'warning'
|
|
: normalized === 'urgent'
|
|
? 'destructive'
|
|
: normalized === 'low'
|
|
? 'neutral'
|
|
: 'primary';
|
|
|
|
return (
|
|
<Badge tone={tone} variant="outline">
|
|
{priority}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function badgeToneForStatus(status: string): 'destructive' | 'neutral' | 'primary' | 'success' | 'warning' {
|
|
switch (status) {
|
|
case 'blocked':
|
|
return 'warning';
|
|
case 'done':
|
|
case 'completed':
|
|
return 'success';
|
|
case 'failed':
|
|
case 'cancelled':
|
|
return 'destructive';
|
|
case 'active':
|
|
case 'in_progress':
|
|
return 'primary';
|
|
default:
|
|
return 'neutral';
|
|
}
|
|
}
|
|
|
|
function sortTaskCounts(counts: Record<string, number>) {
|
|
const order = ['blocked', 'in_progress', 'active', 'ready', 'pending', 'done', 'completed', 'failed', 'cancelled'];
|
|
const orderIndex = new Map(order.map((status, index) => [status, index] as const));
|
|
|
|
return Object.entries(counts).sort((left, right) => {
|
|
const leftOrder = orderIndex.get(left[0]) ?? Number.MAX_SAFE_INTEGER;
|
|
const rightOrder = orderIndex.get(right[0]) ?? Number.MAX_SAFE_INTEGER;
|
|
|
|
if (leftOrder !== rightOrder) {
|
|
return leftOrder - rightOrder;
|
|
}
|
|
|
|
return left[0].localeCompare(right[0]);
|
|
});
|
|
}
|
|
|
|
function groupTasks(tasks: Task[]) {
|
|
const grouped = new Map<string, Task[]>();
|
|
|
|
for (const task of tasks) {
|
|
const bucket = grouped.get(task.status);
|
|
if (bucket) {
|
|
bucket.push(task);
|
|
continue;
|
|
}
|
|
grouped.set(task.status, [task]);
|
|
}
|
|
|
|
return sortTaskCounts(
|
|
Object.fromEntries(Array.from(grouped.entries(), ([status, items]) => [status, items.length])),
|
|
).map(([status]) => [status, grouped.get(status) ?? []] as const);
|
|
}
|
|
|
|
function matchesRun(item: RunListItem, filter: string) {
|
|
if (!filter) {
|
|
return true;
|
|
}
|
|
|
|
return [item.run.run_id, item.run.goal, item.run.summary].some((value) =>
|
|
value.toLowerCase().includes(filter),
|
|
);
|
|
}
|
|
|
|
function matchesTask(task: Task, filter: string) {
|
|
if (!filter) {
|
|
return true;
|
|
}
|
|
|
|
return [task.task_id, task.title, task.summary, task.default_to, task.priority].some(
|
|
(value) => value?.toLowerCase().includes(filter) ?? false,
|
|
);
|
|
}
|
|
|
|
function matchesBlockedItem(item: BlockedQueueItem, filter: string) {
|
|
if (!filter) {
|
|
return true;
|
|
}
|
|
|
|
return [
|
|
item.run.run_id,
|
|
item.task.task_id,
|
|
item.task.title,
|
|
item.task.summary,
|
|
item.attempt.assigned_to,
|
|
item.question.summary,
|
|
item.question.body,
|
|
item.question.from_agent,
|
|
item.question.to_agent,
|
|
].some((value) => value.toLowerCase().includes(filter));
|
|
}
|
|
|
|
function SignalIcon() {
|
|
return (
|
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" viewBox="0 0 24 24">
|
|
<path
|
|
d="M4 12h3l2-5 4 11 2-6h5"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.8"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function InboxIcon() {
|
|
return (
|
|
<svg aria-hidden="true" className="h-6 w-6" fill="none" viewBox="0 0 24 24">
|
|
<path
|
|
d="M4 6.5h16v8.8l-2.4 2.7H6.4L4 15.3V6.5Z"
|
|
stroke="currentColor"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.8"
|
|
/>
|
|
<path
|
|
d="M4.8 14.5h4.5l1.5 2h2.4l1.5-2h4.5"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.8"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|