@jxsuite/studio 2.4.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/chunks/{cf-settings-g5fbf2zp.js → cf-settings-zj2cj1ar.js} +6 -3
  2. package/dist/chunks/{content-source-k9724vcv.js → content-source-7fy9qrm0.js} +10 -9
  3. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-6vvx96n2.js} +9 -8
  4. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-kwzvpemr.js} +13 -12
  5. package/dist/chunks/{files-yew27sn9.js → files-xcybanxe.js} +14 -13
  6. package/dist/chunks/{format-host-wewba4nq.js → format-host-8fr3545e.js} +2 -2
  7. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-g01d93vc.js} +9 -8
  8. package/dist/chunks/{layers-panel-qnsghwwd.js → layers-panel-27essgkc.js} +12 -11
  9. package/dist/chunks/{publish-panel-a0pdft5w.js → publish-panel-09sy2bab.js} +8 -7
  10. package/dist/chunks/{quick-search-9avyc4pc.js → quick-search-sefb61v8.js} +15 -14
  11. package/dist/chunks/{right-panel-kcnhtc5j.js → right-panel-9r32jd5j.js} +11 -10
  12. package/dist/chunks/{studio-aweyr4v1.js → studio-2x9zbxmx.js} +2 -2
  13. package/dist/chunks/{studio-ef8t53vc.js → studio-3dkcd4c5.js} +3 -3
  14. package/dist/chunks/{studio-hb7c46jy.js → studio-5efrtn1m.js} +9 -7
  15. package/dist/chunks/{studio-v1va0vhd.js → studio-6z5eyjwn.js} +24 -25
  16. package/dist/chunks/{studio-ke244pam.js → studio-7p35254k.js} +33 -11
  17. package/dist/chunks/{studio-f5yyvqxv.js → studio-8ezdegcx.js} +13 -11
  18. package/dist/chunks/{studio-cz2j1c0g.js → studio-a0yp7syn.js} +55 -18
  19. package/dist/chunks/{studio-26sv44br.js → studio-dc8s8heb.js} +34 -23
  20. package/dist/chunks/{studio-jnakhg87.js → studio-g5gdqnyd.js} +4 -4
  21. package/dist/chunks/{studio-rqemfhz6.js → studio-hcxzyk3x.js} +2 -2
  22. package/dist/chunks/{studio-ws6ef6js.js → studio-hj2se583.js} +12 -10
  23. package/dist/chunks/{studio-pyrw8h0w.js → studio-j595e748.js} +407 -167
  24. package/dist/chunks/{studio-dgj7gq44.js → studio-k4jvjcfx.js} +4 -4
  25. package/dist/chunks/studio-kfpek536.js +28 -0
  26. package/dist/chunks/{studio-sccge2wf.js → studio-p5dja96s.js} +2 -2
  27. package/dist/chunks/{studio-0b6fs05q.js → studio-pkp61mh8.js} +5 -5
  28. package/dist/chunks/studio-qpfq7xhm.js +1802 -0
  29. package/dist/chunks/{studio-ra7ar50f.js → studio-r5xbtctf.js} +51 -1565
  30. package/dist/chunks/{studio-v2c5pmjj.js → studio-rh1cnamv.js} +17 -15
  31. package/dist/chunks/{studio-28xc8ws4.js → studio-ry1vz5sv.js} +10 -8
  32. package/dist/chunks/{studio-hyn3vhwr.js → studio-xntfdy1x.js} +5 -5
  33. package/dist/iframe-entry.js +344 -26
  34. package/dist/studio.js +274 -247
  35. package/package.json +9 -9
  36. package/src/browse/library-layouts.ts +3 -3
  37. package/src/browse/library-pane.ts +2 -2
  38. package/src/browse/library-preview.ts +19 -3
  39. package/src/canvas/asset-refs.ts +119 -0
  40. package/src/canvas/asset-resolve.ts +256 -0
  41. package/src/canvas/canvas-live-render.ts +23 -15
  42. package/src/canvas/iframe-entry.ts +1 -0
  43. package/src/canvas/iframe-host.ts +4 -0
  44. package/src/canvas/iframe-protocol.ts +9 -0
  45. package/src/canvas/iframe-render.ts +28 -10
  46. package/src/canvas/site-style-css.ts +2 -6
  47. package/src/editor/file-drop-action.ts +2 -1
  48. package/src/files/files.ts +2 -2
  49. package/src/files/media-paths.ts +37 -14
  50. package/src/files/media-upload.ts +47 -6
  51. package/src/grid/grid-idle.ts +45 -0
  52. package/src/grid/grid-view.ts +21 -0
  53. package/src/grid/schema-columns.ts +2 -1
  54. package/src/grid/sources/content-source.ts +24 -3
  55. package/src/new-project/import-tab.ts +3 -2
  56. package/src/panels/ai-chat/composer.ts +30 -13
  57. package/src/panels/frontmatter-fields.ts +6 -2
  58. package/src/panels/properties-panel.ts +8 -2
  59. package/src/panels/seo-modal.ts +5 -6
  60. package/src/panels/signals-panel.ts +2 -1
  61. package/src/platforms/cloud.ts +14 -3
  62. package/src/platforms/devserver.ts +5 -1
  63. package/src/services/ai-models.ts +101 -11
  64. package/src/services/ai-settings.ts +69 -71
  65. package/src/services/cf-settings.ts +19 -37
  66. package/src/services/document-assistant.ts +4 -3
  67. package/src/services/idle.ts +6 -1
  68. package/src/services/settings/definitions.ts +164 -0
  69. package/src/services/settings/kernel.ts +335 -0
  70. package/src/services/settings/write-queue.ts +116 -0
  71. package/src/settings/preferences-accounts.ts +6 -19
  72. package/src/settings/preferences-keymap.ts +8 -18
  73. package/src/settings/schema-field-ui.ts +2 -2
  74. package/src/shell.ts +6 -14
  75. package/src/studio.ts +32 -1
  76. package/src/types.ts +61 -3
  77. package/src/ui/ai-credentials-form.ts +52 -27
  78. package/src/ui/media-picker.ts +6 -11
  79. package/src/utils/studio-utils.ts +45 -3
  80. package/dist/chunks/studio-a7pzveq1.js +0 -110
  81. package/src/canvas/content-assets.ts +0 -263
  82. package/src/services/settings-store.ts +0 -99
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The settings registry — every application setting Studio holds, declared once.
3
+ *
4
+ * Before this table the answer to "which settings roam, and what is each one's default" lived in
5
+ * four places: a `PERSISTED_SETTINGS_KEYS` array, a private `const` per key in each `*-settings.ts`,
6
+ * a bespoke getter/setter pair beside it, and whatever the getter's `||` happened to fall back to.
7
+ * Adding a roaming setting meant editing all four and hoping; the key strings themselves were
8
+ * duplicated between the array and the module that owned them.
9
+ *
10
+ * Two properties of this table are load-bearing rather than tidy:
11
+ *
12
+ * 1. **Scope is declared, not inferred.** {@link USER_SETTINGS} is DERIVED from it, so a setting
13
+ * roams because it says `scope: "user"` — not because someone remembered to add its key to a
14
+ * second list. `device` is the honest name for the rest: panel geometry and a palette's MRU
15
+ * describe *this window*, and a user with two windows open wants them to differ.
16
+ * 2. **A default is not a value.** The default is what a *reader* sees when nothing is stored; it
17
+ * is never itself stored. `getModel()` used to mask unset as `"gpt-4o"` with `||`, a form
18
+ * prefilled from it, and Save wrote the mask back — which is how an install whose owner had
19
+ * configured an entirely different provider was left holding `jx.ai.model: "gpt-4o"` and
20
+ * nothing else. {@link readSetting} and {@link readStoredSetting} in `kernel.ts` are two
21
+ * functions for exactly this reason.
22
+ *
23
+ * @license MIT
24
+ */
25
+
26
+ /**
27
+ * Where a setting lives.
28
+ *
29
+ * `user` follows the author — every window, every project, and (where the platform has a backend
30
+ * store) every browser profile the launcher hands out. `device` stays where it was written.
31
+ */
32
+ export type SettingScope = "user" | "device";
33
+
34
+ /** One setting, as the registry declares it. */
35
+ export interface SettingDefinition {
36
+ /** The storage key, in both localStorage and the backend map. */
37
+ key: string;
38
+ scope: SettingScope;
39
+ /** What a reader sees when nothing is stored. Never written — see the module docblock. */
40
+ default: string;
41
+ /**
42
+ * Whether the value is a credential.
43
+ *
44
+ * Nothing in the kernel treats a secret differently; the flag is what lets a surface refuse to
45
+ * print one, a diagnostic dump refuse to include one, and the Accounts list report _that_ one is
46
+ * held rather than what it is (`specs/studio.md` §15 rule 1).
47
+ */
48
+ secret?: boolean;
49
+ /**
50
+ * Canonicalise an incoming value before it is stored. Total: it is handed raw user input.
51
+ *
52
+ * Applied on write only. A value already on disk is returned as it was found, so a normalizer
53
+ * that tightens later cannot silently rewrite what someone else stored.
54
+ */
55
+ normalize?: (raw: string) => string;
56
+ }
57
+
58
+ /** Trim, and drop trailing slashes — an endpoint is a base, not a path. */
59
+ function normalizeBaseUrl(raw: string): string {
60
+ return raw.trim().replace(/\/+$/, "");
61
+ }
62
+
63
+ /** Trim. Credentials are pasted, and a paste carries whitespace. */
64
+ function trimmed(raw: string): string {
65
+ return raw.trim();
66
+ }
67
+
68
+ /**
69
+ * Every setting, keyed by the name the rest of the app refers to it by.
70
+ *
71
+ * Declared `as const satisfies` so each entry keeps its literal `key` for callers while still being
72
+ * checked against {@link SettingDefinition}.
73
+ */
74
+ export const SETTINGS = {
75
+ aiBaseUrl: {
76
+ default: "",
77
+ key: "jx.ai.baseUrl",
78
+ normalize: normalizeBaseUrl,
79
+ scope: "user",
80
+ },
81
+ /**
82
+ * The provider key. Stays in the settings store rather than moving to the launcher's credential
83
+ * store: the webview is its legitimate consumer — it sends the key itself as `X-Api-Key` — so
84
+ * withholding it and then transmitting it would be the same trust boundary with more parts. See
85
+ * `specs/desktop.md` §3.6, which draws that line.
86
+ */
87
+ aiOpenAiKey: {
88
+ default: "",
89
+ key: "jx.ai.openaiKey",
90
+ normalize: trimmed,
91
+ scope: "user",
92
+ secret: true,
93
+ },
94
+ /**
95
+ * `"gpt-4o"` is what a sender falls back to, NOT a stored choice. A surface that writes back must
96
+ * read through `readStoredSetting`, or it persists a model the user never picked.
97
+ */
98
+ aiModel: {
99
+ default: "gpt-4o",
100
+ key: "jx.ai.model",
101
+ normalize: trimmed,
102
+ scope: "user",
103
+ },
104
+ /**
105
+ * The author's rebindings, as a JSON object of `command id → chords`.
106
+ *
107
+ * Roams, so a keyboard learned once is the keyboard everywhere. `normalize` is why the field
108
+ * exists on {@link SettingDefinition}: an empty override map is the absence of an override, and
109
+ * storing `"{}"` would make every window think a layer had been authored.
110
+ */
111
+ keybindings: {
112
+ default: "",
113
+ key: "jx.keybindings",
114
+ normalize: (raw) => (raw.trim() === "{}" ? "" : raw.trim()),
115
+ scope: "user",
116
+ },
117
+ /**
118
+ * The chrome theme.
119
+ *
120
+ * Read at module evaluation, before the first paint — `shell.ts` says a mismatch with the theme
121
+ * `index.html` hard-codes flashes the shell. That is the reason the kernel keeps a synchronous
122
+ * cache rather than awaiting the backend.
123
+ */
124
+ theme: {
125
+ default: "",
126
+ key: "jx-studio-theme",
127
+ normalize: trimmed,
128
+ scope: "user",
129
+ },
130
+ cfAccountId: {
131
+ default: "",
132
+ key: "jx.cf.accountId",
133
+ normalize: trimmed,
134
+ scope: "user",
135
+ },
136
+ cfToken: {
137
+ default: "",
138
+ key: "jx.cf.token",
139
+ normalize: trimmed,
140
+ scope: "user",
141
+ secret: true,
142
+ },
143
+ } as const satisfies Record<string, SettingDefinition>;
144
+
145
+ /** The registry's own names — `SETTINGS.aiModel`, not `"jx.ai.model"`. */
146
+ export type SettingName = keyof typeof SETTINGS;
147
+
148
+ /** Every declared setting, in declaration order. */
149
+ export const ALL_SETTINGS: readonly SettingDefinition[] = Object.values(SETTINGS);
150
+
151
+ /**
152
+ * The settings that roam, derived from {@link SETTINGS}.
153
+ *
154
+ * This replaces the hand-maintained `PERSISTED_SETTINGS_KEYS`. A setting joins it by declaring
155
+ * `scope: "user"` and by no other means, so the list cannot fall behind the table it describes.
156
+ */
157
+ export const USER_SETTINGS: readonly SettingDefinition[] = ALL_SETTINGS.filter(
158
+ (definition) => definition.scope === "user",
159
+ );
160
+
161
+ /** The roaming keys as strings — what a backend map is keyed by. */
162
+ export const USER_SETTING_KEYS: readonly string[] = USER_SETTINGS.map(
163
+ (definition) => definition.key,
164
+ );
@@ -0,0 +1,335 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * The settings kernel — one owner for every application setting's value.
4
+ *
5
+ * **The model.** The backend store is authoritative, `localStorage` is a synchronous cache of it,
6
+ * and every write is a per-key patch applied by whoever owns the file. What this replaces collapsed
7
+ * all three roles: `localStorage` was the truth, and it was mirrored to disk by whole-map replaces
8
+ * computed from whichever window happened to write last. On the chromium launcher — where every
9
+ * window is its own process with its own browser profile, and so its own `localStorage` — a window
10
+ * holding no settings could therefore overwrite the shared file with an almost empty map. Nothing
11
+ * told the other window it had happened.
12
+ *
13
+ * **Why a Map and not just localStorage.** Reads must be synchronous: the chrome theme is read
14
+ * before the first paint (`shell.ts` says so explicitly — a mismatch flashes the shell), and every
15
+ * credential gate reads on the render path. But the authoritative copy arrives asynchronously. An
16
+ * in-memory Map seeded from the cache at module evaluation gives both: correct on the first frame,
17
+ * and reconciled by {@link hydrateSettings} the moment the backend answers.
18
+ *
19
+ * **The one deletion path.** {@link setSetting} with `""` STORES an empty string;
20
+ * {@link clearSetting} is the only thing that removes. Blank used to mean delete, which read as a
21
+ * convenience until a form that blanked its own drafts on save called the setters with them — and
22
+ * pressing Save a second time revoked the credentials the first press had stored. Making blank
23
+ * unrepresentable as a deletion is what stops that whole class, rather than fixing the one caller.
24
+ *
25
+ * @license MIT
26
+ */
27
+
28
+ import { getPlatform, hasPlatform } from "../../platform";
29
+ import { notify } from "../notify";
30
+ import { ALL_SETTINGS, USER_SETTINGS } from "./definitions";
31
+ import type { SettingDefinition } from "./definitions";
32
+ import { createWriteQueue } from "./write-queue";
33
+ import type { SettingsPatch } from "./write-queue";
34
+
35
+ /** The synchronous source of truth. Absent key means unset — which is not the same as `""`. */
36
+ const values = new Map<string, string>();
37
+
38
+ /** Mirror a value into the cache, so the next boot is correct before the backend answers. */
39
+ function writeCache(key: string, value: string | null): void {
40
+ try {
41
+ if (value === null) {
42
+ globalThis.localStorage?.removeItem(key);
43
+ } else {
44
+ globalThis.localStorage?.setItem(key, value);
45
+ }
46
+ } catch {
47
+ /* Storage unavailable (private mode) — the Map still serves this session. */
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Seed from the cache, at module evaluation and before anything can read.
53
+ *
54
+ * This is what makes the first frame correct: the chrome theme is read before the first paint, and
55
+ * `shell.ts` records that a mismatch with the theme `index.html` hard-codes flashes the shell. The
56
+ * backend's copy arrives later and reconciles through {@link hydrateSettings}.
57
+ *
58
+ * One `try` around the whole loop rather than one per key — storage that throws throws for all of
59
+ * them, and a private-mode window simply starts empty.
60
+ */
61
+ try {
62
+ for (const definition of ALL_SETTINGS) {
63
+ const cached = globalThis.localStorage?.getItem(definition.key);
64
+ if (cached !== null && cached !== undefined) {
65
+ values.set(definition.key, cached);
66
+ }
67
+ }
68
+ } catch {
69
+ /* Storage unavailable — the map starts empty, and hydration fills it. */
70
+ }
71
+
72
+ // ─── Reading ──────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * The value the user actually chose — the ONLY read. A default never stands in for silence here.
76
+ *
77
+ * There is deliberately no default-masking sibling. One existed, and every bug in this area came
78
+ * from a caller taking the masked value for a chosen one: a form prefilled with `"gpt-4o"` and
79
+ * saved it, so an install whose owner had configured a different provider was left holding
80
+ * `jx.ai.model: "gpt-4o"` and nothing else. A consumer that needs a fallback composes it where the
81
+ * fallbacks are known — see `ai-models.ts`'s `preferredModel`, which asks the backend what it
82
+ * prefers before reaching for a name invented here.
83
+ *
84
+ * @returns The stored value, or "" when none is stored.
85
+ */
86
+ export function readStoredSetting(definition: SettingDefinition): string {
87
+ return values.get(definition.key) ?? "";
88
+ }
89
+
90
+ /** Whether a value is stored — as distinct from stored-and-empty, or absent-with-a-default. */
91
+ export function hasSetting(definition: SettingDefinition): boolean {
92
+ return (values.get(definition.key) ?? "") !== "";
93
+ }
94
+
95
+ // ─── Change notification ──────────────────────────────────────────────────────
96
+
97
+ const listeners = new Set<(keys: readonly string[]) => void>();
98
+
99
+ /**
100
+ * Subscribe to settings changes, local or remote. Returns the unsubscribe.
101
+ *
102
+ * This is the seam that lets a surface hold no copy of a setting: the composer's model picker, the
103
+ * Accounts list and the New Project gates all read through the kernel and repaint on this, rather
104
+ * than each caching a value that another window can invalidate.
105
+ */
106
+ export function onSettingsChanged(listener: (keys: readonly string[]) => void): () => void {
107
+ listeners.add(listener);
108
+ return () => {
109
+ listeners.delete(listener);
110
+ };
111
+ }
112
+
113
+ /** Announce the keys that moved. Never called with an empty list. */
114
+ function announce(keys: readonly string[]): void {
115
+ for (const listener of listeners) {
116
+ listener(keys);
117
+ }
118
+ }
119
+
120
+ // ─── Writing ──────────────────────────────────────────────────────────────────
121
+
122
+ const queue = createWriteQueue({
123
+ onError(error: unknown) {
124
+ notify("error", "Your settings could not be saved.", {
125
+ detail: error instanceof Error ? error.message : String(error),
126
+ key: "settings-write",
127
+ source: "Settings",
128
+ });
129
+ },
130
+ async send(patch: SettingsPatch) {
131
+ if (!hasPlatform()) {
132
+ return;
133
+ }
134
+ const platform = getPlatform();
135
+ if (!platform.patchSettings) {
136
+ return;
137
+ }
138
+ /*
139
+ * Only the keys that actually moved go on the wire. A key this window never touched is one the
140
+ * backend leaves exactly as it found it — which is what stops a second window's settings being
141
+ * cleared by a window that happens to hold none of them.
142
+ *
143
+ * An empty value is sent as a REMOVAL. The store is what a fresh install reads back at boot, and
144
+ * `"jx.ai.model": ""` there would be a model choice rather than the absence of one.
145
+ */
146
+ const set: Record<string, string> = {};
147
+ const remove: string[] = [];
148
+ for (const [key, value] of Object.entries(patch)) {
149
+ if (value) {
150
+ set[key] = value;
151
+ } else {
152
+ remove.push(key);
153
+ }
154
+ }
155
+ await platform.patchSettings({ remove, set });
156
+ },
157
+ });
158
+
159
+ /** Resolves when every settings write made so far has been sent. */
160
+ export function settingsSettled(): Promise<void> {
161
+ return queue.settled();
162
+ }
163
+
164
+ /**
165
+ * Apply a patch: `null` deletes, a string stores. Keys named by neither are untouched.
166
+ *
167
+ * Returns the keys that actually moved, so a no-op write announces nothing and schedules nothing.
168
+ */
169
+ function apply(patch: SettingsPatch): string[] {
170
+ const moved: string[] = [];
171
+ for (const [key, next] of Object.entries(patch)) {
172
+ const before = values.get(key) ?? null;
173
+ if (before === next) {
174
+ continue;
175
+ }
176
+ if (next === null) {
177
+ values.delete(key);
178
+ } else {
179
+ values.set(key, next);
180
+ }
181
+ writeCache(key, next);
182
+ moved.push(key);
183
+ }
184
+ return moved;
185
+ }
186
+
187
+ /** Store a value. An empty string is a VALUE; {@link clearSetting} is the only deletion. */
188
+ export function setSetting(definition: SettingDefinition, value: string): void {
189
+ const next = definition.normalize ? definition.normalize(value) : value;
190
+ const moved = apply({ [definition.key]: next });
191
+ if (moved.length > 0) {
192
+ queue.enqueue({ [definition.key]: next });
193
+ announce(moved);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Store several settings as one change — one announcement, one write.
199
+ *
200
+ * What a form's Save should call. Three separate `setSetting`s would coalesce into one write
201
+ * anyway, but they would announce three times, and each intermediate announcement describes a state
202
+ * the user never asked for.
203
+ */
204
+ export function setSettings(entries: readonly [SettingDefinition, string][]): void {
205
+ const patch: SettingsPatch = {};
206
+ for (const [definition, value] of entries) {
207
+ patch[definition.key] = definition.normalize ? definition.normalize(value) : value;
208
+ }
209
+ const moved = apply(patch);
210
+ if (moved.length > 0) {
211
+ queue.enqueue(patch);
212
+ announce(moved);
213
+ }
214
+ }
215
+
216
+ /** Forget several settings as one change — the shape a Disconnect wants. */
217
+ export function clearSettings(definitions: readonly SettingDefinition[]): void {
218
+ const patch: SettingsPatch = {};
219
+ for (const definition of definitions) {
220
+ patch[definition.key] = null;
221
+ }
222
+ const moved = apply(patch);
223
+ if (moved.length > 0) {
224
+ queue.enqueue(patch);
225
+ announce(moved);
226
+ }
227
+ }
228
+
229
+ // ─── Hydration ────────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Reconcile with the backend store. Call once, after the platform is registered; a no-op on
233
+ * platforms without one (dev server, cloud), where the cache is all there is.
234
+ *
235
+ * Deliberately not awaited by the boot path — hydration must not block the first paint — which is
236
+ * safe because the Map is already serving cached values by then. Anything the backend holds wins;
237
+ * anything only this machine holds is pushed up once, so a user upgrading from a cache-only build
238
+ * does not lose what they had configured.
239
+ */
240
+ export async function hydrateSettings(): Promise<void> {
241
+ if (!hasPlatform()) {
242
+ return;
243
+ }
244
+ const platform = getPlatform();
245
+ if (!platform.getSettings) {
246
+ return;
247
+ }
248
+ let stored: Record<string, string>;
249
+ try {
250
+ stored = await platform.getSettings();
251
+ } catch {
252
+ return;
253
+ }
254
+ const incoming: SettingsPatch = {};
255
+ let needsMigration = false;
256
+ for (const definition of USER_SETTINGS) {
257
+ const backendValue = stored[definition.key];
258
+ if (backendValue) {
259
+ incoming[definition.key] = backendValue;
260
+ } else if (values.get(definition.key)) {
261
+ needsMigration = true;
262
+ }
263
+ }
264
+ const moved = apply(incoming);
265
+ if (moved.length > 0) {
266
+ announce(moved);
267
+ }
268
+ if (needsMigration) {
269
+ /* Push this machine's cache-only values up, through the same queue as any other write so it
270
+ cannot race one the user is making at the same moment. Only the keys the backend lacks — a
271
+ patch says nothing about the rest, so hydration cannot echo back what it just read. */
272
+ const push: SettingsPatch = {};
273
+ for (const definition of USER_SETTINGS) {
274
+ const value = values.get(definition.key);
275
+ if (value && !stored[definition.key]) {
276
+ push[definition.key] = value;
277
+ }
278
+ }
279
+ queue.enqueue(push);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Adopt the backend store as another window (or a hand-edit) left it.
285
+ *
286
+ * Applied and announced, but never written back: this is news, not an edit, and echoing it would
287
+ * have two windows writing the same value at each other. A key the store no longer holds is
288
+ * forgotten here too, so a Disconnect in one window reaches the others.
289
+ *
290
+ * Self-echo needs no filter. Our own write reaches us through the same watch, and by then the
291
+ * values already match — {@link apply} reports nothing moved, so nothing is announced.
292
+ */
293
+ export function adoptRemoteSettings(stored: Record<string, string>): void {
294
+ const patch: SettingsPatch = {};
295
+ for (const definition of USER_SETTINGS) {
296
+ patch[definition.key] = stored[definition.key] ?? null;
297
+ }
298
+ const moved = apply(patch);
299
+ if (moved.length > 0) {
300
+ announce(moved);
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Subscribe to the backend store, if this platform can push. A no-op where it cannot.
306
+ *
307
+ * Called once at boot. The returned unsubscribe is not used — Studio boots this module once per
308
+ * window and never unwinds it — so it is deliberately dropped rather than stored somewhere nothing
309
+ * would read it.
310
+ */
311
+ export function watchRemoteSettings(): void {
312
+ if (!hasPlatform()) {
313
+ return;
314
+ }
315
+ getPlatform().subscribeSettings?.(adoptRemoteSettings);
316
+ }
317
+
318
+ /**
319
+ * Drop every value this kernel holds, cache included.
320
+ *
321
+ * The `?profile=fresh` startup profile's half of the story: clearing `localStorage` alone left the
322
+ * Map — and the backend store — untouched, so "fresh" was not fresh. This does not write to the
323
+ * backend, because a clean profile must not destroy credentials that belong to the user rather than
324
+ * to the profile.
325
+ */
326
+ export function resetSettings(): void {
327
+ const moved = [...values.keys()];
328
+ values.clear();
329
+ for (const key of moved) {
330
+ writeCache(key, null);
331
+ }
332
+ if (moved.length > 0) {
333
+ announce(moved);
334
+ }
335
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * A coalescing, serializing write queue.
3
+ *
4
+ * The defect it exists to make unrepresentable: saving a provider mutates three settings through
5
+ * three setters, and each setter used to fire its own whole-map write. Three overlapping writes went
6
+ * out against one file, each reading storage at a different moment, with no ordering between them —
7
+ * so whichever landed last won. That was observed in the wild as a settings file holding
8
+ * `{"jx.ai.model": "gpt-4o"}`: the SECOND of three snapshots, landing last, a state storage itself
9
+ * never rested in.
10
+ *
11
+ * Two rules, and they are different guarantees:
12
+ *
13
+ * 1. **Coalesce.** Every mutation in one synchronous burst merges into a single patch, sent once
14
+ * the burst has finished. N setters produce one write, not N.
15
+ * 2. **Serialize.** At most one send is in flight. A mutation made while one is out waits for it
16
+ * rather than racing it, so the order the app made changes in is the order the store sees them.
17
+ *
18
+ * Generic over the transport so the kernel can hand it a platform call and a test can hand it an
19
+ * array. `schedule` is injectable for the same reason.
20
+ *
21
+ * @license MIT
22
+ */
23
+
24
+ /**
25
+ * A set of changes to apply. `null` DELETES the key; a key named by neither map is left alone.
26
+ *
27
+ * That is the whole difference from the whole-map replace this supersedes: a writer that says
28
+ * nothing about a key cannot destroy it, so a window with a stale or empty view of settings can no
29
+ * longer clear a sibling window's.
30
+ */
31
+ export type SettingsPatch = Record<string, string | null>;
32
+
33
+ export interface WriteQueueOptions {
34
+ /** Deliver one merged patch. Rejection is reported through {@link WriteQueueOptions.onError}. */
35
+ send: (patch: SettingsPatch) => Promise<void>;
36
+ /** Called with the failure and the patch that did not land. */
37
+ onError?: (error: unknown, patch: SettingsPatch) => void;
38
+ /**
39
+ * Defer to the end of the current burst. Defaults to `queueMicrotask`.
40
+ *
41
+ * A microtask rather than a timer: the burst is synchronous, so the patch goes out in the same
42
+ * turn the user acted in, and nothing can observe a half-applied save.
43
+ */
44
+ schedule?: (run: () => void) => void;
45
+ }
46
+
47
+ export interface WriteQueue {
48
+ /** Merge `patch` into the pending one, scheduling a send if none is pending. */
49
+ enqueue: (patch: SettingsPatch) => void;
50
+ /** Resolves when everything enqueued so far has been sent (or has failed and been reported). */
51
+ settled: () => Promise<void>;
52
+ }
53
+
54
+ /**
55
+ * Create a queue over `send`.
56
+ *
57
+ * @param {WriteQueueOptions} opts
58
+ * @returns {WriteQueue}
59
+ */
60
+ export function createWriteQueue(opts: WriteQueueOptions): WriteQueue {
61
+ const schedule = opts.schedule ?? queueMicrotask;
62
+
63
+ /** Merged changes not yet handed to `send`; null when nothing is waiting. */
64
+ let pending: SettingsPatch | null = null;
65
+
66
+ /**
67
+ * The chain every send is appended to.
68
+ *
69
+ * Always resolved — each link ends in a handler — so one failed write cannot poison the writes
70
+ * queued behind it.
71
+ */
72
+ let tail: Promise<void> = Promise.resolve();
73
+
74
+ /**
75
+ * Resolves when the most recently scheduled burst has been sent.
76
+ *
77
+ * Tracked explicitly rather than inferred from {@link tail}: a caller that enqueues and then
78
+ * awaits would otherwise be awaiting the tail as it stood BEFORE its own patch joined it, and
79
+ * whether that happens to work depends on how many turns the scheduler takes.
80
+ */
81
+ let idle: Promise<void> = Promise.resolve();
82
+
83
+ /** Hand the merged burst to `send`, appended to the chain. */
84
+ function flush(): Promise<void> {
85
+ const patch = pending;
86
+ pending = null;
87
+ if (!patch) {
88
+ return tail;
89
+ }
90
+ tail = tail
91
+ .then(() => opts.send(patch))
92
+ .catch((error: unknown) => {
93
+ opts.onError?.(error, patch);
94
+ });
95
+ return tail;
96
+ }
97
+
98
+ return {
99
+ enqueue(patch: SettingsPatch) {
100
+ if (pending) {
101
+ Object.assign(pending, patch);
102
+ return;
103
+ }
104
+ pending = { ...patch };
105
+ idle = new Promise<void>((resolve) => {
106
+ schedule(() => {
107
+ void flush().then(resolve);
108
+ });
109
+ });
110
+ },
111
+
112
+ settled() {
113
+ return idle;
114
+ },
115
+ };
116
+ }
@@ -20,15 +20,9 @@
20
20
  */
21
21
 
22
22
  import { clearGithubToken, githubTokenLocation, githubTokenStored } from "../github/github-auth";
23
- import { getCfAccountId, getCfToken, setCfAccountId, setCfToken } from "../services/cf-settings";
24
- import {
25
- getBaseUrl,
26
- getModel,
27
- hasOpenAiKey,
28
- setBaseUrl,
29
- setModel,
30
- setOpenAiKey,
31
- } from "../services/ai-settings";
23
+ import { clearCfConnection, getCfAccountId, getCfToken } from "../services/cf-settings";
24
+ import { clearAiProvider, getBaseUrl, hasOpenAiKey } from "../services/ai-settings";
25
+ import { preferredModel } from "../services/ai-models";
32
26
 
33
27
  /** One stored credential, as the Accounts section renders it. */
34
28
  export interface AccountRecord {
@@ -53,7 +47,7 @@ export function listAccounts(): AccountRecord[] {
53
47
  const githubToken = githubTokenStored();
54
48
  const cfToken = getCfToken();
55
49
  const cfAccount = getCfAccountId();
56
- const model = getModel();
50
+ const model = preferredModel();
57
51
  const endpoint = getBaseUrl();
58
52
  return [
59
53
  {
@@ -74,11 +68,7 @@ export function listAccounts(): AccountRecord[] {
74
68
  ? `Key stored. Model ${model}${endpoint ? ` via ${endpoint}` : ""}.`
75
69
  : "No key stored. The Assistant section is where one is entered.",
76
70
  connected: hasOpenAiKey(),
77
- revoke: () => {
78
- setOpenAiKey("");
79
- setBaseUrl("");
80
- setModel("");
81
- },
71
+ revoke: clearAiProvider,
82
72
  },
83
73
  {
84
74
  id: "cloudflare",
@@ -87,10 +77,7 @@ export function listAccounts(): AccountRecord[] {
87
77
  ? `Connected${cfAccount ? ` — account ${cfAccount}` : ""}.`
88
78
  : "Not connected. Publishing will ask when it needs it.",
89
79
  connected: Boolean(cfToken),
90
- revoke: () => {
91
- setCfToken("");
92
- setCfAccountId("");
93
- },
80
+ revoke: clearCfConnection,
94
81
  },
95
82
  ];
96
83
  }