plans/version-preview-state-machine.md
Implemented in src/version_preview/ (see "Implementation notes" for the
few places the implementation deliberately deviates from the design below).
This document supersedes the earlier XState-versus-vanilla comparison. The decision is recorded below; the rest of the document is the design for the chosen option.
Deviations made during implementation, with reasons:
notify-error command was added to the command vocabulary. The
branch-unavailable toast must fire only when the resolution completion
survives the controller's staleness check, so it is emitted by the
machine as data rather than toasted by the adapter. All other toasts
(raw mutation errors, IPC warning messages) remain adapter concerns.closed. Disposal
would churn controller identity under mounted useSyncExternalStore
subscribers; an idle closed controller is a few hundred bytes.checking-out state) rather than fire-and-forget.
This removes the stale-restart race by construction. Failures are logged
and never converted into CHECKOUT_FAILED (invariant 10 holds). A
cloud-mode checkout of a DB-snapshot version now restarts once, not
twice as before.selectedVersionIdAtom kept its name. It is documented as
presentation-only at the definition and is never read by the machine;
renaming it across five consumers was churn without safety value.resolving-origin is defensive about unreachable shapes: if it ever
held a checkout, close/failure paths return or fall back to previewing
instead of abandoning the checkout. Reachable sessions never hit this.originBranch so a retry re-captures the live
branch (preserving b249bb40's capture-immediately-before-checkout
guarantee); OPEN during recovery-required re-notifies subscribers so
a dismissed recovery toast re-surfaces; controller creation no longer
notifies registry subscribers (it happens during React render), and the
empty recovery snapshot is a stable singleton.Chosen: a vanilla TypeScript state machine — a pure transition function plus a small serial command executor. XState was considered and rejected for this workflow:
xstate plus @xstate/react) and a v5
actor-model learning curve for every future contributor who touches
version history and nothing else.transition(state, event) → { state, commands } function moves across the
IPC boundary nearly verbatim; an XState machine would be rewritten or drag
the dependency into the main process.The trade we are accepting: we own a small amount of runtime the library would have provided (operation identity, serial execution, subscriptions). The complexity budget in this plan caps that runtime so it cannot silently grow into an in-house actor framework. If the budget is exceeded, that is evidence the decision should be revisited — see "Guardrails."
Commit b249bb40 correctly removed the unsafe fallback to main and captures
a live return branch before checking out a historical version. It also exposed
that Version History is an orchestration workflow, not ordinary component
state.
The workflow is currently spread across (all in
src/components/chat/VersionPane.tsx, ~1,000 lines):
selectedVersionIdAtom (src/atoms/appAtoms.ts), which is also used by
unrelated diff-view UI;isVisibleRef, wasVisibleRef,
currentAppIdRef, liveVersionsRef);previewRequestIdRef request counters used to reject stale results;activePreviewCheckoutPromiseRef, checkedOutVersionIdRef,
returnBranchRef, isResolvingPreviewBranchRef,
isPreviewCheckoutInProgressRef);useCheckoutVersion, useCurrentBranch, useVersions).This permits states that should be impossible and makes recovery depend on React lifecycle timing:
VersionPane.b249bb40: never guess a
return branch and never silently fall back to main.originBranch is captured immediately before the first historical
checkout.originBranch is immutable while the session owns or is
pursuing a historical checkout. It is released only when the session falls
back to browsing having never checked out, so the next selection
re-captures the live branch (which may have changed externally).appId; it never substitutes the
currently selected app.checkedOutVersionId is machine-owned repository state. It is never
inferred from a writable UI atom.targetVersionId and checkedOutVersionId are different concepts.appId,
originBranch, and checkedOutVersionId.closed only after it no longer owns a historical Git
checkout, or after no historical checkout was ever started.All types are plain TypeScript with no imports beyond other domain types.
type ExitIntent =
| { type: "none" }
| { type: "close" }
| { type: "switch-app"; nextAppId: number };
interface PreviewSession {
appId: number;
originBranch: string | null;
targetVersionId: string | null;
checkedOutVersionId: string | null;
exitIntent: ExitIntent;
}
type PreviewState =
| { type: "closed" }
| { type: "browsing"; session: PreviewSession }
| { type: "resolving-origin"; session: PreviewSession }
| { type: "checking-out"; session: PreviewSession }
| { type: "previewing"; session: PreviewSession }
| { type: "restoring"; session: PreviewSession }
| { type: "returning"; session: PreviewSession }
| {
type: "recovery-required";
session: PreviewSession;
error: SerializedError;
};
type PreviewEvent =
// UI intents
| { type: "OPEN"; appId: number }
| { type: "CLOSE" }
| { type: "APP_CHANGED"; nextAppId: number }
| { type: "SELECT_VERSION"; versionId: string }
| { type: "RESTORE" }
| { type: "RETRY_RETURN" }
// Command completions (dispatched only by the controller)
| { type: "ORIGIN_RESOLVED"; branch: string }
| { type: "ORIGIN_RESOLUTION_FAILED"; error: SerializedError }
| { type: "CHECKOUT_SUCCEEDED" }
| { type: "CHECKOUT_FAILED"; error: SerializedError }
| { type: "RESTORE_SUCCEEDED" }
| { type: "RESTORE_FAILED"; error: SerializedError }
| { type: "RETURN_SUCCEEDED" }
| { type: "RETURN_FAILED"; error: SerializedError };
type PreviewCommand =
| { type: "resolve-origin"; appId: number }
| { type: "checkout"; appId: number; versionId: string }
| { type: "return"; appId: number; branch: string }
| { type: "restore"; appId: number; versionId: string; targetBranch: string };
Note: restore maps to the existing ipc.version.revertVersion contract
(src/ipc/types → revertVersion), which already accepts a
targetBranchName. The command vocabulary uses domain language; the adapter
does the translation.
interface TransitionResult {
state: PreviewState;
commands: PreviewCommand[];
}
function transition(state: PreviewState, event: PreviewEvent): TransitionResult;
Rules that make this "world-class" rather than merely adequate:
(state, event) pair returns a result. Unhandled pairs
return { state, commands: [] } explicitly via a shared ignore(state)
helper — never by falling through — so a reviewer can distinguish
"deliberately ignored" from "forgot to handle." A runtime totality test
enumerates the full state×event matrix (see Test strategy).Date, no randomness, no imports beyond types.
Deterministic: same state + event → same result, always.switch on state.type with a never
check; inner switches on event.type with explicit ignore defaults.States not listed for an event ignore it. "record intent" means the session's
exitIntent is updated and no command is emitted.
| State | Event | Next state | Commands |
|---|---|---|---|
closed | OPEN(appId) | browsing (fresh session) | — |
browsing | SELECT_VERSION | resolving-origin (target set) | resolve-origin |
browsing | CLOSE / APP_CHANGED | closed | — |
resolving-origin | ORIGIN_RESOLVED | checking-out (originBranch captured) | checkout |
resolving-origin | ORIGIN_RESOLUTION_FAILED | browsing (target cleared) | — |
resolving-origin | SELECT_VERSION | resolving-origin (latest target; supersedes in-flight resolve) | resolve-origin |
resolving-origin | CLOSE / APP_CHANGED | closed (no checkout ever started) | — |
checking-out | CHECKOUT_SUCCEEDED | previewing, or returning if exit intent recorded | return when exiting |
checking-out | CHECKOUT_FAILED | previewing if a prior checkout exists, else browsing; returning if exit intent recorded and a prior checkout exists, else closed | return when exiting with prior checkout |
checking-out | CLOSE / APP_CHANGED | checking-out (record intent) | — |
checking-out | SELECT_VERSION | ignored (see Resolved decisions) | — |
previewing | SELECT_VERSION | checking-out (originBranch NOT recaptured) | checkout |
previewing | RESTORE | restoring | restore |
previewing | CLOSE / APP_CHANGED | returning | return |
restoring | RESTORE_SUCCEEDED | closed (restore lands on origin branch; no return needed) | — |
restoring | RESTORE_FAILED | previewing, or returning if exit intent recorded | return when exiting |
restoring | CLOSE / APP_CHANGED | restoring (record intent) | — |
returning | RETURN_SUCCEEDED | closed | — |
returning | RETURN_FAILED | recovery-required (full session + error retained) | — |
returning | CLOSE / APP_CHANGED | returning (already exiting; update intent) | — |
recovery-required | RETRY_RETURN | returning | return |
recovery-required | OPEN | stays in recovery (fresh snapshot) — re-notifies subscribers so a dismissed recovery toast re-surfaces | — |
recovery-required | SELECT_VERSION | ignored — recovery must resolve first | — |
Two rows deserve emphasis because they encode current bugs:
resolving-origin + CLOSE → closed with no command: closing while the
initial branch lookup is pending must not require a return, because no
checkout ever happened. Today this path can be missed entirely.returning + RETURN_FAILED → recovery-required retains the session:
today a failed return clears returnBranchRef, breaking the advertised
retry.Follows the repository's snake_case feature-directory convention
(src/preview_panel/, src/ipc/):
src/version_preview/
state.ts // PreviewState, PreviewEvent, PreviewCommand, PreviewSession
transition.ts // pure transition function; zero non-type imports
controller.ts // VersionPreviewController: command execution, subscriptions
commands.ts // VersionPreviewCommands interface + IPC adapter
registry.ts // app-keyed controller registry (module scope)
debug.ts // dev-only ring-buffer event log
src/hooks/
useVersionPreview.ts // React binding via useSyncExternalStore
transition.ts importing anything with side effects (React, Jotai, ipc,
logging) is a lint-visible design violation and should fail review.
One controller per session. Responsibilities, in full:
state: PreviewState and expose getSnapshot() /
subscribe(listener) — the exact contract useSyncExternalStore needs.
Snapshots are immutable; every accepted event produces a new object.send(event): run transition, store the new state, execute returned
commands, notify listeners. Synchronous from the caller's perspective;
command completions arrive later as new events.checkout, return,
restore) may never overlap. The state graph guarantees this; the
controller asserts it (throw in dev, log via the renderer logger in prod).resolve-origin
dispatch increments a private resolveEpoch; a completion tagged with a
stale epoch is dropped before it becomes an event. Mutation completions
are never epoch-filtered (invariant 9). This is the only place
operation identity exists — it never appears in React, in state, or in
events.The complexity budget (see Guardrails) caps this file. There is no generic
scheduler, no command queue beyond the in-flight promise, no timers, and no
retry logic — retry is a domain event (RETRY_RETURN), not an executor
feature.
interface VersionPreviewCommands {
getCurrentBranch(appId: number): Promise<{ branch: string }>;
checkoutVersion(appId: number, versionId: string): Promise<void>;
restoreVersion(input: {
appId: number;
versionId: string;
targetBranch: string;
}): Promise<void>;
}
The production adapter in commands.ts calls ipc.version.* directly —
not the React Query mutation hooks — so commands cannot capture the
currently selected app or depend on a mounted component. The adapter also
absorbs the side effects the hooks perform today, preserving behavior:
activeCheckoutCounterAtom (src/store/appAtoms.ts)
around checkouts, via the Jotai store instance, so unrelated UI that gates
on active checkouts keeps working;queryKeys.branches.current and queryKeys.versions.list for
the command's appId after successful mutations, via an injected
QueryClient;warningMessage from CheckoutVersionResponse as a toast; andCHECKOUT_FAILED (invariant 10). Runtime sync
failures are not machine events at all; the machine's Git belief is
settled the moment the IPC mutation resolves.Every command receives and preserves an explicit appId captured at session
start. A late completion can therefore never act on a newly selected app.
registry.ts holds a module-scope Map<appId, VersionPreviewController>:
ensureController(appId) creates on demand (on OPEN).closed is disposed and removed.recovery-required is retained even if no component is
subscribed, so recovery survives pane unmounts, chat navigation, and app
switches.ChatPanel sends APP_CHANGED(nextAppId) to the old app's
controller. The old session drains in the background (returns the old
app's repository) while the UI proceeds to the new app. If the background
return fails, the retained recovery-required controller drives a global
recovery toast naming the old app, with a working RETRY_RETURN.The registry is deliberately not a Jotai atom: the controller must outlive
React, and useSyncExternalStore is the standard, tear-free way to bind
external stores to React 18+. Jotai remains in use for what it already owns
(activeCheckoutCounterAtom, presentation atoms).
function useVersionPreview(appId: number | null) {
const controller = appId !== null ? ensureController(appId) : null;
const state = useSyncExternalStore(
controller?.subscribe ?? noopSubscribe,
controller?.getSnapshot ?? closedSnapshot,
);
return { state, send: controller?.send ?? noopSend };
}
function useVersionPreviewRecovery(): RecoverySnapshot[];
// subscribes to the registry; returns all sessions in recovery-required,
// across apps, to drive the global recovery toast.
ChatPanel stops owning isVersionPaneOpen as separate useState; pane
visibility becomes state.type !== "closed", and the open/close buttons send
OPEN/CLOSE. This deletes the visibility-edge-detection effect in
VersionPane outright — there is no longer a prop edge to infer.
debug.ts keeps a ring buffer of the last ~100
{ state.type, event.type, commands } entries per controller, logged through
the existing renderer logger at debug level and exposed on
window.__dyadVersionPreviewLog in dev builds. This is the vanilla answer to
the XState inspector: when a rare race is reported, the reproduction is a
readable event trace, and any trace replays deterministically through
transition in a test.
These replace the earlier dual-implementation spike. The vanilla design is accepted as long as it stays inside this budget; exceeding the budget is the signal to stop and revisit the library decision rather than grow an in-house framework:
transition.ts: pure, zero non-type imports, no escape hatches.controller.ts: at most ~200 lines excluding types and comments; no
timers, no generic command queue, no hierarchical/parallel state concepts,
no dynamic actor spawning.Decisions the previous document left open, now fixed:
appId
(registry.ts), bound to React with useSyncExternalStore. Not hosted in
ChatPanel state and not a Jotai atom.src/version_preview/state.ts and transition.ts with the full
transition matrix above.controller.ts, commands.ts, registry.ts, debug.ts.VersionPreviewCommands implementation
with manually resolved deferred promises.VersionPaneChatPanel to useVersionPreview; derive pane visibility from
machine state; convert open/close buttons to OPEN/CLOSE events.APP_CHANGED from the app-selection path to the old app's controller.useVersionPreviewRecovery, with a
real RETRY_RETURN action.selectedVersionIdAtom into presentation-only state for the version
diff. VersionPane, CodeView, CommitMenu, ModifiedFilesCard, and
PreviewToolbar consume the narrower presentation contract.VersionPane: previewRequestIdRef,
activePreviewCheckoutPromiseRef, checkedOutVersionIdRef,
returnBranchRef, isResolvingPreviewBranchRef,
isPreviewCheckoutInProgressRef, wasVisibleRef/isVisibleRef, and the
visibility-edge async effect.noteSaveTimeoutsRef, noteSaveSequencesRef)
untouched; note debouncing is a separate workflow, not repository state.src/components/chat/VersionPane.test.tsx.src/ipc/handlers/__tests__/undo.integration.test.ts to preserve
restore semantics.npm run fmt, npm run lint, and npm run ts before committing.npm run build before
the focused E2E test.(state.type, event.type) pair with
representative payloads; assert transition returns without throwing and
the result passes the invariant checker. This is the vanilla replacement
for a statechart visualizer — the matrix is verified, not just drawn.assertInvariants(prev, event, next)
helper encoding invariants 1–10 (e.g. originBranch never changes once
set; closed is unreachable while checkedOutVersionId is non-null unless
the last event was RETURN_SUCCEEDED or RESTORE_SUCCEEDED). Applied in
every transition test.appId, including after
APP_CHANGED.resolve-origin completion is dropped; a mutation completion never
is.recovery-required.SELECT_VERSION; close sends CLOSE; recovery UI
sends RETRY_RETURN. Components never perform Git work.main-fallback
cases.The renderer machine protects against React races but cannot guarantee recovery after a renderer crash or full app exit. A later, separately planned hardening moves session ownership and per-app Git serialization into the main process behind contract-driven IPC:
const session = await ipc.version.beginPreview({ appId });
await ipc.version.preview({ sessionToken: session.token, versionId });
await ipc.version.endPreview({ sessionToken: session.token });
The vanilla design was chosen partly for this: state.ts and transition.ts
have zero renderer dependencies and can move to the main process verbatim,
with the controller reduced to an IPC client. The follow-up requires
app-id/session-token validation, cleanup policy, and main-process integration
tests, and is intentionally excluded from this migration to keep it
reviewable.
VersionPane.selectedVersionIdAtom or pane
visibility state.appId.main fallback remains absent.transition.ts has no non-type imports; controller.ts is within the
complexity budget.