mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user