Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions gui/src/components/codex-set/CustomLayerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export default function CustomLayerDialog({
* mid-edit, which is the whole point of moving between them.
*/
const draftsRef = useRef(new Map<string, { title: string; body: string }>());
const [parkedDirty, setParkedDirty] = useState(false);
const othersRef = useRef(others);
useEffect(() => { othersRef.current = others; }, [others]);
const editingId = layer?.id ?? null;
const lastIdRef = useRef(editingId);

Expand All @@ -92,8 +95,13 @@ export default function CustomLayerDialog({
const parked = editingId === null ? undefined : draftsRef.current.get(editingId);
setTitle(parked?.title ?? layer?.title ?? "");
setBody(parked?.body ?? layer?.body ?? "");
setParkedDirty([...draftsRef.current].some(([id, draft]) => {
if (id === editingId) return false;
const saved = othersRef.current.find(candidate => candidate.id === id);
return saved !== undefined && (draft.title !== saved.title || draft.body !== saved.body);
}));
}, [editingId, layer]);
const [confirmingDiscard, setConfirmingDiscard] = useState(false);
const [discardAction, setDiscardAction] = useState<"close" | "save" | null>(null);
const titleId = "codex-set-custom-dialog";

// Compare against what the editor OPENED with, seed included. Comparing against
Expand All @@ -102,6 +110,8 @@ export default function CustomLayerDialog({
const initialTitle = layer?.title ?? seed?.title ?? "";
const initialBody = layer?.body ?? seed?.body ?? "";
const dirty = title !== initialTitle || body !== initialBody;
// A parked draft is still live user work. Exclude the displayed layer because
// its inputs supersede the older parked copy when someone navigates back.

useEffect(() => {
const dialog = dialogRef.current;
Expand All @@ -114,16 +124,20 @@ export default function CustomLayerDialog({
}, []);

const requestClose = useCallback(() => {
if (dirty) { setConfirmingDiscard(true); return; }
if (dirty || parkedDirty) { setDiscardAction("close"); return; }
onClose();
}, [dirty, onClose]);
}, [dirty, parkedDirty, onClose]);

const handleCancel = useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
requestClose();
}, [requestClose]);

const draft: Draft = { id: layer?.id ?? null, title, body, enabled: layer?.enabled ?? true };
const requestSave = () => {
if (parkedDirty) { setDiscardAction("save"); return; }
onSave({ ...draft, body: normalizeBody(body) });
};
const problem = validateDraft(draft, others);
const normalized = normalizeBody(body);
const normalizationApplied = normalized !== body;
Expand Down Expand Up @@ -215,7 +229,7 @@ export default function CustomLayerDialog({
</ul>
)}

{confirmingDiscard ? (
{discardAction ? (
// The prompt text IS the accessible name. role="alertdialog" without one
// announces an unnamed dialog, so a screen-reader user is asked to confirm
// something the announcement never states.
Expand All @@ -225,10 +239,14 @@ export default function CustomLayerDialog({
aria-labelledby={titleId + "-discard"}
>
<span id={titleId + "-discard"} className="muted small">{t("codexSet.custom.discardPrompt")}</span>
<button type="button" className="btn btn-sm" onClick={() => setConfirmingDiscard(false)}>
<button type="button" className="btn btn-sm" onClick={() => setDiscardAction(null)}>
{t("codexSet.custom.keepEditing")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={onClose}>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={() => discardAction === "save" ? onSave({ ...draft, body: normalized }) : onClose()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the layer selected when Save was requested

When a parked draft triggers the Save confirmation, discardAction records only "save" while draft continues to follow the displayed layer. The header navigation remains enabled, so moving to another layer before clicking Discard makes this callback save that newly displayed layer instead of the layer for which Save was requested; pressing Escape similarly routes through requestClose and changes the pending action to "close". Capture the pending normalized draft when opening the confirmation, or lock navigation and treat Escape as cancellation while it is active.

AGENTS.md reference: gui/AGENTS.md:L31-L34

Useful? React with 👍 / 👎.

>
{t("common.discard")}
</button>
</div>
Expand All @@ -238,7 +256,7 @@ export default function CustomLayerDialog({
type="button"
className="btn btn-primary btn-sm"
disabled={problem !== null || busy}
onClick={() => onSave({ ...draft, body: normalized })}
onClick={requestSave}
>
{t("common.save")}
</button>
Expand Down
30 changes: 30 additions & 0 deletions gui/tests/codex-set-stack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,36 @@ test("8. an unsaved edit survives navigating away and back", async () => {
await act(async () => { root.unmount(); });
});

test("8b. closing or saving another layer warns about a parked edit", async () => {
const calls = stubRoutes(call => {
if (call.url.includes("/text")) return json({ ok: true, layers: {} });
if (call.method === "PUT") return json({ ok: true, changed: true, snapshot: snapshot({ custom: THREE }) });
return json(snapshot({ custom: THREE }));
});
const { container, root } = await mount();
await openEditor(container, "aaaaaa");
await act(async () => { typeInto(fields().body, "Parked work in progress."); });
await act(async () => { navButtons()[1]!.click(); });

await act(async () => { dialog().dispatchEvent(new testWindow.Event("cancel", { cancelable: true })); });
expect(dialog().querySelector(".codex-set-custom-dialog__discard")).not.toBeNull();
await act(async () => {
const keepEditing = [...dialog().querySelectorAll("button")].find(button => button.textContent?.includes("Keep editing"))!;
keepEditing.click();
});

const save = [...dialog().querySelectorAll("button")].find(button => button.textContent?.includes("Save"))!;
await act(async () => { save.click(); });
expect(calls.filter(call => call.method === "PUT")).toHaveLength(0);
expect(dialog().querySelector(".codex-set-custom-dialog__discard")).not.toBeNull();
await act(async () => {
const discard = [...dialog().querySelectorAll("button")].find(button => button.textContent?.includes("Discard"))!;
discard.click();
});
expect(calls.filter(call => call.method === "PUT")).toHaveLength(1);
await act(async () => { root.unmount(); });
});

test("10. one layer offers no navigation at all", async () => {
stubRoutes(call => (call.url.includes("/text") ? json({ ok: true, layers: {} }) : json(snapshot({ custom: [layer()] }))));
const { container, root } = await mount();
Expand Down
Loading