feat(desktop,host): read and write an experiment, its gate one hook and one tag per list (experimental 2/3)

Merged
DA
6d ago
+869-47

Summary

PR 2 of Settings › Experimental (docs/proposals/experimental-settings.md, decided by Dean on 2026-10-14). It covers the Mac's half of where an experiment's choice lives, and the gates every gated feature reads. The page itself is the next PR.

Storage, sync and write (decisions 1 and 6)

  • Cache. The host keeps the experiments the service answered for the signed-in account in settings.json (SettingsStore.cacheExperiments), so a launch draws its gated surfaces before the service answers. A sign-out, or another account's sign-in, drops them.
  • Transport. They ride the settings snapshot (AppSettings.experiments, flattened into AppSettingsView) to every window and to main.
  • Reconcile. The launch and sign-in reconcile caches what the service holds. It skips the cache when an experiment write went out meanwhile, so an answer read before a write never puts a switch back.
  • Write. experiments.set, reached by ACT_KIND.EXPERIMENT_SET, writes one switch to the service first, keeps its answer, and redraws every window. It goes through the one experimentWriter (experiment-write.ts), whose writes take turns on one permit, so two quick presses land in the order they were made. An unanswered write is the row's refusal, and the switch stays where it stood. A reply that arrives after the account signed out is refused too ("The account changed before that switch landed."). Signed out, it refuses with "Sign in to turn on an experiment."

Analytics (decision 8)

  • experiment:toggle carries the experiment (a new PRODUCT_EXPERIMENT set) and on or off (PRODUCT_SETTING_VALUE). Nothing else travels.
  • A total Record, EXPERIMENT_COUNTED_AS, bridges the settings registry to that set, so a new experiment doesn't build until analytics names it.

The gates, one per list

  • Reading. useExperiment(EXPERIMENT.X) and experimentOnNow (in renderer/use-app-state.ts) are a gated surface's one way to ask. They are tagged @public until Factory's view (#2608), stacked on this PR, reads them; the same precedent shared/messages/terminal.ts set.
  • Shortcut lists. AppShortcut.experiment tags a gated command. shownShortcutGroups keeps it off the Keyboard shortcuts page and out of the Settings search while off. Its chord stays reserved in the table.
  • Menu bar. The template's items carry their command as id. A gated feature's item is removed while its experiment is off, and a separator the removal strands goes with it.
  • Menu redraw. One menuBarDrawer holds the update item and the experiments, and redraws when either moves. Main tells it from the same app-state broadcast that steers the theme.
  • ⌘K. Already gated by its offer: useAppCommand(command, undefined) removes the action, frees the chord, and makes a menu click inert.
  • History. Factory's place uses the reachable predicate navigation-history.ts already takes.
  • Not built: a "+" entry tag. No experiment has a "+" entry yet; the first one to need it adds it.

Evidence

  • Platform-independent checks: pnpm check:affected exit 0.
  • New or extended tests:
    • packages/host/src/settings-store.test.ts +2: the cache survives a reopen for the signed-in account; another account's answer is dropped; sign-out and account switch leave nothing on.
    • packages/host/src/account-preferences-client.test.ts +2: the answer's experiments are read past an unknown key; an older answer without the part reads as none; the write is a one-key patch. The existing test now expects experiments: {}.
    • packages/host/src/experiment-write.test.ts +4: real store and real client over a fake HTTP layer. The service's answer is kept and carried; two presses land in press order while the first answer is held back; an unanswered write leaves the switch; a reply after sign-out is refused. Added after Devin's review.
    • packages/features/src/analytics/product-events.test.ts +1: experiment:toggle travels with its two properties and refuses a key outside the set.
    • apps/desktop/src/main/app-menu.test.ts (new) +3: a command that does not stand has no item and strands no separator; the drawer redraws only when the update item or the experiments move, and keeps one across the other.

CI could not verify the Mac. The menu bar's redraw and the gated lists run in the Electron app, and CI is Linux only. The release rehearsal on a tag is the only Mac gate.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged.
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged. (A new method, experiments.set; no envelope changed.)
  • No new as type assertion outside the allowlisted files. (One as const in a test fixture only.)
  • No Effect.runPromise/runSync/runFork outside the runtime edges. (Tests only, which are their own edge.)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch.
  • No Effect, Stream, or Layer written as a statement and dropped.
  • Test count delta: +12 (listed above).
  • Each hand-rolled file the PR replaces is deleted. (None.)
  • Every CLAUDE.md/AGENTS.md sentence naming a changed part is edited. No guide names the menu drawer, the settings snapshot's fields, or the act. app-menu.ts's header now says what the drawer holds. The renderer AGENTS.md "Experiments" paragraph (approved by Dean) lands with the page PR.
  • PRIVACY.md unchanged. experiment:toggle carries only an as const key and on or off, inside the existing allowlist boundary.
  • Package package.json deps match. (@luke/desktop and @luke/host already declare @luke/settings.)
  • Barrel/door rule. shared/shortcuts.ts now reads @luke/settings, which the renderer already imports.

Notes

  • Blockers or follow-up verification: none. Factory (#2608) stacks on this PR and adds the first tagged command, its sidebar row, and its place, each with an absent-while-off test.

🤖 Generated with Claude Code