"use client"; import { MessageCircle, Users, Rocket, Loader2, Database, Share2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { useProjects } from "@/hooks/use-projects"; import type { BatchProposal, StartRoute } from "@/hooks/use-prompter"; import type { ProjectSummary } from "@/types"; import type { CellWork, DraftProposal } from "@/lib/api/prompter"; import { Team } from "@/types"; import { HelpTip } from "@/components/ui/help-tip"; /** The delivery cells a multi-cell draft fans out to (one the_work entry each). */ const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI]; const CELL_LABEL: Record = { backend: "Backend", frontend: "Frontend", ux_ui: "UX/UI", }; /** The project_ids a draft currently targets: the per-cell ``the_work[].project_id`` * set (the multi-select model), falling back to a legacy top-level project_id. * Only scoped ids count — an out-of-scope id is treated as unselected. */ function selectedProjectIds( draft: DraftProposal, scoped: Set, ): string[] { const work = Array.isArray(draft.the_work) ? draft.the_work : []; const pids = work .filter( (w): w is CellWork & { project_id: string } => !!w?.team && (CELL_TEAMS as readonly string[]).includes(w.team) && typeof w.project_id === "string" && w.project_id !== "" && scoped.has(w.project_id), ) .map((w) => w.project_id); if (pids.length > 0) return pids; if (draft.project_id && scoped.has(draft.project_id)) return [draft.project_id]; return []; } interface BatchReviewCardProps { batch: BatchProposal; /** The conflict-free waves (lists of draft indices), once previewed. */ waves: number[][] | null; /** The repos this MegaTask is scoped to — each task targets a subset of them. */ projectIds: string[]; onKeepChatting: () => void; /** Set the whole set of projects one task targets (multi-select across cells, * one repo per cell — the backend stores one project per cell). */ onSetProjects: (index: number, ids: string[]) => void; onConfirm: (route: StartRoute) => void; /** A launch is in flight — disable the actions so a double-click can't dupe. */ isLaunching?: boolean; } /** * The MegaTask review card: every task the agent proposed in one batch, each * with its target projects (a multi-select checkbox list — one task can span * several repos, one repo per delivery cell) and collision surface, plus the * conflict-free wave plan. The human reviews the whole batch and the sequencing, * picks the repos each task lands in, then picks one start path. */ export function BatchReviewCard({ batch, waves, projectIds, onKeepChatting, onSetProjects, onConfirm, isLaunching = false, }: BatchReviewCardProps) { const { data: allProjects = [] } = useProjects(); // Only the scoped repos are valid targets (the agent read only those). const scoped = new Set(projectIds); const scopedByCell = (cell: Team): ProjectSummary[] => allProjects.filter((p) => scoped.has(p.id) && p.assigned_cell === cell); const titleOf = (i: number): string => batch.drafts[i]?.title ?? `Task ${i + 1}`; // A task is mis-targeted when it has no project selected at all (the backend // re-asserts each targeted project is in scope and the batch spans ≥2 repos). const missingProject = batch.drafts.some( (d) => selectedProjectIds(d, scoped).length === 0, ); /** Toggle one project in a task's selection. A RoboCo project is per-cell and * the backend stores one project per cell, so checking a repo in a cell that * already has a different repo checked swaps it (unchecks the sibling). */ const toggle = (index: number, projectId: string) => { const draft = batch.drafts[index]; const current = selectedProjectIds(draft, scoped); const proj = allProjects.find((p) => p.id === projectId); const cell = proj?.assigned_cell; if (current.includes(projectId)) { onSetProjects( index, current.filter((id) => id !== projectId), ); return; } // Checking: drop any other project in the same cell (one repo per cell). const next = current.filter((id) => { const other = allProjects.find((p) => p.id === id); return other?.assigned_cell !== cell; }); onSetProjects(index, [...next, projectId]); }; return (
MegaTask: {batch.title || "Untitled"} {batch.drafts.length} tasks

One batch, sequenced into conflict-free waves. Each task keeps its own project, branch, and PR; the Main PM coordinates them all.

    {batch.drafts.map((draft, i) => { const selected = selectedProjectIds(draft, scoped); return (
  1. {/* Title is clamped to a fixed 2-line space so a long title can't grow the row (or, as a long unbroken token, blow the card width out and wreck the layout). min-w-0 lets the flex item shrink below min-content; break-words stops a token from overflowing; the full title is on the tooltip. */} {i + 1}. {draft.title}
    {draft.adds_migration && ( migration )} {draft.touches_shared && ( shared )}
    {(draft.objective || draft.description) && (

    {draft.objective || draft.description}

    )} {/* Multi-select project picker — one task can span several repos (one per delivery cell). Grouped by cell; one repo per cell. */}

    Projects {selected.length === 0 && "— pick at least one"}

    {CELL_TEAMS.map((cell) => { const repos = scopedByCell(cell); if (repos.length === 0) return null; return (
    {CELL_LABEL[cell] ?? cell}
    {repos.map((p) => { const checked = selected.includes(p.id); return ( ); })}
    ); })}
  2. ); })}
{/* Wave plan — how the batch will be sequenced */} {waves && waves.length > 0 && (

Wave plan ({waves.length} wave{waves.length === 1 ? "" : "s"})

    {waves.map((wave, w) => (
  1. titleOf(i)).join(", ")} > Wave {w + 1}:{" "} {wave.map((i) => titleOf(i)).join(", ")}
  2. ))}
)} {missingProject && (

Pick at least one project for every task before launching the MegaTask.

)}
{/* Board review & Start → the Board reviews the whole batch first */} {/* Approve & Start → straight to the Main PM, waves dispatch at once */}
); }