From cb5365a490b7f0cc6596d1a70b259b72ea0b3841 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sat, 27 Jun 2026 00:02:17 +0200 Subject: [PATCH] [feature] Panel per-cell project picker + pnpm format infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- panel/.prettierignore | 12 + panel/.prettierrc.json | 10 + panel/package.json | 5 +- panel/pnpm-lock.yaml | 16 +- .../components/prompter/batch-review-card.tsx | 241 ++++++++++++------ panel/src/hooks/use-prompter.ts | 74 ++++-- panel/src/lib/api/prompter.ts | 6 +- 7 files changed, 271 insertions(+), 93 deletions(-) create mode 100644 panel/.prettierignore create mode 100644 panel/.prettierrc.json diff --git a/panel/.prettierignore b/panel/.prettierignore new file mode 100644 index 00000000..2f0e5146 --- /dev/null +++ b/panel/.prettierignore @@ -0,0 +1,12 @@ +# Build / generated output +.next/ +out/ +build/ +coverage/ + +# Deps + lockfiles +node_modules/ +pnpm-lock.yaml + +# Generated +next-env.d.ts \ No newline at end of file diff --git a/panel/.prettierrc.json b/panel/.prettierrc.json new file mode 100644 index 00000000..791565d5 --- /dev/null +++ b/panel/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2, + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf" +} \ No newline at end of file diff --git a/panel/package.json b/panel/package.json index 88ddcfbf..9287e356 100644 --- a/panel/package.json +++ b/panel/package.json @@ -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", diff --git a/panel/pnpm-lock.yaml b/panel/pnpm-lock.yaml index e649e684..3ac82d39 100644 --- a/panel/pnpm-lock.yaml +++ b/panel/pnpm-lock.yaml @@ -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 diff --git a/panel/src/components/prompter/batch-review-card.tsx b/panel/src/components/prompter/batch-review-card.tsx index 1121bbae..ebd4e325 100644 --- a/panel/src/components/prompter/batch-review-card.tsx +++ b/panel/src/components/prompter/batch-review-card.tsx @@ -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 = { + 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 ( @@ -79,67 +123,121 @@ export function BatchReviewCard({
    - {batch.drafts.map((draft, i) => ( -
  1. -
    - - {i + 1}. {draft.title} - -
    - {draft.adds_migration && ( - - - migration - - )} - {draft.touches_shared && ( - - - shared - - )} + {batch.drafts.map((draft, i) => { + const entries = cellEntries(draft); + return ( +
  2. +
    + + {i + 1}. {draft.title} + +
    + {draft.adds_migration && ( + + + migration + + )} + {draft.touches_shared && ( + + + shared + + )} +
    - - {(draft.objective || draft.description) && ( -

    - {draft.objective || draft.description} -

    - )} - {/* Per-task project — editable so a misfiled task can be fixed */} -
    - Project - -
    -
  3. - ))} + {(draft.objective || draft.description) && ( +

    + {draft.objective || draft.description} +

    + )} + {entries.length > 0 ? ( + /* Per-cell project picker — one Select per the_work entry, + scoped to that cell's repos (a project is per-cell). */ +
    + {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 ( +
    + + {CELL_LABEL[team] ?? team} + + +
    + ); + })} +
    + ) : ( + /* Legacy single-cell draft (no per-cell the_work) — one Select + bound to the top-level project_id, scoped to all repos. */ +
    + + Project + + +
    + )} + + ); + })}
{/* Wave plan — how the batch will be sequenced */} @@ -161,7 +259,8 @@ export function BatchReviewCard({ {missingProject && (

- Pick a project for every task before launching the MegaTask. + Pick a project for every cell of every task before launching the + MegaTask.

)} diff --git a/panel/src/hooks/use-prompter.ts b/panel/src/hooks/use-prompter.ts index 923f8eef..792693b7 100644 --- a/panel/src/hooks/use-prompter.ts +++ b/panel/src/hooks/use-prompter.ts @@ -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,18 +845,32 @@ 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) { - toast.error( - `Task ${offender + 1} ("${batch.drafts[offender].title}") needs one of ` + - "this MegaTask's selected projects. Pick it in the review card.", - ); - return; + 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 ${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; diff --git a/panel/src/lib/api/prompter.ts b/panel/src/lib/api/prompter.ts index 7a22caff..0ec1d1de 100644 --- a/panel/src/lib/api/prompter.ts +++ b/panel/src/lib/api/prompter.ts @@ -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. */