From 23cf7a3ebc60bfe3dde267f46ee6bb406c5fb4cc Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:26:52 +0900 Subject: [PATCH] fix(gui): protect parked prompt-layer drafts In-dialog navigation parked drafts in memory, but the close/save guard only considered the currently visible layer, so edits parked on other layers were silently discarded. Track whether any parked draft differs from its persisted sibling and route close and save through the existing discard confirmation so unsaved custom prompt-layer text is never lost without a prompt. --- .../codex-set/CustomLayerDialog.tsx | 32 +++++++++++++++---- gui/tests/codex-set-stack.test.tsx | 30 +++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/gui/src/components/codex-set/CustomLayerDialog.tsx b/gui/src/components/codex-set/CustomLayerDialog.tsx index 29efc6baccb..2221ab0a920 100644 --- a/gui/src/components/codex-set/CustomLayerDialog.tsx +++ b/gui/src/components/codex-set/CustomLayerDialog.tsx @@ -68,6 +68,9 @@ export default function CustomLayerDialog({ * mid-edit, which is the whole point of moving between them. */ const draftsRef = useRef(new Map()); + const [parkedDirty, setParkedDirty] = useState(false); + const othersRef = useRef(others); + useEffect(() => { othersRef.current = others; }, [others]); const editingId = layer?.id ?? null; const lastIdRef = useRef(editingId); @@ -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 @@ -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; @@ -114,9 +124,9 @@ 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(); @@ -124,6 +134,10 @@ export default function CustomLayerDialog({ }, [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; @@ -215,7 +229,7 @@ export default function CustomLayerDialog({ )} - {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. @@ -225,10 +239,14 @@ export default function CustomLayerDialog({ aria-labelledby={titleId + "-discard"} > {t("codexSet.custom.discardPrompt")} - - @@ -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")} diff --git a/gui/tests/codex-set-stack.test.tsx b/gui/tests/codex-set-stack.test.tsx index d28b9685857..c880b88fc4c 100644 --- a/gui/tests/codex-set-stack.test.tsx +++ b/gui/tests/codex-set-stack.test.tsx @@ -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();