Phase 0 Research: Agent Editor Unsaved Changes Warning
All open questions from the spec's Technical Context were resolved by inspecting the existing codebase rather than external research; this is an integration with established in-repo patterns. No NEEDS CLARIFICATION markers remain.
Decision 1: Reuse useUnsavedChangesStore instead of building a new state mechanismβ
- Decision: Reuse the existing Zustand store at
ui/src/store/unsaved-changes-store.ts(hasUnsavedChanges,pendingNavigationHref,setUnsaved,requestNavigation,cancelNavigation,confirmNavigation). - Rationale: The store already implements exactly the API the agent editor needs β a single boolean dirty flag and a pending-href slot for intercepted navigation. It is already wired into
AppHeader.GuardedLink, so extending header-nav guarding to/dynamic-agentsis a one-line predicate change. Adding a second consumer also keeps the store at "two real users", which by the Rule of Three does not yet justify a more general API. - Alternatives considered:
- New per-feature Zustand slice β rejected: doubles the store surface and forces
AppHeaderto read two stores. - React Context provider in the editor β rejected: the warning needs to be visible to
AppHeader(rendered above the editor in the tree), so a context inside the editor cannot reach it. - Generalize the store now (e.g., a registry of dirty editors) β rejected per YAGNI and Rule of Three; only two consumers exist.
- New per-feature Zustand slice β rejected: doubles the store surface and forces
Decision 2: Reuse UnsavedChangesDialog, parameterize copy via optional propsβ
- Decision: Add optional
title,description,discardLabel,cancelLabelprops toui/src/components/task-builder/UnsavedChangesDialog.tsx, defaulting to today's Task Builder copy. The agent editor passes its own description ("You have unsaved changes in the agent editorβ¦"). - Rationale: Keeps visual parity (FR-014) with zero new components. The default props preserve current Task Builder behavior with no caller changes.
- Alternatives considered:
- Two separate dialog files β rejected: pure duplication for one string.
- Move dialog into a shared
ui/components/ui/location β rejected as a follow-up cleanup; current path works fine and moving it is out of scope for this feature.
Decision 3: Value-snapshot dirty detection (not event-based)β
- Decision: On editor open, snapshot the initial form values into a ref. On every render, compute
dirty = !shallowEqualForm(snapshot, current)and callsetUnsaved(dirty)from an effect when it changes. - Rationale: Matches the spec's edge case "field reverted to original value clears dirty" (Edge Cases, FR-001, SC-002). Event-based dirty (any
onChangeflips dirty true) would produce false positives. - Comparison strategy: A small dedicated comparator that handles the editor's known field set:
- Strings: strict equality.
- String arrays (e.g.,
sharedWithTeams,skills): same length and same elements in order. - Objects (
allowedTools: Record<string, string[]>,builtinTools,features,model): canonical-JSON compare (JSON.stringifyafter sorting top-level keys). The payload is tiny (β€ a few KB), so this is well under the 1ms budget. subagents: array ofSubAgentRefobjects β canonical-JSON compare.
- Alternatives considered:
- Per-field
onChangeflips dirty β rejected (false positives on revert). react-hook-formintegration β rejected as a much larger refactor for one feature.- Deep-equal lib (e.g.,
fast-deep-equal) β acceptable but adds a dep; the hand-rolled comparator on a known shape is simpler and avoids a new dependency.
- Per-field
Decision 4: Where to call setUnsavedβ
- Decision: Inside the editor, in a single
useEffectthat depends on the comparator output. CallsetUnsaved(false)in the editor's unmount cleanup so a stale dirty flag never leaks to other pages. - Rationale: Single source of truth; avoids scattering
setUnsavedcalls across every input. Cleanup ensures that closing the editor (via discard or successful save) resets the global flag. - Save-flow handling:
- On successful save: call
setUnsaved(false)beforeonSave()so the parent's unmount of the editor does not race the dirty flag. - On failed save: do nothing β the dirty effect will keep the flag true on the next render because the snapshot is unchanged.
- On successful save: call
- Read-only mode: skip the dirty effect entirely (early return) so config-driven agents never set the flag (FR-012).
- Cloning: snapshot is taken after the " (New)" suffix is applied to the name, so the form starts clean (Edge Cases).
Decision 5: How to intercept the editor's back buttonβ
- Decision: Wrap the existing
onCancelprop. IfhasUnsavedChangesis true, set localpendingClose: trueand render<UnsavedChangesDialog>inline within the editor. "Discard changes" callssetUnsaved(false)thenonCancel(). "Keep editing" clearspendingClose. - Rationale:
onCancelis provided byDynamicAgentsTaband only flips local React state β there is no router involvement, so we don't need the store'spendingNavigationHrefslot for this path. Keeping it local also means the parent (DynamicAgentsTab) needs no changes. - Alternatives considered:
- Route the back action through
useUnsavedChangesStore.requestNavigationβ rejected: the store'spendingNavigationHrefis for hrefs; reusing it for "close editor" couples unrelated semantics.
- Route the back action through
Decision 6: How to intercept Agents-page sub-tab clicksβ
- Decision: In
ui/src/app/(app)/dynamic-agents/page.tsx, wrapsetActiveTabso that whenhasUnsavedChangesis true and the editor is mounted on theagentstab, it stores the requested tab id locally and renders<UnsavedChangesDialog>. "Discard changes" callssetUnsaved(false)then proceeds with the originalrouter.push. - Rationale: The tab switch is purely client-side router state (a query param). Local state inside the page component is sufficient and avoids extending the store with a "pending tab" concept.
- Alternatives considered:
- Use
requestNavigationwith a synthetic href β rejected: the page already has its ownsetActiveTab; using it directly is clearer.
- Use
Decision 7: How to intercept top-level header navigationβ
- Decision: In
ui/src/components/layout/AppHeader.tsx, extend the existingGuardedLinkpredicate frompathname?.startsWith("/task-builder") && hasUnsavedChangesto also includepathname?.startsWith("/dynamic-agents") && hasUnsavedChanges. And extend the modal-render guard at the bottom ofAppHeaderthe same way. - Rationale: Minimal change; reuses the entire existing Task Builder mechanism. The dialog rendered by
AppHeadercontinues to use Task Builder default copy or β if we want feature-perfect copy on the header path β the dialog renders generic copy ("You have unsaved changes. They will be lost if you leave now.") to cover both Task Builder and the agent editor. We choose the generic copy approach to keep one dialog instance in the header. - Alternatives considered:
- Render a separate dialog instance per source β rejected: two stacked dialogs is worse UX than one neutral message.
- Have each editor mount its own header-nav guard β rejected: that's exactly what
useUnsavedChangesStoreexists to centralize.
Decision 8: Out-of-scope navigation paths (browser refresh / tab close / browser back)β
- Decision: Explicitly do not add a
beforeunloadlistener or any other native browser interception. - Rationale: The user request explicitly excludes "annoying browser pop-up". Spec FR-009 codifies this. Native browser back/refresh are listed in Assumptions as out of scope.
Decision 9: Testing approachβ
- Decision:
- Unit-test the dirty-tracking hook with synthetic before/after value pairs (revert-to-original returns false; any real change returns true; read-only mode never reports true).
- Component-test
DynamicAgentEditorfor the back-button path: render, mutate a field, click back, assert dialog appears, click "Keep editing" β dialog gone & state intact, click "Discard changes" βonCancelinvoked. - Component-test
AppHeaderGuardedLinkfor the/dynamic-agentspredicate: withhasUnsavedChanges=trueandpathname="/dynamic-agents", clicking a link callsrequestNavigationand prevents default. - Defer the page-tab interception test to the quickstart (manual) β Next.js App Router tabs are awkward to mount in Jest and the logic is a small wrapper.
- Rationale: Aligns with existing repo testing patterns (Jest + RTL for UI). Keeps automated tests focused on the value-bearing logic; uses manual verification for thin router glue.
References (existing code reused)β
ui/src/store/unsaved-changes-store.tsβ Zustand store (unchanged schema).ui/src/components/task-builder/UnsavedChangesDialog.tsxβ dialog component (small additive props change).ui/src/components/layout/AppHeader.tsxβGuardedLinkand modal mount (predicate extension).ui/src/components/dynamic-agents/DynamicAgentEditor.tsxβ adds dirty tracking + back-button guard.ui/src/app/(app)/dynamic-agents/page.tsxβ adds sub-tab guard.