diff --git a/apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx b/apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx new file mode 100644 index 0000000..07eec93 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import * as React from "react"; +import { useParams } from "next/navigation"; +import { Plus, Radar } from "lucide-react"; + +import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel"; +import { ConvoyKanban, GateInbox } from "@/components/mission-control/convoy-kanban"; +import { CreateConvoyDialog } from "@/components/mission-control/create-convoy-dialog"; +import { Button } from "@/components/ui/button"; + +export default function MissionControlPage() { + const params = useParams(); + const workspaceSlug = params?.workspaceSlug as string | undefined; + const [selectedId, setSelectedId] = React.useState(null); + const [createOpen, setCreateOpen] = React.useState(false); + + if (!workspaceSlug) { + return null; + } + + return ( +
+
+
+
+ +
+
+

Mission control

+

+ Convoy kanban, human gates, and gate co-pilot +

+
+
+ +
+ +
+ + +
+ +
+ + +
+ + +
+ ); +} diff --git a/apps/web/components/layout/icon-rail.tsx b/apps/web/components/layout/icon-rail.tsx index 086751b..860d9b0 100644 --- a/apps/web/components/layout/icon-rail.tsx +++ b/apps/web/components/layout/icon-rail.tsx @@ -9,6 +9,7 @@ import { LayoutGrid, MoreHorizontal, Presentation, + Radar, Sparkles, UserPlus, Users, @@ -34,6 +35,7 @@ const NAV_ITEMS: NavItem[] = [ { id: "home", label: "Home", segment: "", icon: Home }, { id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid }, { id: "planner", label: "Planner", segment: "planner", icon: CalendarDays }, + { id: "mission-control", label: "Mission control", segment: "mission-control", icon: Radar }, { id: "ai", label: "AI", segment: "ai", icon: Sparkles }, { id: "teams", label: "Teams", segment: "teams", icon: Users }, { id: "docs", label: "Docs", segment: "docs", icon: FileText }, @@ -47,6 +49,7 @@ function workspaceHref(workspaceSegment: string, segment: string) { const RESERVED_ROOT_SEGMENTS = new Set([ "planner", + "mission-control", "ai", "teams", "docs", diff --git a/apps/web/components/layout/top-header.tsx b/apps/web/components/layout/top-header.tsx index 51e9a22..6020f57 100644 --- a/apps/web/components/layout/top-header.tsx +++ b/apps/web/components/layout/top-header.tsx @@ -17,6 +17,7 @@ import { Pencil, Plus, Presentation, + Radar, ScrollText, Search, Settings, @@ -164,6 +165,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) { Role routing + + workspace?.slug && router.push(`/${workspace.slug}/mission-control`) + } + > + + Mission control + diff --git a/apps/web/components/mission-control/convoy-detail-panel.tsx b/apps/web/components/mission-control/convoy-detail-panel.tsx new file mode 100644 index 0000000..31fb2ca --- /dev/null +++ b/apps/web/components/mission-control/convoy-detail-panel.tsx @@ -0,0 +1,181 @@ +"use client"; + +import * as React from "react"; +import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle"; +import { Loader2, Sparkles } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/trpc"; + +function gateActionsForStatus(status: ConvoyStatus): Array<{ + action: "approve" | "revise" | "request_changes" | "promote"; + label: string; + variant?: "default" | "outline" | "destructive"; +}> { + switch (status) { + case "convoy_review": + return [ + { action: "approve", label: "Approve plan" }, + { action: "revise", label: "Request changes", variant: "outline" }, + ]; + case "pr_review": + return [ + { action: "approve", label: "Approve merge" }, + { action: "request_changes", label: "Request changes", variant: "outline" }, + ]; + case "merged": + return [{ action: "promote", label: "Promote to prod" }]; + default: + return []; + } +} + +export function ConvoyDetailPanel({ + workspaceSlug, + convoyId, +}: { + workspaceSlug: string; + convoyId: string | null; +}) { + const utils = api.useUtils(); + const [copilotText, setCopilotText] = React.useState(null); + + const detailQuery = api.convoys.get.useQuery( + { workspace: workspaceSlug, convoyId: convoyId ?? "" }, + { enabled: Boolean(workspaceSlug && convoyId) }, + ); + + const gateMut = api.convoys.gateAction.useMutation({ + onSuccess: () => { + void utils.convoys.kanban.invalidate(); + if (convoyId) void utils.convoys.get.invalidate({ workspace: workspaceSlug, convoyId }); + }, + }); + + const copilotMut = api.convoys.gateCopilot.useMutation({ + onSuccess: (result) => setCopilotText(result.text), + }); + + if (!convoyId) { + return ( +
+ Select a convoy to review gates and co-pilot suggestions. +
+ ); + } + + if (detailQuery.isLoading) { + return ( +
+ + Loading convoy… +
+ ); + } + + const convoy = detailQuery.data?.convoy; + if (!convoy) { + return

Convoy not found.

; + } + + const status = convoy.status as ConvoyStatus; + const actions = gateActionsForStatus(status); + + return ( +
+
+

+ {convoy.content?.convoySlug ?? convoy.title} +

+

Status: {status}

+ {convoy.content?.successMetric && ( +

{convoy.content.successMetric}

+ )} +
+ + {convoy.description && ( +
+

Plan

+
{convoy.description}
+
+ )} + + {actions.length > 0 && ( +
+ {actions.map((item) => ( + + ))} +
+ )} + +
+
+

+ + Gate co-pilot +

+ +
+ {copilotMut.data && ( +

+ Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId}) +

+ )} + {copilotMut.error && ( +

{copilotMut.error.message}

+ )} + {copilotText && ( +
+

{copilotText}

+
+ )} +
+ + {(detailQuery.data?.briefs.length ?? 0) > 0 && ( +
+

Briefs

+
    + {detailQuery.data?.briefs.map((brief) => ( +
  • + {brief.title}{" "} + ({brief.status ?? "—"}) +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/components/mission-control/convoy-kanban.tsx b/apps/web/components/mission-control/convoy-kanban.tsx new file mode 100644 index 0000000..173f048 --- /dev/null +++ b/apps/web/components/mission-control/convoy-kanban.tsx @@ -0,0 +1,128 @@ +"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 ( +
+ + Loading kanban… +
+ ); + } + + const columns = kanbanQuery.data?.columns ?? []; + + return ( +
+ {columns.map((column) => ( +
+
+

+ {column.label} +

+

{column.convoys.length}

+
+
    + {column.convoys.map((convoy) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ); +} + +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 ( +
+

Needs you

+

Gate 1, 2, and 3 approvals

+ {inbox.length === 0 ? ( +

No pending gates.

+ ) : ( +
    + {inbox.map((convoy: ConvoyCard) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/mission-control/create-convoy-dialog.tsx b/apps/web/components/mission-control/create-convoy-dialog.tsx new file mode 100644 index 0000000..7d32627 --- /dev/null +++ b/apps/web/components/mission-control/create-convoy-dialog.tsx @@ -0,0 +1,171 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Loader2, Plus, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import type { ConvoyClassification } from "@tasks/shared/convoy-lifecycle"; + +const CLASSIFICATIONS: ConvoyClassification[] = [ + "feature", + "hotfix", + "docs-only", + "infra-only", + "server-only", + "config-only", +]; + +type FormState = { + slug: string; + classification: ConvoyClassification; + successMetric: string; + ideaMarkdown: string; +}; + +const EMPTY: FormState = { + slug: "", + classification: "feature", + successMetric: "", + ideaMarkdown: "", +}; + +export function CreateConvoyDialog({ + workspaceSlug, + open, + onOpenChange, + onCreated, +}: { + workspaceSlug: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (convoyId: string) => void; +}) { + const utils = api.useUtils(); + const [form, setForm] = React.useState(EMPTY); + + const createMut = api.convoys.create.useMutation({ + onSuccess: (result) => { + void utils.convoys.kanban.invalidate(); + onCreated(result.convoyId); + onOpenChange(false); + setForm(EMPTY); + }, + }); + + return ( + + + + +
+ + New convoy + + + + +
+ +
+
+ + setForm((f) => ({ ...f, slug: e.target.value }))} + /> +
+
+ + +
+
+ + setForm((f) => ({ ...f, successMetric: e.target.value }))} + /> +
+
+ +