# Phase 1: Draft State Ownership - Research **Researched:** 2026-03-28 **Domain:** React/Tauri runtime settings draft ownership, polling isolation, and async form safety **Confidence:** HIGH ## Summary Phase 1 should stay entirely inside the existing React runtime workspace and `useRuntimeManager` boundary. The current failure mode is structural: `useRuntimeManager` owns a single polled `snapshot`, and `RuntimeSettingsDrawer.tsx` rehydrates all local field state from `snapshot.config` whenever the drawer is open and `snapshot` changes. Because polling runs every 3 seconds, any refresh, scan result, or save response can overwrite in-progress edits. The lowest-risk fix is to split ownership into three explicit layers: 1. **Live runtime snapshot** - continues to refresh from polling and lifecycle commands. 2. **Saved baseline config** - the last persisted config the user can safely revert to. 3. **Editable draft state** - drawer-local intent that survives close/reopen in the same app session and is never overwritten by polling while dirty. This phase should not introduce a global store, backend schema change, or Phase 3 workspace consolidation. Keep the brownfield shape: `useRuntimeManager` remains the feature-scoped source of truth, `RuntimeSettingsDrawer.tsx` becomes a renderer/editor over manager-owned draft state, and current save/start/stop backend contracts stay intact. **Primary recommendation:** move draft/baseline ownership into the runtime feature state layer, expose explicit draft actions from `useRuntimeManager`, and refactor the drawer so async helpers update only targeted fields while stale responses are ignored. ## Current-State Findings ### Existing overwrite risk - `tauri-app/src/features/runtime/useRuntimeManager.ts` polls `getRuntimeSnapshot()` every 3 seconds and writes the whole `snapshot` object back into React state. - `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` has a single `useEffect(..., [open, snapshot])` that repopulates all local form fields from `snapshot.config` whenever polling refreshes the snapshot while the drawer is open. - `saveConfig()` in `useRuntimeManager.ts` builds the save payload from the hook's current `snapshot.config`, so the save baseline is also tied to the same mutable polled object. ### Existing reusable boundaries - `tauri-app/src/features/runtime/runtime-settings-form.ts` already centralizes patch construction and model selection helpers. It is the right place for draft-vs-baseline merge rules and invalid-selection handling helpers. - `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` already covers the runtime status card + drawer integration and is the best place to add polling/draft regression tests. - `tauri-app/src/features/runtime/types.ts` is the existing contract surface for the runtime feature and should carry any new draft-specific UI contracts. ### UX constraints already locked - Per `01-CONTEXT.md`, opening creates a draft from the latest saved baseline (D-01), close preserves unsaved draft in-session (D-06), cancel/discard resets to the last saved baseline (D-04/D-05), async helpers are field-scoped (D-07), stale responses must be ignored (D-10), and invalid values must be preserved inline with save blocked (D-11). - Per `01-UI-SPEC.md`, the drawer must present `Draft settings`, `Live now`, `Save changes`, `Discard changes`, `Keep editing`, and sticky live-vs-draft affordances instead of silently mutating values. ## Recommended Architecture ### Pattern 1: Manager owns live snapshot + baseline + draft Add explicit runtime feature state instead of deriving editable form state directly from `snapshot` on every render. Recommended shape: - `liveSnapshot: RuntimeSnapshot` - latest polling/lifecycle truth - `savedBaseline: RuntimeConfig` - last committed config - `draftConfig: RuntimeConfig | null` - active or hidden in-session draft - `isDraftDirty: boolean` - `isDraftOpen: boolean` - `draftErrors` / `invalidFields` / `pendingFieldOps` Recommended actions: - `openDraft()` - create draft from `savedBaseline` only if draft does not already exist - `closeDraft()` - hide draft without discarding it - `discardDraft()` - replace full draft with `savedBaseline` - `updateDraftField(field, value)` - targeted field update - `saveDraft()` - persist current draft, promote saved values to baseline, keep drawer open - `applyLiveRefresh(snapshot)` - update operational live state every poll; update baseline only when draft is absent or clean ### Pattern 2: Drawer becomes a controlled draft editor `RuntimeSettingsDrawer.tsx` should stop treating `snapshot` as the editable source of truth. Instead it should receive: - current draft values - saved/live reference values - dirty/invalid metadata - explicit actions for field updates, discard, save, and close The drawer may keep only ephemeral UI state that is not part of the draft contract, such as an expanded discard confirmation row or transient search text when that text is intentionally not preserved. ### Pattern 3: Field-scoped async operations Folder pickers, rescans, GPU detection, and any validation should update a targeted field or helper state only. Do not run a full-form rehydrate after any async operation. Recommended approach: - track per-field request ids or sequence counters - before applying an async result, verify its id matches the latest request for that field - preserve the drafted field value if validation says it is invalid; attach inline invalid metadata instead of auto-correcting or clearing the field This is especially important for: - `models_dir` - `selectedModel` - `selectedModelId` - file-picker-added extra model paths ### Pattern 4: Save promotes baseline immediately After a successful save: - the returned config becomes the new `savedBaseline` - the visible draft is replaced with the saved values and marked clean - the drawer stays open - polling continues, but future polls must not regress the saved baseline or draft-facing UI ### Pattern 5: Failure preserves draft Save or validation failures should not reset the draft. Keep the user's current values in the editor, surface inline or drawer-level error messaging, and block save until invalid fields are resolved. ## Anti-Patterns to Avoid - Rehydrating local input state from `snapshot.config` in a `useEffect` that depends on the full `snapshot` - Using one `busy` flag as the only async state for all field-level operations - Clearing invalid model/folder choices automatically after rescan - Coupling discard behavior to drawer close/backdrop click - Introducing Redux/Zustand/global app state for a feature-scoped ownership problem - Moving runtime status card consolidation into this phase (deferred to Phase 3) ## Testing Strategy ### Recommended automated coverage | Behavior | Test file | Automated command | |----------|-----------|-------------------| | Poll refresh does not overwrite dirty draft fields | `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` | `npm test -- RuntimeSurface` | | Close/reopen preserves in-session draft but discard resets to baseline | `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` | `npm test -- RuntimeSurface` | | Save promotes baseline and keeps drawer open | `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` | `npm test -- RuntimeSurface` | | Field-scoped rescan preserves draft selection unless invalid | `tauri-app/src/features/runtime/runtime-settings-form.test.ts` and/or `RuntimeSurface.test.tsx` | `npm test -- runtime-settings-form` | | Invalid draft values remain visible and block save | `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` | `npm test -- RuntimeSurface` | ### Phase verification commands - Targeted UI/unit tests: `npm test -- RuntimeSurface` - Helper/unit tests: `npm test -- runtime-settings-form` - Phase gate: `npm run build` ## Open Questions Resolved For Planning 1. **Where should draft ownership live?** - Recommendation: feature-scoped runtime manager state, not backend and not drawer-local snapshot rehydration. 2. **Do we need backend revision/timestamp support for Phase 1?** - Recommendation: no backend change required for the first pass. The immediate bug is frontend ownership. Reconcile against the latest saved config returned by existing save/refresh calls. 3. **Should close prompt to discard?** - Recommendation: no. Close hides the draft; `Discard changes` is the explicit reset path. 4. **Should invalid rescans clear selection?** - Recommendation: no. Preserve the value, mark invalid inline, disable save. ## Sources - `tauri-app/src/features/runtime/useRuntimeManager.ts` - `tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx` - `tauri-app/src/features/runtime/runtime-settings-form.ts` - `tauri-app/src/features/runtime/types.ts` - `tauri-app/src/features/runtime/components/RuntimeSurface.test.tsx` - `tauri-app/src/features/runtime/runtime-settings-form.test.ts` - `.planning/phases/01-hf-model-discovery/01-CONTEXT.md` - `.planning/phases/01-hf-model-discovery/01-UI-SPEC.md` - `.planning/ROADMAP.md` - `.planning/REQUIREMENTS.md` ## Metadata - Research focus: brownfield-safe draft-state separation for the runtime settings drawer - New external dependencies required: none - Suggested discovery level: Level 0 (existing patterns only; no new library selection)