Tasks: Warn User About Losing Unsaved Changes in Dynamic Agent Editor
Input: Design documents from /docs/docs/specs/2026-04-29-agent-editor-unsaved-warning/
Prerequisites: plan.md, spec.md, research.md, data-model.md, contracts/ui-contracts.md, quickstart.md
Tests: Tests are included for the dirty-tracking hook and the back-button interception path because the spec's success criteria (SC-001 through SC-005) and edge cases (revert clears dirty, save success/failure, read-only, cloning) are non-trivial behaviors that warrant automated coverage. The page-tab and header-nav paths are verified via the quickstart (manual) β those are thin router glue and harder to fixture.
Organization: Tasks are grouped by user story (P1 back-button, P1 sub-tab, P2 header-nav). Each story is independently testable per the spec.
Format: [ID] [P?] [Story] Descriptionβ
- [P]: Can run in parallel (different files, no dependencies on incomplete tasks)
- [Story]: US1 = back-button (P1), US2 = sub-tab switch (P1), US3 = header-nav (P2)
- All paths are absolute from the repo root.
Path Conventionsβ
- Frontend-only feature inside the
ui/Next.js workspace. - Source:
ui/src/... - Tests: co-located
*.test.ts(x)next to the file under test (existing repo convention β seeui/jest.config.jsfor any deviations).
Phase 1: Setup (Shared Infrastructure)β
Purpose: Sanity-check the dev environment before any code change. This feature adds no new dependencies, so setup is minimal.
- T001 Verify the UI workspace builds and lints cleanly on the current branch by running
npm run lintandnpm run buildinui/. Capture baseline output so post-change runs can be compared. - T002 Verify Jest is configured for
ui/and that an existing UI test runs (e.g.,npm test -- --listTestsor run any one suite). Confirms the test runner works before new tests are added.
Phase 2: Foundational (Blocking Prerequisites)β
Purpose: Generalize the shared dialog and add the reusable dirty-tracking hook. Both are consumed by every user story below, so they must land first.
β οΈ CRITICAL: No user story work can begin until this phase is complete.
- T003 [P] Generalize
ui/src/components/task-builder/UnsavedChangesDialog.tsxpercontracts/ui-contracts.mdContract 1: add optionaltitle,description,discardLabel,cancelLabelprops with defaults that exactly preserve current Task Builder copy. Do not change layout, styles, animation, or default behavior. The existingAppHeaderconsumer must continue to work with no change. - T004 [P] Create
ui/src/hooks/use-editor-dirty-tracking.tsimplementinguseEditorDirtyTrackingpercontracts/ui-contracts.mdContract 3 anddata-model.mdEntities 1, 3, 4. Behavior:- Snapshot
currentValueson mount and wheneversnapshotKeychanges. - Compare current vs snapshot using a value-based comparator (default: canonical JSON of sorted top-level keys; allow caller-provided
equals). - When
enabled=false, hook is inert (never writes to the global store except the unmount cleanup). - On every render where computed
dirtydiffers from the previously written value, calluseUnsavedChangesStore.getState().setUnsaved(dirty)from auseEffect. - Provide
resetSnapshot()that re-snapshotscurrentValuesimmediately and writessetUnsaved(false). - Unmount cleanup always calls
setUnsaved(false).
- Snapshot
- T005 [US1][US2][US3] Add unit tests for the dirty hook in
ui/src/hooks/use-editor-dirty-tracking.test.tscovering:- Mount with
enabled=falseβ store flag stays false even when values change. - Mount with
enabled=true, values unchanged β flag stays false. - Mutate one field β flag becomes true.
- Revert that field to original value β flag becomes false (per spec FR-001 / SC-002 / Edge Case "field reverted to original value").
- Object-shaped fields (
Record<string, string[]>, optionalundefinedvs{}) treated as equal when semantically equal (perdata-model.mdvalidation rules). resetSnapshot()clears dirty even after edits.- Unmount calls
setUnsaved(false)even when dirty was true.
- Mount with
Checkpoint: Foundation ready β the dialog accepts custom copy without breaking Task Builder, and the dirty hook is tested. User stories can now proceed in parallel.
Phase 3: User Story 1 β Warn when leaving editor via back button (Priority: P1) π― MVPβ
Goal: Clicking the editor's back arrow with unsaved changes shows the in-app modal instead of silently discarding work. The MVP β fixing only this path already prevents the most common loss-of-work scenario.
Independent Test: Open the dynamic agent editor (create or edit), modify any field, click the back arrow β in-app modal appears. "Keep editing" preserves all edits; "Discard changes" returns to the agent list.
Tests for User Story 1 β οΈβ
Write these tests FIRST and confirm they fail before implementation.
- T006 [P] [US1] Component test in
ui/src/components/dynamic-agents/DynamicAgentEditor.unsaved.test.tsxcovering the back-button flow:- Render
<DynamicAgentEditor agent={fixture} />with mockedonCancelandonSave. - Without changes, click the back arrow β
onCancelinvoked, no dialog rendered. - Mutate the name field, click back arrow β dialog renders,
onCancelNOT invoked. - Click "Keep editing" β dialog removed, name field still has the edited value,
onCancelNOT invoked. - Mutate again, click back arrow, click "Discard changes" β dialog removed,
onCancelinvoked once. - With
readOnlyprop true, mutate a field via direct state poke is impossible (fields disabled) β assertuseUnsavedChangesStore.getState().hasUnsavedChangesstays false (FR-012). - Save flow: stub the network call to succeed, click Save β
setUnsaved(false)is observed beforeonSaveis invoked (FR-010); then clicking back must not show the dialog. - Save flow failure: stub the network to reject, click Save β flag remains true (FR-011); then clicking back shows the dialog.
- Render
Implementation for User Story 1β
- T007 [US1] Wire
useEditorDirtyTrackingintoui/src/components/dynamic-agents/DynamicAgentEditor.tsx:- Build a
currentValuesobject aggregating all editable form fields perdata-model.mdEntity 2 (name,description,systemPrompt,visibility,sharedWithTeams,allowedTools,builtinTools,subagents,skills,features,modelId,modelProvider,gradientTheme). - Choose
snapshotKeyso it changes when the editor source identity changes AND once when the async model defaults are applied (perdata-model.md"Special handling β model defaults"). A two-part key like${agent?._id ?? cloneFrom?._id ?? "new"}|${modelsResolvedSentinel}works. - Pass
enabled = !readOnly.
- Build a
- T008 [US1] Intercept the back button in
DynamicAgentEditor.tsx:- Add local state
pendingClose: boolean. - Replace the inline
onClick={onCancel}on theArrowLeftButtonwith a wrapper: ifdirtyfrom the hook βsetPendingClose(true); else β callonCancel()directly. - Render
<UnsavedChangesDialog open={pendingClose} onCancel={() => setPendingClose(false)} onDiscard={() => { setPendingClose(false); setUnsaved(false); onCancel(); }} title="Unsaved changes" description="You have unsaved changes in the agent editor. They will be lost if you leave now." />.
- Add local state
- T009 [US1] Update
handleSubmitinDynamicAgentEditor.tsxto clear the dirty flag on a successful save:- On success: call
resetSnapshot()(from the hook) ANDuseUnsavedChangesStore.getState().setUnsaved(false)BEFORE invokingonSave(). This is belt-and-suspenders and matchesresearch.mdDecision 4. - On failure: do nothing extra β the dirty effect will keep the flag true on the next render because the snapshot is unchanged.
- On success: call
- T010 [US1] Add a brief inline comment in
DynamicAgentEditor.tsxat the dirty-tracking call site explaining why the snapshot key includes a "models resolved" sentinel (the async-loaded model defaults can otherwise appear as dirty against an empty snapshot β seeresearch.mdDecision 4 anddata-model.mdValidation rules). Per the constitution, comments explain why, not what.
Checkpoint: At this point, User Story 1 is fully functional. Run the relevant sections of quickstart.md ("P1 β Back button warns when dirty", "Negative β does not warn when not dirty", "Negative β does not warn for read-only / config-driven agents", "Negative β wizard step navigation does not warn", "Save flows β clears dirty correctly", "Edge β revert clears dirty", "Cloning β starts clean").
Phase 4: User Story 2 β Warn when switching sub-tabs on the Agents page (Priority: P1)β
Goal: While the editor is open with unsaved changes, clicking a sibling sub-tab (MCP Servers / LLM Models / Conversations) shows the in-app modal and the URL does not change until the user confirms discard.
Independent Test: Open the editor, modify any field, click MCP Servers (or any sibling sub-tab) β modal appears. "Keep editing" cancels the switch; "Discard changes" completes it.
Implementation for User Story 2β
- T011 [US2] Modify
ui/src/app/(app)/dynamic-agents/page.tsxto guard tab switches percontracts/ui-contracts.mdContract 6:- Add
pendingTab: string | nulllocal state. - Wrap
setActiveTab(tab): ifuseUnsavedChangesStore.getState().hasUnsavedChangesis true ANDtab !== activeTab, callsetPendingTab(tab)and return without modifying the URL. Otherwise, proceed with the existingrouter.pushlogic. - Render
<UnsavedChangesDialog open={pendingTab !== null} onCancel={() => setPendingTab(null)} onDiscard={() => { const t = pendingTab; setPendingTab(null); useUnsavedChangesStore.getState().setUnsaved(false); if (t) { /* original router.push from existing setActiveTab */ } }} title="Unsaved changes" description="You have unsaved changes in the agent editor. They will be lost if you switch tabs." />. - Extract the inner "do the tab switch" body into a small private function so both the un-guarded path and the discard handler call the same code (Rule of Three not yet met for two call sites, but extraction here keeps them in sync β acceptable.)
- Add
Checkpoint: User Stories 1 and 2 both pass their quickstart sections. Run "P1 β Sub-tab switch warns when dirty" from quickstart.md.
Phase 5: User Story 3 β Warn when leaving the page via top-level navigation (Priority: P2)β
Goal: While the editor is open with unsaved changes, clicking any top-level header link (Home, Chat, Skills, Task Builder, Knowledge Bases, Admin, the logo) is intercepted with the same modal pattern that already exists for Task Builder.
Independent Test: Open the editor, modify any field, click a header link (e.g., Chat) β modal appears. "Keep editing" stays on the page; "Discard changes" navigates to the destination.
Tests for User Story 3 β οΈβ
- T012 [US3] Component test in
ui/src/components/layout/AppHeader.unsaved-dynamic-agents.test.tsxcovering the predicate extension:- Mock
usePathnameto return/dynamic-agents. Mock the store withhasUnsavedChanges: true. - Render
<AppHeader />. Click aGuardedLink(e.g., the Home link) β expectrequestNavigation(href)to be called and the link's default navigation prevented. - With
hasUnsavedChanges: falseand same pathname β click β navigates normally (norequestNavigationcall). - With
hasUnsavedChanges: truebutpathname='/some-other-page'β click β navigates normally (the predicate must match the current path family). - With
pathname='/task-builder'andhasUnsavedChanges: trueβ behavior unchanged from today (regression check).
- Mock
Implementation for User Story 3β
- T013 [US3] In
ui/src/components/layout/AppHeader.tsx, extend theGuardedLinkpredicate percontracts/ui-contracts.mdContract 4:- Replace
const isOnTaskBuilderEditor = pathname?.startsWith("/task-builder") && hasUnsavedChanges;withconst shouldGuardNavigation = hasUnsavedChanges && (pathname?.startsWith("/task-builder") || pathname?.startsWith("/dynamic-agents")); - Apply the same predicate to the
isOnTaskBuilderEditorusage further down that controls dialog rendering (rename the variable in both places toshouldGuardNavigationfor clarity).
- Replace
- T014 [US3] Switch the
AppHeader-rendered<UnsavedChangesDialog>to generic copy percontracts/ui-contracts.mdContract 4:title="Unsaved changes",description="You have unsaved changes. They will be lost if you leave now."(covers both Task Builder and the agent editor without confusion).- Verify Task Builder regression: existing Task Builder UX still warns on header nav and the user can still discard/keep.
Checkpoint: All three user stories independently functional. Run "P2 β Top-level header navigation warns when dirty" from quickstart.md, plus the Task Builder regression check (open Task Builder, dirty it, click a header link β modal still appears).
Phase 6: Polish & Cross-Cutting Concernsβ
Purpose: Verification across all stories, plus a final lint/build/test gate per Constitution VI.
- T015 Run the full
quickstart.mdend-to-end against a local dev server (cd ui && npm run dev). Each section must behave as described, including all negative cases (no false positives, native browser refresh is intentionally NOT blocked, wizard step navigation never warns). - T016 Run
cd ui && npm run lintand resolve any new warnings introduced by this feature only. Pre-existing lints are out of scope. - T017 Run
cd ui && npm testand confirm:- The new
use-editor-dirty-tracking.test.tspasses. - The new
DynamicAgentEditor.unsaved.test.tsxpasses. - The new
AppHeader.unsaved-dynamic-agents.test.tsxpasses. - No previously-passing test regressed (especially Task Builderβrelated tests).
- The new
- T018 Run
cd ui && npm run buildto confirm the production build succeeds with no new TypeScript errors. - T019 [P] Update
ui/src/components/dynamic-agents/ARCHITECTURE.md(if present) with a one-paragraph note about the unsaved-changes warning behavior pointing to this spec atdocs/docs/specs/2026-04-29-agent-editor-unsaved-warning/. Skip if the file does not exist β do NOT create new docs proactively.
Dependencies & Execution Orderβ
Phase Dependenciesβ
- Setup (Phase 1): No dependencies β start immediately.
- Foundational (Phase 2): Depends on Setup. BLOCKS all user stories β both T003 (dialog) and T004 (hook) must land before any story implementation; T005 (hook tests) can run in parallel with story work but should land before merge.
- User Story 1 (Phase 3): Depends on Foundational. T006 (test) before T007βT010 (implementation) per TDD note.
- User Story 2 (Phase 4): Depends on Foundational. Independent of US1.
- User Story 3 (Phase 5): Depends on Foundational. Independent of US1 and US2.
- Polish (Phase 6): Depends on all desired user stories.
User Story Dependenciesβ
- US1 (back button, P1): Needs T003 + T004. No dependency on US2 or US3.
- US2 (sub-tab switch, P1): Needs T003 + T004 (consumes the same dirty flag US1 sets). Functionally depends on US1 having wired
setUnsavedfrom inside the editor (T007), since without dirty wiring the tab guard would never fire β so in practice US2 implementation should land after T007, even though they touch different files. Marked here because it affects scheduling. - US3 (header nav, P2): Needs T003 + T004 + T007 (same reasoning β the editor must be writing the dirty flag for the header guard to have anything to react to).
Within Each User Storyβ
- US1: T006 (test) β T007 β T008 β T009 β T010.
- US2: T011 (single implementation task; manual verification via quickstart).
- US3: T012 (test) β T013 β T014.
Parallel Opportunitiesβ
- T003 and T004 are different files with no inter-dependency β run in parallel.
- T005 (hook tests) is a different file from T003/T004's implementation β can be authored in parallel with US1 implementation, but should be reviewed/landed alongside Phase 2.
- US2 and US3 implementation are independent of each other once T007 is done β can be picked up by different developers.
- T019 (optional doc note) is independent of all test/lint/build tasks in Polish.
Parallel Example: After Foundationalβ
# Two developers can split work after T007 lands:
Developer A: T011 (US2 β sub-tab guard in app/(app)/dynamic-agents/page.tsx)
Developer B: T012 β T013 β T014 (US3 β AppHeader predicate + test + dialog copy)
# Foundational tasks themselves can overlap:
Task: "T003 β generalize UnsavedChangesDialog props (task-builder/UnsavedChangesDialog.tsx)"
Task: "T004 β create useEditorDirtyTracking hook (hooks/use-editor-dirty-tracking.ts)"
Implementation Strategyβ
MVP First (User Story 1 only)β
- Complete Phase 1: Setup (T001βT002).
- Complete Phase 2: Foundational (T003βT005). CRITICAL β blocks all stories.
- Complete Phase 3: User Story 1 (T006βT010).
- STOP and validate: run the back-button sections of
quickstart.md. This alone delivers the largest fraction of the value (most users discard work via the back arrow, per the spec's prioritization). - Optional: ship the MVP at this point. The remaining stories add coverage for less common paths.
Incremental Deliveryβ
- Setup + Foundational β ready.
- Add US1 β validate back-button paths β ship as MVP.
- Add US2 β validate sub-tab paths.
- Add US3 β validate header-nav paths and Task Builder regression.
- Polish (T015βT019) β final lint/build/test gate.
Parallel Team Strategyβ
With two developers:
- Both pair briefly on Phase 1 + Phase 2 to align on the hook contract.
- Once T003 + T004 + T007 are merged:
- Developer A: US2 (T011).
- Developer B: US3 (T012 β T013 β T014).
- Polish (Phase 6) is owned by whoever finishes their story first.
Notesβ
- This is a frontend-only feature; no backend changes, no new dependencies, no schema changes.
- [P] tasks are in different files with no incomplete dependencies.
- All tasks follow
[checkbox] [TaskID] [P?] [Story?] description-with-pathper the format spec. - Tests intentionally cover the value-bearing logic (dirty hook, back-button intercept, header predicate). Page-tab interception (T011) is deliberately verified manually β Next.js App Router page components are awkward to mount in Jest and the logic is a thin wrapper.
- Constitution checks: every task respects YAGNI (no speculative
beforeunloadhandler, no per-field dirty tracking, no new abstraction beyond what two consumers need today). The new hook (T004) is the only new abstraction and it's introduced because two real consumers will share it. - Commit after each task or each logical group, using Conventional Commits + DCO sign-off (
git commit -s -m "feat(ui): ...").