Files
ForgeLM/.planning/phases/01-hf-model-discovery/01-RESEARCH.md
T

9.2 KiB

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.

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

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)