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+869+012345678901234567890123456789-47-47-01234567890123456789
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 intoAppSettingsView) 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 byACT_KIND.EXPERIMENT_SET, writes one switch to the service first, keeps its answer, and redraws every window. It goes through the oneexperimentWriter(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:togglecarries the experiment (a newPRODUCT_EXPERIMENTset) 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)andexperimentOnNow(inrenderer/use-app-state.ts) are a gated surface's one way to ask. They are tagged@publicuntil Factory's view (#2608), stacked on this PR, reads them; the same precedentshared/messages/terminal.tsset. - Shortcut lists.
AppShortcut.experimenttags a gated command.shownShortcutGroupskeeps 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
menuBarDrawerholds 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
reachablepredicatenavigation-history.tsalready 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:affectedexit 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 expectsexperiments: {}.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:toggletravels 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.shgreen. (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
astype assertion outside the allowlisted files. (Oneas constin a test fixture only.) - No
Effect.runPromise/runSync/runForkoutside 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.mdsentence 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.mdunchanged.experiment:togglecarries only anas constkey and on or off, inside the existing allowlist boundary. - Package
package.jsondeps match. (@luke/desktopand@luke/hostalready declare@luke/settings.) - Barrel/door rule.
shared/shortcuts.tsnow 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