[feature] Panel per-cell project picker + pnpm format infra

MegaTask root-subtasks can fan out across cells (be+fe, fe+uxui). Since a
RoboCo project is per-cell (ProjectTable.assigned_cell), a monorepo is N
per-cell projects sharing one git_url — so multi-cell IS multi-project. The
batch-review card now shows one project Select per the_work entry, scoped to
that cell's repos, instead of one Select bound to a single top-level
project_id. confirmBatch validates each cell's project is in scope and the
batch still spans >=2 distinct projects.

- prompter.ts: CellWork gains optional project_id (the per-cell picker seam).
- batch-review-card.tsx: per-cell Selects (one per the_work entry), scoped to
  the cell's projects; legacy single-cell drafts keep the one-Select path.
- use-prompter.ts: updateBatchDraftProject edits per-cell (entryIndex);  confirmBatch validates every cell; batchFromEvent parses per-cell map.

Also adds the missing pnpm format infrastructure (the panel had no formatter
at all): prettier devDep + .prettierrc.json (default-style config: 80-col,
double-quote, semi, trailing-comma-all) + .prettierignore, plus format /
format:check scripts. Only the 3 changed files above were reformatted; the
~222 pre-existing non-compliant files are left untouched (a wholesale reformat
is a separate explicit decision, not bundled into this feature).
This commit is contained in:
Renn F
2026-06-27 00:02:17 +02:00
parent c03e76c433
commit cb5365a490
7 changed files with 271 additions and 93 deletions
+12
View File
@@ -0,0 +1,12 @@
# Build / generated output
.next/
out/
build/
coverage/
# Deps + lockfiles
node_modules/
pnpm-lock.yaml
# Generated
next-env.d.ts
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf"
}
+4 -1
View File
@@ -10,7 +10,9 @@
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --coverage", "test": "vitest run --coverage",
"test:watch": "vitest" "test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -66,6 +68,7 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"prettier": "^3.8.5",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5", "typescript": "^5",
+13 -3
View File
@@ -109,7 +109,7 @@ importers:
version: 10.1.0(@types/react@19.2.8)(react@19.2.3) version: 10.1.0(@types/react@19.2.8)(react@19.2.3)
recharts: recharts:
specifier: ^3.8.1 specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1) version: 3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
remark-gfm: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -162,6 +162,9 @@ importers:
jsdom: jsdom:
specifier: ^29.1.1 specifier: ^29.1.1
version: 29.1.1 version: 29.1.1
prettier:
specifier: ^3.8.5
version: 3.8.5
tailwindcss: tailwindcss:
specifier: ^4 specifier: ^4
version: 4.1.18 version: 4.1.18
@@ -3230,6 +3233,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier@3.8.5:
resolution: {integrity: sha512-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA==}
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1: pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -7228,6 +7236,8 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.8.5: {}
pretty-format@27.5.1: pretty-format@27.5.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@@ -7317,7 +7327,7 @@ snapshots:
react@19.2.3: {} react@19.2.3: {}
recharts@3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): recharts@3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1):
dependencies: dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3) '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
clsx: 2.1.1 clsx: 2.1.1
@@ -7327,7 +7337,7 @@ snapshots:
immer: 10.2.0 immer: 10.2.0
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
react-is: 16.13.1 react-is: 17.0.2
react-redux: 9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1) react-redux: 9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1)
reselect: 5.1.1 reselect: 5.1.1
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
@@ -20,15 +20,50 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
import type { BatchProposal, StartRoute } from "@/hooks/use-prompter"; import type { BatchProposal, StartRoute } from "@/hooks/use-prompter";
import type { CellWork, DraftProposal } from "@/lib/api/prompter";
import { Team } from "@/types";
/** 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<string, string> = {
backend: "Backend",
frontend: "Frontend",
ux_ui: "UX/UI",
};
/** One the_work entry whose team is a delivery cell — a per-cell project picker. */
interface CellEntry {
entry: CellWork;
entryIndex: number;
team: Team;
}
/** A draft's per-cell entries (the the_work slots that carry a cell team), in
* the_work order. Empty for a legacy single-cell draft with no cell the_work. */
function cellEntries(draft: DraftProposal): CellEntry[] {
return (draft.the_work ?? [])
.map((entry, entryIndex) => ({ entry, entryIndex, team: entry?.team }))
.filter(
(e): e is CellEntry =>
!!e.team && (CELL_TEAMS as readonly string[]).includes(e.team),
);
}
interface BatchReviewCardProps { interface BatchReviewCardProps {
batch: BatchProposal; batch: BatchProposal;
/** The conflict-free waves (lists of draft indices), once previewed. */ /** The conflict-free waves (lists of draft indices), once previewed. */
waves: number[][] | null; waves: number[][] | null;
/** The repos this MegaTask is scoped to — each task must target one of them. */ /** The repos this MegaTask is scoped to — each cell must target one of them. */
projectIds: string[]; projectIds: string[];
onKeepChatting: () => void; onKeepChatting: () => void;
onProjectChange: (index: number, projectId: string) => void; /** `entryIndex` is the the_work slot (the cell); -1 for a legacy single-cell
* draft with no per-cell map (sets the top-level project_id). */
onProjectChange: (
index: number,
entryIndex: number,
projectId: string,
) => void;
onConfirm: (route: StartRoute) => void; onConfirm: (route: StartRoute) => void;
/** A launch is in flight — disable the actions so a double-click can't dupe. */ /** A launch is in flight — disable the actions so a double-click can't dupe. */
isLaunching?: boolean; isLaunching?: boolean;
@@ -36,9 +71,11 @@ interface BatchReviewCardProps {
/** /**
* The MegaTask review card: every task the agent proposed in one batch, each * The MegaTask review card: every task the agent proposed in one batch, each
* with its target project (editable) and collision surface, plus the * with its per-cell target projects (editable) and collision surface, plus the
* conflict-free wave plan. The human reviews the whole batch and the sequencing, * conflict-free wave plan. A multi-cell task (be+fe, fe+uxui) shows one project
* fixes any task in the wrong repo, then picks one start path for all of them. * picker per cell, scoped to that cell's repos a RoboCo project is per-cell,
* so each cell lands in its own repo. The human reviews the whole batch and the
* sequencing, fixes any cell in the wrong repo, then picks one start path.
*/ */
export function BatchReviewCard({ export function BatchReviewCard({
batch, batch,
@@ -51,14 +88,21 @@ export function BatchReviewCard({
}: BatchReviewCardProps) { }: BatchReviewCardProps) {
const { data: allProjects = [] } = useProjects(); const { data: allProjects = [] } = useProjects();
// Only the scoped repos are valid targets (the agent read only those). // Only the scoped repos are valid targets (the agent read only those).
const projects = allProjects.filter((p) => projectIds.includes(p.id));
const scoped = new Set(projectIds); const scoped = new Set(projectIds);
const titleOf = (i: number): string => const titleOf = (i: number): string =>
batch.drafts[i]?.title ?? `Task ${i + 1}`; batch.drafts[i]?.title ?? `Task ${i + 1}`;
// A task is mis-targeted unless its project is one of the scoped repos. // A task is mis-targeted when any of its cells lacks a scoped project (a
const missingProject = batch.drafts.some( // multi-cell draft checks every the_work entry; a legacy single-cell draft
(d) => !d.project_id || !scoped.has(d.project_id), // with no cell map checks its top-level project_id).
); const missingProject = batch.drafts.some((d) => {
const entries = cellEntries(d);
if (entries.length > 0) {
return entries.some(
(ce) => !ce.entry.project_id || !scoped.has(ce.entry.project_id),
);
}
return !d.project_id || !scoped.has(d.project_id);
});
return ( return (
<Card className="border-primary/40 bg-primary/5"> <Card className="border-primary/40 bg-primary/5">
@@ -79,67 +123,121 @@ export function BatchReviewCard({
<CardContent className="space-y-3 pb-3"> <CardContent className="space-y-3 pb-3">
<ol className="space-y-2"> <ol className="space-y-2">
{batch.drafts.map((draft, i) => ( {batch.drafts.map((draft, i) => {
<li const entries = cellEntries(draft);
key={i} return (
className="rounded-md border bg-background/60 px-3 py-2 text-sm" <li
> key={i}
<div className="flex items-start justify-between gap-2"> className="rounded-md border bg-background/60 px-3 py-2 text-sm"
<span className="font-medium leading-tight"> >
{i + 1}. {draft.title} <div className="flex items-start justify-between gap-2">
</span> <span className="font-medium leading-tight">
<div className="flex shrink-0 items-center gap-1"> {i + 1}. {draft.title}
{draft.adds_migration && ( </span>
<Badge variant="outline" className="gap-1 text-xs"> <div className="flex shrink-0 items-center gap-1">
<Database className="h-3 w-3" /> {draft.adds_migration && (
migration <Badge variant="outline" className="gap-1 text-xs">
</Badge> <Database className="h-3 w-3" />
)} migration
{draft.touches_shared && ( </Badge>
<Badge variant="outline" className="gap-1 text-xs"> )}
<Share2 className="h-3 w-3" /> {draft.touches_shared && (
shared <Badge variant="outline" className="gap-1 text-xs">
</Badge> <Share2 className="h-3 w-3" />
)} shared
</Badge>
)}
</div>
</div> </div>
</div> {(draft.objective || draft.description) && (
{(draft.objective || draft.description) && ( <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground"> {draft.objective || draft.description}
{draft.objective || draft.description} </p>
</p> )}
)} {entries.length > 0 ? (
{/* Per-task project — editable so a misfiled task can be fixed */} /* Per-cell project picker one Select per the_work entry,
<div className="mt-1.5 flex items-center gap-2"> scoped to that cell's repos (a project is per-cell). */
<span className="text-xs text-muted-foreground">Project</span> <div className="mt-1.5 space-y-1">
<Select {entries.map(({ entry, entryIndex, team }) => {
value={ const cellProjects = allProjects.filter(
draft.project_id && scoped.has(draft.project_id) (p) => scoped.has(p.id) && p.assigned_cell === team,
? draft.project_id );
: "" const pid = entry.project_id ?? "";
} const ok = pid !== "" && scoped.has(pid);
onValueChange={(v) => onProjectChange(i, v)} return (
disabled={isLaunching} <div
> key={entryIndex}
<SelectTrigger className="flex items-center gap-2"
className={`h-7 flex-1 text-xs ${ >
draft.project_id && scoped.has(draft.project_id) <span className="w-16 shrink-0 text-xs text-muted-foreground">
? "" {CELL_LABEL[team] ?? team}
: "border-destructive" </span>
}`} <Select
> value={ok ? pid : ""}
<SelectValue placeholder="Pick a project…" /> onValueChange={(v) =>
</SelectTrigger> onProjectChange(i, entryIndex, v)
<SelectContent> }
{projects.map((p) => ( disabled={isLaunching}
<SelectItem key={p.id} value={p.id}> >
{p.name} <SelectTrigger
</SelectItem> className={`h-7 flex-1 text-xs ${
))} ok ? "" : "border-destructive"
</SelectContent> }`}
</Select> >
</div> <SelectValue placeholder="Pick a project…" />
</li> </SelectTrigger>
))} <SelectContent>
{cellProjects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
</div>
) : (
/* Legacy single-cell draft (no per-cell the_work) one Select
bound to the top-level project_id, scoped to all repos. */
<div className="mt-1.5 flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Project
</span>
<Select
value={
draft.project_id && scoped.has(draft.project_id)
? draft.project_id
: ""
}
onValueChange={(v) => onProjectChange(i, -1, v)}
disabled={isLaunching}
>
<SelectTrigger
className={`h-7 flex-1 text-xs ${
draft.project_id && scoped.has(draft.project_id)
? ""
: "border-destructive"
}`}
>
<SelectValue placeholder="Pick a project…" />
</SelectTrigger>
<SelectContent>
{allProjects
.filter((p) => scoped.has(p.id))
.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</li>
);
})}
</ol> </ol>
{/* Wave plan — how the batch will be sequenced */} {/* Wave plan — how the batch will be sequenced */}
@@ -161,7 +259,8 @@ export function BatchReviewCard({
{missingProject && ( {missingProject && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
Pick a project for every task before launching the MegaTask. Pick a project for every cell of every task before launching the
MegaTask.
</p> </p>
)} )}
+57 -17
View File
@@ -59,6 +59,26 @@ export interface BatchProposal {
/** Which start button the human pressed on the draft card. */ /** Which start button the human pressed on the draft card. */
export type StartRoute = "board" | "main_pm"; export type StartRoute = "board" | "main_pm";
/** The delivery cells that carry their own per-cell project in a MegaTask draft.
* A RoboCo project is per-cell (assigned_cell); a multi-cell draft puts one
* the_work entry per cell, each with its cell's project_id. */
const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI];
/** The per-cell project_ids a draft targets: one per the_work entry whose team
* is a delivery cell and that carries a project_id. Empty for a legacy
* single-cell draft that uses a top-level project_id instead. */
function draftCellProjectIds(draft: DraftProposal): string[] {
return (draft.the_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 !== "",
)
.map((w) => w.project_id);
}
export interface EditableDraft { export interface EditableDraft {
title: string; title: string;
description: string; description: string;
@@ -784,19 +804,25 @@ export function usePrompter() {
// Confirm a MegaTask — create the umbrella + sequenced root-subtasks, reap // Confirm a MegaTask — create the umbrella + sequenced root-subtasks, reap
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/** Reassign one task in the proposed MegaTask to a different project. Lets the /** Reassign one cell of one task in the proposed MegaTask to a different
* human fix a draft the agent put in the wrong (or no) repo before launch. * project. `entryIndex` is the the_work slot (the cell); pass -1 for a legacy
* single-cell draft that has no per-cell map (sets the top-level project_id).
* Project does not affect the wave plan (waves derive from collision surface), * Project does not affect the wave plan (waves derive from collision surface),
* so the previewed waves stay valid. */ * so the previewed waves stay valid. */
const updateBatchDraftProject = useCallback( const updateBatchDraftProject = useCallback(
(index: number, projectId: string) => { (index: number, entryIndex: number, projectId: string) => {
setBatch((prev) => { setBatch((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
drafts: prev.drafts.map((d, i) => drafts: prev.drafts.map((d, i) => {
i === index ? { ...d, project_id: projectId } : d, if (i !== index) return d;
), if (entryIndex < 0) return { ...d, project_id: projectId };
const the_work = (d.the_work ?? []).map((w, wi) =>
wi === entryIndex ? { ...w, project_id: projectId } : w,
);
return { ...d, the_work };
}),
}; };
}); });
}, },
@@ -819,18 +845,32 @@ export function usePrompter() {
); );
return; return;
} }
// Every task must target one of the scoped repos (the agent assigns it, // Every cell of every task must target one of the scoped repos (the agent
// the human can fix it). The backend re-asserts this authoritatively. // assigns it, the human can fix it). A multi-cell draft carries its
// per-cell project_ids in the_work[]; a legacy single-cell draft falls back
// to its top-level project_id. The backend re-asserts this authoritatively.
const scoped = scopeRef.current.projectIds; const scoped = scopeRef.current.projectIds;
const offender = batch.drafts.findIndex( const scopedSet = new Set(scoped);
(d) => !d.project_id || !scoped.includes(d.project_id), for (let i = 0; i < batch.drafts.length; i++) {
); const d = batch.drafts[i];
if (offender !== -1) { const pids = draftCellProjectIds(d);
toast.error( const targets =
`Task ${offender + 1} ("${batch.drafts[offender].title}") needs one of ` + pids.length > 0 ? pids : d.project_id ? [d.project_id] : [];
"this MegaTask's selected projects. Pick it in the review card.", if (targets.length === 0) {
); toast.error(
return; `Task ${i + 1} ("${d.title}") has no project. ` +
"Pick one for each of its cells in the review card.",
);
return;
}
const bad = targets.find((pid) => !scopedSet.has(pid));
if (bad !== undefined) {
toast.error(
`Task ${i + 1} ("${d.title}") targets a project outside this ` +
"MegaTask's selected repos. Pick it in the review card.",
);
return;
}
} }
launchingRef.current = true; launchingRef.current = true;
+5 -1
View File
@@ -7,11 +7,15 @@ import type { Team, TaskType, TaskNature, Complexity } from "@/types";
// the structured draft the agent proposes and the human confirms. // the structured draft the agent proposes and the human confirms.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** One cell's slice of the work — the per-cell breakdown of The Work. */ /** One cell's slice of the work the per-cell breakdown of The Work.
* `project_id` is the per-cell repo for a multi-cell MegaTask draft (a RoboCo
* project is per-cell; a monorepo is N per-cell projects sharing one git_url).
* A single-cell draft keeps its project at the DraftProposal top level. */
export interface CellWork { export interface CellWork {
team: Team; team: Team;
summary: string; summary: string;
items: string[]; items: string[];
project_id?: string | null;
} }
/** A structured task draft, mirroring the backend PrompterDraftTask. */ /** A structured task draft, mirroring the backend PrompterDraftTask. */