Submatter/apps/web/components/mission-control/convoy-kanban.tsx
Randall Stillwell ea70716fc6 feat(mission-control): convoy kanban, gate inbox, and co-pilot (Epic 5)
Adds mission control UI with human gate actions via tRPC, kanban columns from
the shared lifecycle enum, and gate co-pilot using resolveModel('gate-copilot').

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 15:01:03 -05:00

128 lines
4 KiB
TypeScript

"use client";
import * as React from "react";
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
type ConvoyCard = {
id: string;
title: string;
status: ConvoyStatus;
content: { convoySlug: string; classification: string; successMetric: string } | null;
};
export function ConvoyKanban({
workspaceSlug,
selectedId,
onSelect,
}: {
workspaceSlug: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const kanbanQuery = api.convoys.kanban.useQuery(
{ workspace: workspaceSlug },
{ enabled: Boolean(workspaceSlug) },
);
if (kanbanQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading kanban…
</div>
);
}
const columns = kanbanQuery.data?.columns ?? [];
return (
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
{columns.map((column) => (
<div
key={column.status}
className="flex w-56 shrink-0 flex-col rounded-lg border border-border bg-muted/20"
>
<div className="border-b border-border px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{column.label}
</p>
<p className="text-[10px] text-muted-foreground">{column.convoys.length}</p>
</div>
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto p-2">
{column.convoys.map((convoy) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => onSelect(convoy.id)}
className={cn(
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
selectedId === convoy.id && "ring-2 ring-primary",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
{convoy.content?.classification && (
<p className="mt-0.5 text-[10px] text-muted-foreground">
{convoy.content.classification}
</p>
)}
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
}
export function GateInbox({
workspaceSlug,
selectedId,
onSelect,
}: {
workspaceSlug: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const kanbanQuery = api.convoys.kanban.useQuery(
{ workspace: workspaceSlug },
{ enabled: Boolean(workspaceSlug) },
);
const inbox = kanbanQuery.data?.gateInbox ?? [];
return (
<div className="border-b border-border p-4">
<h2 className="text-sm font-semibold">Needs you</h2>
<p className="text-xs text-muted-foreground">Gate 1, 2, and 3 approvals</p>
{inbox.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">No pending gates.</p>
) : (
<ul className="mt-3 space-y-2">
{inbox.map((convoy: ConvoyCard) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => onSelect(convoy.id)}
className={cn(
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
selectedId === convoy.id && "ring-2 ring-amber-500",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
{gateLabelForStatus(convoy.status)}
</p>
</button>
</li>
))}
</ul>
)}
</div>
);
}