Files
paseo/docs/forms.md
Mohamed Boudra d9b65e269f Scheduled runs each get their own workspace in the sidebar (#1934)
* feat(schedules): per-run workspaces, isolation and archive controls

Scheduled agents never appeared in the sidebar: each schedule reused one
stamped workspace and archived the run's agent on completion. Now every run
mints its own workspace — the entity the sidebar shows — with per-schedule
controls for isolation (local or worktree) and whether to archive that
workspace when the run finishes.

The schedule form is rebuilt on a non-React form model (open/commands/close)
to end the flicker, edit-into-create contamination, and multi-host hydration
bugs that came from effect-choreographed shared state. That lands the reusable
foundations it needed: a form kit over a single control-geometry owner, a
data-access taxonomy (replica/snapshot/fetch) with real load-state semantics,
and lint/boundary guardrails so new screens inherit the paved road.

Also fixes desktop combobox popovers truncating options when the trigger is
narrower than the content. New schedule protocol fields are optional on both
create and update, so old and new clients/daemons stay compatible.

* test(app): complete mock themes for control-geometry tokens

switch and terminal-profile-edit-modal now style through
createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50],
and theme.colors.borderAccent. The hand-rolled mock themes in these two
suites lacked those tokens, so the eager StyleSheet.create mock threw at
import (collection error), not on any assertion. Add the missing tokens.

* fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration

- Server: archive the run's workspace even when agent creation fails, so a
  misconfigured provider no longer orphans a sidebar workspace/worktree.
- Edit form: keep a stored worktree isolation while worktree eligibility is
  still resolving, instead of silently downgrading a saved schedule to local.
- Schedules screen: show the load error/retry UI when every host fails, rather
  than spinning forever behind the loading branch.
- Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the
  sheet closes instead of re-opening.
- Form model: apply late-loading saved preferences to untouched create-mode
  fields (never to edited schedules or user-modified fields).

* fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety

- Push-router: re-send terminal and checkout-diff subscriptions after a
  reconnect; the old per-hook effect resubscribed on isConnected and the
  extracted router did not, so daemon restarts silently stopped terminals_changed
  pushes (root cause of the terminal-activity e2e failures).
- Server: pass the run's source repo root when archiving worktree runs so the
  worktree is unregistered (git worktree remove/prune), not just deleted; also
  archive the workspace when agent creation fails even with archive-off (an
  agentless workspace has nothing to inspect).
- Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the
  Android native path too; replace the JSDOM component test with a real
  Playwright dismiss spec and inline the trivial dismiss decision.
- Form model: editing an interval schedule no longer rewrites its cadence to
  cron unless the cadence UI is touched (90-minute intervals have no cron
  equivalent); switching projects clears stale provider/model state and gates
  submit until the new host's snapshot resolves.

* chore(lint): drop custom lint wrapper and boundary script, plain oxlint

The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports).
The receiver-sensitive checks the custom script enforced are not worth a
parallel lint pipeline; if they come back it will be as oxlint rules.

* fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations

- Persist the run's workspace/agent ids on the running-run record so a daemon
  crash mid-run can be recovered: restart archives the interrupted run's
  workspace under the same policy as normal cleanup (agentless always archives,
  otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
  hosts without workspaceMultiplicity — an old daemon ignores the fields, so
  offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
  a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
  model-less providers can pick their advertised modes.
2026-07-07 22:13:42 +02:00

4.9 KiB

Forms

The paved road for building forms in the app. The schedule form is the golden example; when building or fixing any form, copy its shape, not the shape of whatever screen you happen to be near.

Golden example files:

  • packages/app/src/schedules/schedule-form-model.ts (+ .test.ts) — the model
  • packages/app/src/schedules/use-schedule-form-model.ts — model lifetime adapter
  • packages/app/src/schedules/use-schedule-form-provider-snapshot.ts — async input adapter
  • packages/app/src/components/schedules/schedule-form-sheet.tsx — render + intent dispatch
  • packages/app/src/schedules/aggregated-schedules.ts / hooks/use-schedules.ts — load-state gating
  • packages/app/e2e/schedules-*.spec.ts — the behavioral contract

The form model

Every non-trivial form gets a plain TypeScript model — zero React imports:

  • openXxxForm(snapshot) constructs a fresh instance from declared inputs (mode, the record being edited, hosts, defaults). Edit mode seeds every value AND display from the snapshot — never from a previous instance.
  • Commands mutate (setHost, setProject(value, display), setModel, …). Derived state (disclosure, canSubmit, displays) is recomputed inside the model on every publish.
  • close() destroys the instance. subscribe/getState feed one useSyncExternalStore in the component.

The component renders state and dispatches intent. That is all it does.

Lifecycle rules (each one killed a real shipped bug)

  1. Fresh mount per open. The sheet returns null when not visible and mounts the open form with a key derived from mode + record identity. A long-lived component instance shared across create/edit is how edit contaminated create.
  2. Construct the model ONCE per mountuseState(() => openXxxForm(snapshot)). NEVER useMemo(() => open(...), [snapshot]): the snapshot's identity depends on live data (projects, hosts, preferences), and any background churn — e.g. a scheduled run creating a workspace — would reconstruct the model and wipe the user's in-progress input.
  3. Late data is an explicit model input, not a reconstruction. applyProviderSnapshot(serverId, …), applyProjectTargets(…), applyHosts(…). Adapters pipe identity changes into these with mechanical effects. Input plumbing is fine; orchestration effects are not — the sheet itself has zero useEffect/useRef, and that is the target for every form.
  4. Resolution is explicit model state, per host (idle | pending | complete), keyed off the opened snapshot's serverId. Waiting for data is a state you can render, not an effect race.
  5. Displays are owned state. The selected option's label is captured at selection/seed time (setProject(value, display)), never re-derived from a live options list — list churn must not flicker or blank a selection.
  6. Disclosure is derived in the model from user intent (host → project → model → thinking/mode), so fields cannot pop in from cache timing.

Form kit

  • Compose Field / SelectField / FormTextInput / SegmentedControl / Switch from components/ui/. Geometry (heights, padding, radii, focus/hover states) is owned by components/ui/control-geometry.ts — controls never declare their own, and screens never nudge global component styles to align a row.
  • The form declares one size for all fields: sm on desktop, md compact (useIsCompactFormFactor).
  • Availability hierarchy: a field whose capability doesn't apply is hidden (isolation on a non-git project — same gating as New Workspace), not rendered disabled with an explanation. Disabled-with-a-reason hint is only for transient states the user can resolve.
  • Copy is opt-in and rare. No hint/subtext unless the maintainer approved the exact string; validation errors are the exception. State a fact (like the timezone) once — never in a preview line AND a helper line.
  • useUnistyles is banned (see docs/unistyles.md); lint enforces.

Data gating

Aggregate hooks return a discriminated load state:

type AggregateLoadState<T> =
  | { status: "connecting" } // an answer may still be pending
  | { status: "loading" }
  | { status: "loaded"; data: T[] };

Empty states are only typeable inside loaded — a fetch that "succeeded" before hosts connected is connecting, not empty. Query keys carry real fetch inputs (host set, connection statuses), never synthetic version counters.

Anti-patterns (reject in review on sight)

  • useEffect choreography impersonating construct/hydrate/resolve/destroy.
  • One mounted form instance serving create and edit.
  • useMemo-keyed model construction on live-data identity.
  • Selected labels derived from live query lists.
  • isLoading/isEmpty boolean bags where a load-state union belongs.
  • Conditional mounting of hint/error rows that shifts layout (subtext renders only when present, but the pattern for that lives in Field, not ad hoc).