[F086] prompter: restore parked cell content on project toggle off/on

rebuildCellWork appended a blank {summary:'', items:[]} entry for a newly-
selected cell, so toggling a cell's project OFF then back ON in the MegaTask
review card discarded the agent-authored per-cell summary/items — the entry
was dropped on toggle-off and re-added blank on toggle-on.

Park each draft's last per-cell content in client-only BatchProposal state
(parkedCellWork, keyed by draft index — never sent to the backend; confirm
ships only title/drafts/project_ids/route, and it ride-alongs into the
localStorage persist slice so the restore survives a reload mid-review).
rebuildCellWork gains an optional priorByCell map: a re-added cell with no
live entry restores its parked summary/items (with the new project_id) in-
stead of blanking; a live entry still wins over a stale parked copy so an
in-place edit is never regressed. parkCellWork is the pure merge seam
(prevParked seeds, live work overwrites) the setBatchDraftProjects updater
calls — kept pure so the updater stays a thin caller.

Tests: rebuildCellWork restore/blank-fallback/live-wins + parkCellWork
retain/overwrite/merge (6 new), 19 GREEN. eslint/typecheck/prettier clean.
No wire-payload change, no regression to the fill/drop/one-repo-per-cell
invariants.
This commit is contained in:
Renn F
2026-06-28 19:07:20 +02:00
parent b3109feb46
commit 341e252f8c
2 changed files with 181 additions and 10 deletions
@@ -3,6 +3,7 @@ import type { CellWork, DraftProposal } from "@/lib/api/prompter";
import { Team } from "@/types";
import {
fillBatchProjects,
parkCellWork,
rebuildCellWork,
type BatchProposal,
} from "@/hooks/use-prompter";
@@ -183,4 +184,105 @@ describe("rebuildCellWork", () => {
expect(out.the_work).toEqual([]);
expect(out.project_id).toBeNull();
});
// F086: toggling a cell's project off then back on must NOT blank the
// agent-authored summary/items. The caller parks the cell's last content
// (the_work snapshot before the toggle-off) and passes it back as
// priorByCell; rebuildCellWork restores from it instead of appending a
// blank entry.
it("restores a re-selected cell's parked summary/items from priorByCell instead of blanking (F086)", () => {
const prior = new Map<Team, CellWork>([
[
Team.BACKEND,
{
team: Team.BACKEND,
summary: "agent-authored backend work",
items: ["endpoint", "migrations"],
},
],
]);
// currentWork has no backend entry (it was toggled off and dropped) —
// without priorByCell this would append {summary:"", items:[]}.
const out = rebuildCellWork([BE], projects, [], prior);
expect(out.the_work).toHaveLength(1);
expect(out.the_work[0].team).toBe(Team.BACKEND);
expect(out.the_work[0].summary).toBe("agent-authored backend work");
expect(out.the_work[0].items).toEqual(["endpoint", "migrations"]);
expect(out.the_work[0].project_id).toBe(BE); // restored content, new repo
});
it("a freshly-selected cell with no parked copy still gets a blank entry", () => {
// priorByCell has no frontend entry — append blank, unchanged behavior.
const prior = new Map<Team, CellWork>([
[Team.BACKEND, { team: Team.BACKEND, summary: "s", items: ["i"] }],
]);
const out = rebuildCellWork([FE], projects, [], prior);
expect(out.the_work[0]).toMatchObject({
team: Team.FRONTEND,
summary: "",
items: [],
});
});
it("an existing selected entry wins over a stale parked copy (edits are kept)", () => {
// The cell is currently selected with edited content; priorByCell holds
// an older copy. The live entry must win — restore must not regress an
// in-place edit back to the stale parked copy.
const current: CellWork[] = [
{
team: Team.BACKEND,
summary: "edited just now",
items: ["new"],
project_id: BE2,
},
];
const prior = new Map<Team, CellWork>([
[Team.BACKEND, { team: Team.BACKEND, summary: "stale", items: ["old"] }],
]);
const out = rebuildCellWork([BE], projects, current, prior);
expect(out.the_work[0].summary).toBe("edited just now");
expect(out.the_work[0].items).toEqual(["new"]);
expect(out.the_work[0].project_id).toBe(BE);
});
});
describe("parkCellWork — parked snapshot across a toggle-off/on (F086)", () => {
it("retains a deselected cell's content so a later re-select can restore it", () => {
// work still has backend (about to be toggled off); no prior parking yet.
const parked = parkCellWork(
[],
[{ team: Team.BACKEND, summary: "agent work", items: ["a"] }],
);
expect(parked).toEqual([
{ team: Team.BACKEND, summary: "agent work", items: ["a"] },
]);
// Now the cell is toggled off — work no longer has it, but the parked
// snapshot retains it.
const parked2 = parkCellWork(parked, []);
expect(parked2.map((w) => w.team)).toContain(Team.BACKEND);
expect(parked2.find((w) => w.team === Team.BACKEND)?.summary).toBe(
"agent work",
);
});
it("a live entry overwrites a stale parked copy (edits are parked)", () => {
const parked = parkCellWork(
[{ team: Team.BACKEND, summary: "old", items: [] }],
[{ team: Team.BACKEND, summary: "edited", items: ["x"] }],
);
expect(parked).toHaveLength(1);
expect(parked[0].summary).toBe("edited");
expect(parked[0].items).toEqual(["x"]);
});
it("merges parked cells for different teams with the live ones", () => {
const parked = parkCellWork(
[{ team: Team.BACKEND, summary: "parked-be", items: [] }],
[{ team: Team.FRONTEND, summary: "live-fe", items: ["y"] }],
);
const byTeam = new Map(parked.map((w) => [w.team, w]));
expect(byTeam.get(Team.BACKEND)?.summary).toBe("parked-be");
expect(byTeam.get(Team.FRONTEND)?.summary).toBe("live-fe");
});
});
+79 -10
View File
@@ -50,11 +50,18 @@ export type TargetKind = "project" | "product" | "megatask";
/** A MegaTask the agent proposed: a title + one draft per task (each draft
* carries its own project_id + collision surface). `dropped` is how many raw
* entries the agent emitted that were malformed and discarded. */
* entries the agent emitted that were malformed and discarded.
*
* ``parkedCellWork`` is client-only state (never sent to the backend — confirm
* ships only ``title``/``drafts``/``project_ids``/``route``): a snapshot of each
* draft's last per-cell content, keyed by draft index. It exists so toggling a
* cell's project OFF then back ON in the review card restores the agent-authored
* / user-edited summary+items instead of blanking them (F086). */
export interface BatchProposal {
title: string;
drafts: DraftProposal[];
dropped: number;
parkedCellWork?: Record<number, CellWork[]>;
}
/** Which start button the human pressed on the draft card. */
@@ -222,11 +229,19 @@ function batchFromEvent(
* (that cell no longer participates). The top-level ``project_id`` is cleared
* — a multi-repo task targets via its cell map, not a top-level repo. Pure, so
* ``setBatchDraftProjects`` can stay a thin setState wrapper and this is
* unit-tested directly. */
* unit-tested directly.
*
* ``priorByCell`` is the parked snapshot of a cell's last content (kept by the
* caller in ``BatchProposal.parkedCellWork``) so that toggling a cell's project
* OFF then back ON restores the agent-authored / user-edited summary+items
* instead of blanking them. A live entry in ``currentWork`` always wins over a
* stale parked copy — restore only applies to cells that are being re-added
* (not present in ``currentWork``). */
export function rebuildCellWork(
ids: string[],
allProjects: { id: string; assigned_cell?: Team | "" }[],
currentWork: CellWork[],
priorByCell?: ReadonlyMap<Team, CellWork>,
): { the_work: CellWork[]; project_id: null } {
const cellToPid = new Map<Team, string>();
for (const id of ids) {
@@ -244,11 +259,18 @@ export function rebuildCellWork(
}
return w;
});
// Append a minimal entry for a newly-selected cell not already in the_work.
// Append an entry for a newly-selected cell not already in the_work. If the
// cell was toggled off and back on, restore its parked summary/items instead
// of a blank entry — only when there's no live entry (covered) for it.
const appended: CellWork[] = [];
for (const [team, pid] of cellToPid) {
if (!covered.has(team)) {
appended.push({ team, summary: "", items: [], project_id: pid });
const prior = priorByCell?.get(team);
appended.push(
prior
? { ...prior, project_id: pid }
: { team, summary: "", items: [], project_id: pid },
);
}
}
// Drop entries for cells no longer selected.
@@ -259,6 +281,26 @@ export function rebuildCellWork(
return { the_work, project_id: null };
}
/** Merge a draft's previously-parked cell content with its current ``the_work``
* into the next parked snapshot (F086). Previously-parked cells (toggled off,
* no longer in ``work``) are retained so a later re-select can restore them;
* live entries in ``work`` overwrite any stale parked copy, so an in-place edit
* is the content that gets parked. Pure + unit-tested; the hook's
* ``setBatchDraftProjects`` updater is a thin caller of this. */
export function parkCellWork(
prevParked: readonly CellWork[],
work: readonly CellWork[],
): CellWork[] {
const byTeam = new Map<Team, CellWork>();
for (const w of prevParked) {
if (w?.team) byTeam.set(w.team, w);
}
for (const w of work) {
if (w?.team) byTeam.set(w.team, w);
}
return Array.from(byTeam.values());
}
/** Fill each draft's missing project assignment from the MegaTask's scoped
* repos, so the review card is pre-filled instead of forcing the human to
* re-pick the projects they already scoped at intake. The intake prompt tells
@@ -1027,14 +1069,41 @@ export function usePrompter() {
(index: number, ids: string[]) => {
setBatch((prev) => {
if (!prev) return prev;
const draft = prev.drafts[index];
const work: CellWork[] = Array.isArray(draft?.the_work)
? (draft!.the_work as CellWork[])
: [];
// Park each cell's last-seen content so a later re-select of a
// toggled-off cell restores its summary/items instead of blanking
// them (F086). Previously-parked cells seed the map, then the live
// entries win (so an in-place edit is the copy that gets parked).
const parkedSnapshot = parkCellWork(
prev.parkedCellWork?.[index] ?? [],
work,
);
const prior = new Map<Team, CellWork>(
parkedSnapshot
.filter((w): w is CellWork => !!w?.team)
.map((w) => [w.team, w]),
);
return {
...prev,
drafts: prev.drafts.map((d, i) => {
if (i !== index) return d;
const work = Array.isArray(d.the_work) ? d.the_work : [];
const rebuilt = rebuildCellWork(ids, allProjects, work);
return { ...d, the_work: rebuilt.the_work, project_id: null };
}),
drafts: prev.drafts.map((d, i) =>
i === index
? {
...d,
the_work: rebuildCellWork(ids, allProjects, work, prior)
.the_work,
project_id: null,
}
: d,
),
// Persist the parked snapshot (every cell's latest content, selected
// or not) so the next toggle can restore from it.
parkedCellWork: {
...(prev.parkedCellWork ?? {}),
[index]: parkedSnapshot,
},
};
});
},