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 { Team } from "@/types";
|
||||||
import {
|
import {
|
||||||
fillBatchProjects,
|
fillBatchProjects,
|
||||||
|
parkCellWork,
|
||||||
rebuildCellWork,
|
rebuildCellWork,
|
||||||
type BatchProposal,
|
type BatchProposal,
|
||||||
} from "@/hooks/use-prompter";
|
} from "@/hooks/use-prompter";
|
||||||
@@ -183,4 +184,105 @@ describe("rebuildCellWork", () => {
|
|||||||
expect(out.the_work).toEqual([]);
|
expect(out.the_work).toEqual([]);
|
||||||
expect(out.project_id).toBeNull();
|
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
|
/** 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
|
* 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 {
|
export interface BatchProposal {
|
||||||
title: string;
|
title: string;
|
||||||
drafts: DraftProposal[];
|
drafts: DraftProposal[];
|
||||||
dropped: number;
|
dropped: number;
|
||||||
|
parkedCellWork?: Record<number, CellWork[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which start button the human pressed on the draft card. */
|
/** 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
|
* (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
|
* — 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
|
* ``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(
|
export function rebuildCellWork(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
allProjects: { id: string; assigned_cell?: Team | "" }[],
|
allProjects: { id: string; assigned_cell?: Team | "" }[],
|
||||||
currentWork: CellWork[],
|
currentWork: CellWork[],
|
||||||
|
priorByCell?: ReadonlyMap<Team, CellWork>,
|
||||||
): { the_work: CellWork[]; project_id: null } {
|
): { the_work: CellWork[]; project_id: null } {
|
||||||
const cellToPid = new Map<Team, string>();
|
const cellToPid = new Map<Team, string>();
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
@@ -244,11 +259,18 @@ export function rebuildCellWork(
|
|||||||
}
|
}
|
||||||
return w;
|
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[] = [];
|
const appended: CellWork[] = [];
|
||||||
for (const [team, pid] of cellToPid) {
|
for (const [team, pid] of cellToPid) {
|
||||||
if (!covered.has(team)) {
|
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.
|
// Drop entries for cells no longer selected.
|
||||||
@@ -259,6 +281,26 @@ export function rebuildCellWork(
|
|||||||
return { the_work, project_id: null };
|
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
|
/** 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
|
* 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
|
* re-pick the projects they already scoped at intake. The intake prompt tells
|
||||||
@@ -1027,14 +1069,41 @@ export function usePrompter() {
|
|||||||
(index: number, ids: string[]) => {
|
(index: number, ids: string[]) => {
|
||||||
setBatch((prev) => {
|
setBatch((prev) => {
|
||||||
if (!prev) return 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 {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
drafts: prev.drafts.map((d, i) => {
|
drafts: prev.drafts.map((d, i) =>
|
||||||
if (i !== index) return d;
|
i === index
|
||||||
const work = Array.isArray(d.the_work) ? d.the_work : [];
|
? {
|
||||||
const rebuilt = rebuildCellWork(ids, allProjects, work);
|
...d,
|
||||||
return { ...d, the_work: rebuilt.the_work, project_id: null };
|
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