[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",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage",
"test:watch": "vitest"
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -66,6 +68,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.1",
"jsdom": "^29.1.1",
"prettier": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
+13 -3
View File
@@ -109,7 +109,7 @@ importers:
version: 10.1.0(@types/react@19.2.8)(react@19.2.3)
recharts:
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:
specifier: ^4.0.1
version: 4.0.1
@@ -162,6 +162,9 @@ importers:
jsdom:
specifier: ^29.1.1
version: 29.1.1
prettier:
specifier: ^3.8.5
version: 3.8.5
tailwindcss:
specifier: ^4
version: 4.1.18
@@ -3230,6 +3233,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
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:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -7228,6 +7236,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.8.5: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -7317,7 +7327,7 @@ snapshots:
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:
'@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
@@ -7327,7 +7337,7 @@ snapshots:
immer: 10.2.0
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)
reselect: 5.1.1
tiny-invariant: 1.3.3
@@ -20,15 +20,50 @@ import {
} from "@/components/ui/select";
import { useProjects } from "@/hooks/use-projects";
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 {
batch: BatchProposal;
/** The conflict-free waves (lists of draft indices), once previewed. */
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[];
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;
/** A launch is in flight — disable the actions so a double-click can't dupe. */
isLaunching?: boolean;
@@ -36,9 +71,11 @@ interface BatchReviewCardProps {
/**
* The MegaTask review card: every task the agent proposed in one batch, each
* with its target project (editable) and collision surface, plus the
* conflict-free wave plan. The human reviews the whole batch and the sequencing,
* fixes any task in the wrong repo, then picks one start path for all of them.
* with its per-cell target projects (editable) and collision surface, plus the
* conflict-free wave plan. A multi-cell task (be+fe, fe+uxui) shows one project
* 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({
batch,
@@ -51,14 +88,21 @@ export function BatchReviewCard({
}: BatchReviewCardProps) {
const { data: allProjects = [] } = useProjects();
// 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 titleOf = (i: number): string =>
batch.drafts[i]?.title ?? `Task ${i + 1}`;
// A task is mis-targeted unless its project is one of the scoped repos.
const missingProject = batch.drafts.some(
(d) => !d.project_id || !scoped.has(d.project_id),
// A task is mis-targeted when any of its cells lacks a scoped project (a
// multi-cell draft checks every the_work entry; a legacy single-cell draft
// 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 (
<Card className="border-primary/40 bg-primary/5">
@@ -79,7 +123,9 @@ export function BatchReviewCard({
<CardContent className="space-y-3 pb-3">
<ol className="space-y-2">
{batch.drafts.map((draft, i) => (
{batch.drafts.map((draft, i) => {
const entries = cellEntries(draft);
return (
<li
key={i}
className="rounded-md border bg-background/60 px-3 py-2 text-sm"
@@ -108,16 +154,64 @@ export function BatchReviewCard({
{draft.objective || draft.description}
</p>
)}
{/* Per-task project — editable so a misfiled task can be fixed */}
{entries.length > 0 ? (
/* Per-cell project picker — one Select per the_work entry,
scoped to that cell's repos (a project is per-cell). */
<div className="mt-1.5 space-y-1">
{entries.map(({ entry, entryIndex, team }) => {
const cellProjects = allProjects.filter(
(p) => scoped.has(p.id) && p.assigned_cell === team,
);
const pid = entry.project_id ?? "";
const ok = pid !== "" && scoped.has(pid);
return (
<div
key={entryIndex}
className="flex items-center gap-2"
>
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{CELL_LABEL[team] ?? team}
</span>
<Select
value={ok ? pid : ""}
onValueChange={(v) =>
onProjectChange(i, entryIndex, v)
}
disabled={isLaunching}
>
<SelectTrigger
className={`h-7 flex-1 text-xs ${
ok ? "" : "border-destructive"
}`}
>
<SelectValue placeholder="Pick a project…" />
</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>
<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, v)}
onValueChange={(v) => onProjectChange(i, -1, v)}
disabled={isLaunching}
>
<SelectTrigger
@@ -130,7 +224,9 @@ export function BatchReviewCard({
<SelectValue placeholder="Pick a project…" />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
{allProjects
.filter((p) => scoped.has(p.id))
.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
@@ -138,8 +234,10 @@ export function BatchReviewCard({
</SelectContent>
</Select>
</div>
)}
</li>
))}
);
})}
</ol>
{/* Wave plan — how the batch will be sequenced */}
@@ -161,7 +259,8 @@ export function BatchReviewCard({
{missingProject && (
<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>
)}
+54 -14
View File
@@ -59,6 +59,26 @@ export interface BatchProposal {
/** Which start button the human pressed on the draft card. */
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 {
title: string;
description: string;
@@ -784,19 +804,25 @@ export function usePrompter() {
// Confirm a MegaTask — create the umbrella + sequenced root-subtasks, reap
// -----------------------------------------------------------------------
/** Reassign one task in the proposed MegaTask to a different project. Lets the
* human fix a draft the agent put in the wrong (or no) repo before launch.
/** Reassign one cell of one task in the proposed MegaTask to a different
* 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),
* so the previewed waves stay valid. */
const updateBatchDraftProject = useCallback(
(index: number, projectId: string) => {
(index: number, entryIndex: number, projectId: string) => {
setBatch((prev) => {
if (!prev) return prev;
return {
...prev,
drafts: prev.drafts.map((d, i) =>
i === index ? { ...d, project_id: projectId } : d,
),
drafts: prev.drafts.map((d, i) => {
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,19 +845,33 @@ export function usePrompter() {
);
return;
}
// Every task must target one of the scoped repos (the agent assigns it,
// the human can fix it). The backend re-asserts this authoritatively.
// Every cell of every task must target one of the scoped repos (the agent
// 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 offender = batch.drafts.findIndex(
(d) => !d.project_id || !scoped.includes(d.project_id),
);
if (offender !== -1) {
const scopedSet = new Set(scoped);
for (let i = 0; i < batch.drafts.length; i++) {
const d = batch.drafts[i];
const pids = draftCellProjectIds(d);
const targets =
pids.length > 0 ? pids : d.project_id ? [d.project_id] : [];
if (targets.length === 0) {
toast.error(
`Task ${offender + 1} ("${batch.drafts[offender].title}") needs one of ` +
"this MegaTask's selected projects. Pick it in the review card.",
`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;
setIsLaunching(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.
// ---------------------------------------------------------------------------
/** 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 {
team: Team;
summary: string;
items: string[];
project_id?: string | null;
}
/** A structured task draft, mirroring the backend PrompterDraftTask. */