Problem
In client/src/pages/PromptManager.jsx:607-613, selecting another stage in the prompt stages list directly calls setSelectedStage(name):
{groupStages.map(([name, config]) => (
<button
key={name}
onClick={() => setSelectedStage(name)}
...
Unlike the Job Skills tab in the same page (which implements an inline confirm row via isJobSkillDirty and pendingJobSkill, added in #3939), the Stages tab has no dirty tracking or unsaved edits guard. When selectedStage updates in the URL, the useEffect hook at client/src/pages/PromptManager.jsx:171-195 immediately triggers:
useEffect(() => {
if (!selectedStage) { setStageTemplate(''); setStageConfig({}); setPreview(''); return; }
let cancelled = false;
getPrompt(selectedStage, { silent: true })
.then(res => {
if (cancelled || !res) return;
setStageTemplate(res.template || '');
const cfg = { name: res.name, description: res.description, model: res.model, provider: res.provider || null, variables: res.variables || [] };
const timeout = parseTimeoutMs(res.timeout);
if (timeout !== null) cfg.timeout = timeout;
setStageConfig(cfg);
setPreview('');
})
...
Any unsaved modifications made to stageTemplate, model/tier selections, or timeout overrides in stageConfig are immediately and silently overwritten with the newly clicked stage's data.
Trigger
- Navigate to
/prompts (or /prompts?stage=pipeline-prose-draft).
- In the Stages editor, make changes to the prompt template in the textarea (or alter the Model tier / Specific provider, or modify the timeout override).
- Without clicking "Save", click any other stage in the accordion sidebar list (e.g.
pipeline-comic-script or brain-classifier).
setSelectedStage(name) runs synchronously, triggering a fetch for the new stage and obliterating all unsaved template and configuration edits.
Impact
Prompt templates are extensive, delicate prompts often containing hundreds of lines of instructions and variable interpolation markup. A user who inadvertently clicks a neighboring row in the 120+ item stage list, or clicks another stage to reference its wording, permanently loses their in-progress work with no warning, confirmation, or undo mechanism.
Fix
Adopt the same dirty-state and inline confirmation pattern proven in the Job Skills tab (#3939):
- In
client/src/pages/PromptManager.jsx:
- Store baseline state for the loaded stage:
savedStageTemplate (string) and savedStageConfig (object), populated when getPrompt resolves.
- Compute
isStageDirty = Boolean(selectedStage) && (stageTemplate !== savedStageTemplate || JSON.stringify(stageConfig) !== JSON.stringify(savedStageConfig)).
- Add state
pendingStage (nullable string), cleared when isStageDirty is false.
- When a stage row is clicked:
- If
name === selectedStage, clear pendingStage.
- If
isStageDirty, set setPendingStage(name) instead of calling setSelectedStage(name).
- If clean, call
setSelectedStage(name).
- In the stage list items:
- If
pendingStage === name && isStageDirty, render InlineConfirmRow inside the list slot with question={Discard unsaved changes to "${stages[selectedStage]?.name || selectedStage}"?}, confirmText="Discard", cancelText="Keep editing". On confirm, switch to pendingStage and clear pendingStage; on cancel, clear pendingStage.
- When
selectedStage === name && isStageDirty, render an <span className="shrink-0 text-[10px] px-1.5 py-0.5 bg-port-warning/20 text-port-warning rounded uppercase font-semibold">Unsaved</span> badge beside the stage name.
- In the stage editor header, display
{isStageDirty && <span className="text-port-warning">Unsaved changes</span>}.
- In
saveStage, update savedStageTemplate and savedStageConfig on successful PUT so the dirty state resets cleanly.
- Rejected alternative: A browser-native
window.confirm modal. Rejected per client UI convention (client/src/AGENTS.md: "No window.alert/confirm - use inline confirmations or toast notifications").
Dispatch rationale: model:medium + effort:medium — requires coordinating state between grouped accordion stage rows, dirty computation over template and config objects, inline confirmation components, and companion vitest assertions without regressing URL-driven deep linking.
Files to touch:
client/src/pages/PromptManager.jsx
client/src/pages/PromptManager.test.jsx (add test suite mirroring the job skill unsaved-edit guard tests for stages)
Acceptance criteria
Problem
In
client/src/pages/PromptManager.jsx:607-613, selecting another stage in the prompt stages list directly callssetSelectedStage(name):Unlike the Job Skills tab in the same page (which implements an inline confirm row via
isJobSkillDirtyandpendingJobSkill, added in #3939), the Stages tab has no dirty tracking or unsaved edits guard. WhenselectedStageupdates in the URL, theuseEffecthook atclient/src/pages/PromptManager.jsx:171-195immediately triggers:Any unsaved modifications made to
stageTemplate, model/tier selections, or timeout overrides instageConfigare immediately and silently overwritten with the newly clicked stage's data.Trigger
/prompts(or/prompts?stage=pipeline-prose-draft).pipeline-comic-scriptorbrain-classifier).setSelectedStage(name)runs synchronously, triggering a fetch for the new stage and obliterating all unsaved template and configuration edits.Impact
Prompt templates are extensive, delicate prompts often containing hundreds of lines of instructions and variable interpolation markup. A user who inadvertently clicks a neighboring row in the 120+ item stage list, or clicks another stage to reference its wording, permanently loses their in-progress work with no warning, confirmation, or undo mechanism.
Fix
Adopt the same dirty-state and inline confirmation pattern proven in the Job Skills tab (
#3939):client/src/pages/PromptManager.jsx:savedStageTemplate(string) andsavedStageConfig(object), populated whengetPromptresolves.isStageDirty = Boolean(selectedStage) && (stageTemplate !== savedStageTemplate || JSON.stringify(stageConfig) !== JSON.stringify(savedStageConfig)).pendingStage(nullable string), cleared whenisStageDirtyis false.name === selectedStage, clearpendingStage.isStageDirty, setsetPendingStage(name)instead of callingsetSelectedStage(name).setSelectedStage(name).pendingStage === name && isStageDirty, renderInlineConfirmRowinside the list slot withquestion={Discard unsaved changes to "${stages[selectedStage]?.name || selectedStage}"?},confirmText="Discard",cancelText="Keep editing". On confirm, switch topendingStageand clearpendingStage; on cancel, clearpendingStage.selectedStage === name && isStageDirty, render an<span className="shrink-0 text-[10px] px-1.5 py-0.5 bg-port-warning/20 text-port-warning rounded uppercase font-semibold">Unsaved</span>badge beside the stage name.{isStageDirty && <span className="text-port-warning">Unsaved changes</span>}.saveStage, updatesavedStageTemplateandsavedStageConfigon successful PUT so the dirty state resets cleanly.window.confirmmodal. Rejected per client UI convention (client/src/AGENTS.md: "No window.alert/confirm - use inline confirmations or toast notifications").Dispatch rationale:
model:medium+effort:medium— requires coordinating state between grouped accordion stage rows, dirty computation over template and config objects, inline confirmation components, and companion vitest assertions without regressing URL-driven deep linking.Files to touch:
client/src/pages/PromptManager.jsxclient/src/pages/PromptManager.test.jsx(add test suite mirroring the job skill unsaved-edit guard tests for stages)Acceptance criteria
InlineConfirmRowasking whether to discard unsaved changes, without switching the open stage or URL.