Submatter/apps/web/app/(app)/fleet/page.tsx
Randall Stillwell 6ccb060bea feat(second-brain): open self-hosted Obsidian for crews — harness-agnostic v0.1
- MCP create-backlog-item for natural language Plan/Epic/Task authoring
- Worker non-code: optional worktree + per-harness allowlist + doc_review→published
- Harness registry: harness_providers/role_harness_routes, WorkerAdapter (minimal|crewai|langgraph|remote-ag-ui), AG-UI POST /api/ag-ui, Settings → Harnesses with Test
- Plans tree: backlog.listTree + createPlan/Epic/Task + Start→Convoy, web CRUD without CLI
- Fleet: /fleet rollup across workspaces + SSE /api/fleet/events

Decision: docs/decisions/2026-08-25-second-brain-os-pivot.md
Convoy: .convoys/second-brain-os-v01

Co-authored-by: orchestrator
2026-08-25 16:20:53 -05:00

190 lines
8.6 KiB
TypeScript
Raw 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 { 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 ?? [];
// 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>
</div>
);
}