Submatter/apps/web/app/(app)/fleet/page.tsx
Randall Stillwell 6f139d6af2 feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12)
- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation
- Completion artifacts table 0017, deterministic summary template, gallery /artifacts
- Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask
- Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples
- Allow pr_review→merged as automation for webhook

Convoy: .convoys/owner-cockpit-v02

Co-authored-by: orchestrator
2026-08-25 17:49:38 -05:00

194 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import * as React from "react";
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2, Radar } from "lucide-react";
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
import { OpsCopilotFab } from "@/components/copilot/ops-copilot-panel";
import { FleetActivityFeed } from "@/components/fleet/fleet-activity-feed";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
function formatAge(date: Date | string): string {
const d = new Date(date);
const diff = Date.now() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}d`;
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours > 0) return `${hours}h`;
const mins = Math.floor(diff / (1000 * 60));
return `${mins}m`;
}
export default function FleetPage() {
const fleetQuery = api.convoys.fleet.useQuery();
const [selected, setSelected] = React.useState<{ convoyId: string; workspaceSlug: string } | null>(null);
if (fleetQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading fleet…
</div>
);
}
if (fleetQuery.error) {
return <p className="p-6 text-sm text-destructive">{fleetQuery.error.message}</p>;
}
const data = fleetQuery.data;
const byWorkspace = data?.byWorkspace ?? [];
const gateInboxAcross = data?.gateInboxAcross ?? [];
const defaultWorkspaceSlug =
selected?.workspaceSlug ?? byWorkspace[0]?.workspace.slug ?? gateInboxAcross[0]?.workspaceSlug ?? "";
// Group gate inbox by status for "× pr_review 2d" count display
const gateByStatus = new Map<string, typeof gateInboxAcross>();
for (const c of gateInboxAcross) {
const arr = gateByStatus.get(c.status) ?? [];
arr.push(c);
gateByStatus.set(c.status, arr);
}
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Radar className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">Fleet</h1>
<p className="text-xs text-muted-foreground">All workspaces · gates needing you · live convoy events</p>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-4 lg:grid-cols-[260px_1fr_340px]">
{/* Needs you */}
<div className="flex min-h-0 flex-col gap-4">
<section className="rounded-lg border border-border bg-card">
<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 inbox across all workspaces</p>
{gateInboxAcross.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
{[...gateByStatus.entries()].map(([status, list]) => (
<span key={status} className="rounded bg-amber-500/10 px-2 py-0.5 text-amber-700 dark:text-amber-300">
× {status} {list.length} · {formatAge(list[0]?.updatedAt ?? new Date())}
</span>
))}
</div>
)}
</div>
<div className="p-3">
{gateInboxAcross.length === 0 ? (
<p className="text-sm text-muted-foreground">No pending gates.</p>
) : (
<ul className="space-y-2">
{gateInboxAcross.map((convoy) => (
<li key={`${convoy.workspaceId}-${convoy.id}`}>
<button
type="button"
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
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",
selected?.convoyId === 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 as ConvoyStatus) ?? convoy.status}</p>
<p className="text-[10px] text-muted-foreground">
{convoy.workspaceName} · {convoy.workspaceSlug} · {formatAge(convoy.updatedAt)} ago
</p>
</button>
</li>
))}
</ul>
)}
</div>
</section>
<FleetActivityFeed />
</div>
{/* byWorkspace cards grid */}
<div className="min-h-0 space-y-4">
{byWorkspace.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">No workspaces or no convoys yet.</p>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{byWorkspace.map(({ workspace, convoys }) => {
const workspaceGateCount = gateInboxAcross.filter((c) => c.workspaceId === workspace.id).length;
return (
<div key={workspace.id} className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">{workspace.name}</h3>
<p className="text-xs text-muted-foreground">
{workspace.slug} · {convoys.length} convoys
{workspaceGateCount > 0 ? ` · ${workspaceGateCount} needing you` : ""}
</p>
</div>
<ul className="space-y-2 p-3">
{convoys.length === 0 ? (
<li className="text-xs text-muted-foreground">No convoys.</li>
) : (
convoys.slice(0, 8).map((convoy) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => setSelected({ convoyId: convoy.id, workspaceSlug: convoy.workspaceSlug })}
className={cn(
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
selected?.convoyId === convoy.id && "ring-2 ring-primary",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
<p className="text-[10px] text-muted-foreground">
{convoy.status} · {convoy.content?.classification ?? ""} · {formatAge(convoy.updatedAt)} ago
</p>
</button>
</li>
))
)}
{convoys.length > 8 && (
<li className="text-center text-xs text-muted-foreground">+{convoys.length - 8} more</li>
)}
</ul>
</div>
);
})}
</div>
)}
{/* byStatus summary */}
{data?.byStatus && Object.keys(data.byStatus).length > 0 && (
<div className="rounded-lg border border-border bg-muted/10 p-3">
<p className="mb-2 text-xs font-semibold uppercase text-muted-foreground">By status</p>
<div className="flex flex-wrap gap-2">
{Object.entries(data.byStatus).map(([status, list]) => (
<span key={status} className="rounded border border-border bg-background px-2 py-1 text-xs">
{status}: {(list as unknown[]).length}
</span>
))}
</div>
</div>
)}
</div>
{/* Detail panel workspace-agnostic */}
<aside className="min-h-0 border border-border bg-background lg:rounded-lg">
{selected ? (
<ConvoyDetailPanel workspaceSlug={selected.workspaceSlug} convoyId={selected.convoyId} />
) : (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
Select a convoy to review gates and co-pilot suggestions.
</div>
)}
</aside>
</div>
<OpsCopilotFab workspaceSlug={defaultWorkspaceSlug} />
</div>
);
}