"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 [noteBody, setNoteBody] = React.useState(""); const [noteDelivery, setNoteDelivery] = React.useState<"queued" | "direct">("queued"); 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), }); const operatorNotesQuery = api.convoys.listOperatorNotes.useQuery( { workspace: workspaceSlug, convoyId: convoyId ?? "" }, { enabled: Boolean(workspaceSlug && convoyId) }, ); const postNoteMut = api.convoys.postOperatorNote.useMutation({ onSuccess: () => { setNoteBody(""); void operatorNotesQuery.refetch(); }, }); 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}

)}

Worker checkpoints

{detailQuery.data?.checkpoints.count ?? 0} checkpoint {(detailQuery.data?.checkpoints.count ?? 0) === 1 ? "" : "s"} {detailQuery.data?.checkpoints.lastOccurredAt ? ` · last ${new Date(detailQuery.data.checkpoints.lastOccurredAt).toLocaleString()}` : ""}

{detailQuery.data?.pr?.prUrl && (

PR

{detailQuery.data.pr.prUrl} {typeof detailQuery.data.pr.prNumber === "number" && detailQuery.data.pr.prNumber ? ` (#${detailQuery.data.pr.prNumber})` : ""}
)} {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 ?? "—"})
  • ))}
)} {status === "in_progress" && convoyId && (

Operator notes — in progress thread

Distinct from gate co-pilot. Queued notes inject at next tool round; direct messages prepend before next model call.

{(operatorNotesQuery.data ?? []).length === 0 ? (

No operator messages yet.

) : ( (operatorNotesQuery.data ?? []).map((note) => (
{note.delivery} {new Date(note.createdAt).toLocaleString()} {note.deliveredAt && ( delivered {new Date(note.deliveredAt).toLocaleString()} )}

{note.body}

author {note.authorUserId.slice(0, 8)}

)) )}