diff --git a/client/src/pages/PromptManager.jsx b/client/src/pages/PromptManager.jsx index f1c2d319d2..d623626d09 100644 --- a/client/src/pages/PromptManager.jsx +++ b/client/src/pages/PromptManager.jsx @@ -34,6 +34,11 @@ const VALID_PROMPT_TABS = ['stages', 'variables', 'job-skills']; // when the fetch settles. const PAGE_SUBTITLE = 'Customize AI prompts for backend operations'; +// Every editable field of a prompt variable, so the dirty check and the blank +// form can't drift apart when a field is added. +const VAR_FORM_FIELDS = ['key', 'name', 'category', 'content']; +const BLANK_VAR_FORM = Object.freeze({ key: '', name: '', category: '', content: '' }); + export default function PromptManager() { const [searchParams, setSearchParams] = useSearchParams(); const rawTab = searchParams.get('tab'); @@ -118,7 +123,37 @@ export default function PromptManager() { }, [isStageDirty]); // Variable editing (selection is URL-driven — see selectedVar above) - const [varForm, setVarForm] = useState({ key: '', name: '', category: '', content: '' }); + const [varForm, setVarForm] = useState(BLANK_VAR_FORM); + // The last form the server confirmed (loaded or saved). Kept as a full copy + // rather than a boolean flag so typing an edit and undoing it back to the + // original stops counting as dirty. Mirrors the stage/job-skill guards. + const [savedVarForm, setSavedVarForm] = useState(BLANK_VAR_FORM); + // The variable slot the user asked to open while holding unsaved edits, + // awaiting confirmation: `{ key }`, where a null key is the blank "+" form. + // An object rather than a bare key so "new variable" is a real pending value + // instead of colliding with "nothing pending". + const [pendingVar, setPendingVar] = useState(null); + // Create mode counts too: a typed-but-unsaved draft is exactly the work the + // "+" button and a list click would otherwise throw away, so the baseline is + // the blank form rather than a `selectedVar` gate. + const isVarDirty = VAR_FORM_FIELDS.some((f) => varForm[f] !== savedVarForm[f]); + // Names the open editor the way its list row does, so the discard question + // quotes the label the user is actually looking at — the list still shows the + // stored name while an unsaved rename sits in the form. A create-mode draft + // has no row yet, so it falls back to its typed key. + const openVarLabel = selectedVar + ? (variables[selectedVar]?.name || selectedVar) + : (varForm.name || varForm.key || 'New Variable'); + // `saveVariable` resumes after an await holding the values its closure + // captured at click time, so it reads the live selection through this ref to + // tell "still the same editor" from "the user moved on mid-flight". + const varLiveRef = useRef(selectedVar); + varLiveRef.current = selectedVar; + // A clean editor has nothing to discard, so an armed row disarms itself the + // moment the edit is undone or saved. + useEffect(() => { + if (!isVarDirty) setPendingVar(null); + }, [isVarDirty]); // Stage creation const [creatingStage, setCreatingStage] = useState(false); @@ -304,44 +339,90 @@ export default function PromptManager() { useEffect(() => { if (!selectedVar) { varHydratedRef.current = null; - setVarForm({ key: '', name: '', category: '', content: '' }); + setVarForm(BLANK_VAR_FORM); + setSavedVarForm(BLANK_VAR_FORM); return; } if (variables[selectedVar] && varHydratedRef.current !== selectedVar) { const v = variables[selectedVar]; - setVarForm({ key: selectedVar, name: v.name || '', category: v.category || '', content: v.content || '' }); + const loaded = { key: selectedVar, name: v.name || '', category: v.category || '', content: v.content || '' }; + setVarForm(loaded); + // Baselined in the same tick as the editor state, so the dirty check can + // never see one without the other. + setSavedVarForm(loaded); varHydratedRef.current = selectedVar; } }, [selectedVar, variables]); + // Saving an existing variable used to deselect it and blank the editor, which + // read as a crash rather than a success (#6023). The editor now stays open on + // what was just saved, and a create adopts the new key as the selection. const saveVariable = async () => { setSaving(true); - const ok = await (selectedVar - ? savePromptVariable(selectedVar, varForm, { silent: true }) - : createPromptVariable(varForm, { silent: true })) + const sentFor = selectedVar; + const sentForm = varForm; + const ok = await (sentFor + ? savePromptVariable(sentFor, sentForm, { silent: true }) + : createPromptVariable(sentForm, { silent: true })) .then(() => true) .catch((err) => { toast.error('Failed to save variable: ' + err.message); return false; }); if (!ok) { setSaving(false); return; } setSaving(false); - setSelectedVar(null); - setVarForm({ key: '', name: '', category: '', content: '' }); + const label = sentForm.name || sentForm.key; + toast.success(`Variable "${label}" ${sentFor ? 'saved' : 'created'}`); + // Re-baseline only while the same editor is still open: a save that lands + // after the user moved on would otherwise stamp the outgoing variable's + // text as the incoming one's saved state. Edits typed while the request was + // open stay dirty, which is correct — the server never saw them. + if (sentFor === varLiveRef.current) { + setSavedVarForm(sentForm); + // Claim the hydration slot ahead of the refresh, so neither the reload + // nor the selection change re-hydrates over what the user is looking at. + if (!sentFor) varHydratedRef.current = sentForm.key; + } await loadData(); + // The URL adopts a newly created key only once the refreshed list holds it, + // or the editor flashes its "variable could not be found" panel on the way. + if (!sentFor && varLiveRef.current === null) setSelectedVar(sentForm.key); }; const deleteVariable = async (key) => { + const label = variables[key]?.name || key; const ok = await deletePromptVariable(key, { silent: true }) .then(() => true) .catch((err) => { toast.error('Failed to delete variable: ' + err.message); return false; }); if (!ok) return; - if (selectedVar === key) setSelectedVar(null); + toast.success(`Variable "${label}" deleted`); + if (selectedVar === key) switchVariable(null); await loadData(); }; - const newVariable = () => { - setSelectedVar(null); - setVarForm({ key: '', name: '', category: '', content: '' }); + // Opening another variable (or the blank "+" form) replaces the editor, so + // unsaved edits must be confirmed away first (#6023). The requested slot + // parks in `pendingVar` and an inline confirm row takes over its place in the + // list — no window.confirm. + const requestVariable = (key) => { + // Re-clicking the open variable is how a user backs out of the prompt from + // the list side; leaving it armed would strand the row mid-question. The + // "+" (null key) is exempt: in create mode it still resets the draft. + if (key !== null && key === selectedVar) { setPendingVar(null); return; } + if (isVarDirty) { cancelVarDelete(); setPendingVar({ key }); return; } + switchVariable(key); }; + // Editor state is cleared in the same tick as the selection, not left to the + // `selectedVar` effect — otherwise the frame between the two renders the + // OUTGOING variable's text and dirty badge under the incoming variable's row. + const switchVariable = (key) => { + setPendingVar(null); + varHydratedRef.current = null; + setVarForm(BLANK_VAR_FORM); + setSavedVarForm(BLANK_VAR_FORM); + setSelectedVar(key); + }; + + const newVariable = () => requestVariable(null); + const createStage = async () => { setSaving(true); const payload = { ...newStageForm }; @@ -901,8 +982,36 @@ export default function PromptManager() {
+ {/* The "+" has no list row of its own, so its discard question + takes the top of the list. The dirty check is part of the + render condition, not just of arming: undoing the edit while + the row is armed leaves nothing to discard. */} + {pendingVar && pendingVar.key === null && isVarDirty && ( + switchVariable(null)} + onCancel={() => setPendingVar(null)} + /> + )} {Object.entries(variables).sort(([a], [b]) => a.localeCompare(b)).map(([key, v]) => ( - isConfirmingVar(key) ? ( + (pendingVar && pendingVar.key === key && isVarDirty) ? ( + switchVariable(key)} + onCancel={() => setPendingVar(null)} + /> + ) : isConfirmingVar(key) ? (
) : (
-

- {selectedVar ? `Edit: ${selectedVar}` : 'New Variable'} -

+
+

+ {selectedVar ? `Edit: ${selectedVar}` : 'New Variable'} +

+ {isVarDirty && ( +
Unsaved changes
+ )} +