diff --git a/panel/src/hooks/__tests__/use-prompter.test.ts b/panel/src/hooks/__tests__/use-prompter.test.ts index 9d94609e..8f824f0e 100644 --- a/panel/src/hooks/__tests__/use-prompter.test.ts +++ b/panel/src/hooks/__tests__/use-prompter.test.ts @@ -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.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.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.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"); + }); }); diff --git a/panel/src/hooks/use-prompter.ts b/panel/src/hooks/use-prompter.ts index 33001047..e0fb525d 100644 --- a/panel/src/hooks/use-prompter.ts +++ b/panel/src/hooks/use-prompter.ts @@ -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; } /** 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, ): { the_work: CellWork[]; project_id: null } { const cellToPid = new Map(); 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(); + 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( + 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, + }, }; }); },