@jxsuite/studio 2.4.3 → 4.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 (208) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
  33. package/dist/chunks/studio-2zkz8evn.js +3163 -0
  34. package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
  57. package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/studio-kyh9avvv.js +28 -0
  60. package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-sccge2wf.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
  81. package/dist/chunks/studio-xrapxwwc.js +1539 -0
  82. package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +450 -48
  86. package/dist/studio.js +2455 -1761
  87. package/package.json +16 -15
  88. package/src/browse/library-layouts.ts +3 -3
  89. package/src/browse/library-pane.ts +59 -5
  90. package/src/browse/library-preview.ts +19 -3
  91. package/src/canvas/asset-refs.ts +119 -0
  92. package/src/canvas/asset-resolve.ts +256 -0
  93. package/src/canvas/canvas-live-render.ts +23 -15
  94. package/src/canvas/canvas-render.ts +23 -1
  95. package/src/canvas/iframe-entry.ts +1 -0
  96. package/src/canvas/iframe-host.ts +4 -0
  97. package/src/canvas/iframe-patch.ts +47 -21
  98. package/src/canvas/iframe-protocol.ts +9 -0
  99. package/src/canvas/iframe-render.ts +50 -16
  100. package/src/commands/app-commands.ts +4 -0
  101. package/src/commands/command-args.ts +29 -0
  102. package/src/commands/context.ts +15 -1
  103. package/src/commands/defaults.ts +29 -1
  104. package/src/commands/levels.ts +16 -0
  105. package/src/commands/live-context.ts +3 -0
  106. package/src/content/collection-match.ts +267 -0
  107. package/src/content/entry-commands.ts +38 -29
  108. package/src/content/entry-fields.ts +6 -0
  109. package/src/editor/file-drop-action.ts +2 -1
  110. package/src/editor/shortcuts.ts +5 -0
  111. package/src/files/file-ops.ts +10 -42
  112. package/src/files/files.ts +487 -61
  113. package/src/files/fs-events.ts +9 -0
  114. package/src/files/gitignore.ts +432 -0
  115. package/src/files/media-paths.ts +37 -14
  116. package/src/files/media-upload.ts +47 -6
  117. package/src/files/serialize-document.ts +60 -0
  118. package/src/format/convert-file.ts +511 -0
  119. package/src/format/format-choices.ts +199 -0
  120. package/src/grid/grid-idle.ts +45 -0
  121. package/src/grid/grid-view.ts +35 -0
  122. package/src/grid/schema-columns.ts +2 -1
  123. package/src/grid/sources/content-source.ts +24 -3
  124. package/src/media/media-open.ts +58 -0
  125. package/src/media/media-pane.ts +310 -0
  126. package/src/new-project/import-tab.ts +333 -160
  127. package/src/new-project/new-project-modal.ts +27 -34
  128. package/src/packages/ensure-deps.ts +14 -7
  129. package/src/packages/jxsuite-update.ts +11 -11
  130. package/src/page-params.ts +10 -46
  131. package/src/panels/activity-bar.ts +45 -16
  132. package/src/panels/ai-chat/attached-context.ts +6 -1
  133. package/src/panels/ai-chat/chat-view.ts +255 -13
  134. package/src/panels/ai-chat/composer.ts +21 -80
  135. package/src/panels/ai-panel.ts +116 -2
  136. package/src/panels/frontmatter-fields.ts +6 -2
  137. package/src/panels/navigator-panels.ts +7 -6
  138. package/src/panels/properties-panel.ts +8 -2
  139. package/src/panels/seo-modal.ts +5 -6
  140. package/src/panels/settings-menu.ts +799 -0
  141. package/src/panels/signals-panel.ts +2 -1
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +34 -3
  144. package/src/platforms/devserver.ts +72 -34
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/services/ai-ask.ts +247 -0
  147. package/src/services/ai-import-tools.ts +494 -0
  148. package/src/services/ai-models.ts +101 -11
  149. package/src/services/ai-project-tools.ts +20 -26
  150. package/src/services/ai-settings.ts +69 -71
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/cf-settings.ts +19 -37
  154. package/src/services/connection.ts +74 -0
  155. package/src/services/context-manager.ts +89 -0
  156. package/src/services/document-assistant.ts +49 -7
  157. package/src/services/idle.ts +6 -1
  158. package/src/services/import-client.ts +33 -6
  159. package/src/services/import-run.ts +158 -0
  160. package/src/services/import-seed.ts +120 -0
  161. package/src/services/jx-validate.ts +58 -6
  162. package/src/services/project-adoption.ts +71 -0
  163. package/src/services/references.ts +0 -0
  164. package/src/services/settings/definitions.ts +181 -0
  165. package/src/services/settings/kernel.ts +335 -0
  166. package/src/services/settings/write-queue.ts +116 -0
  167. package/src/services/tool-executor.ts +34 -2
  168. package/src/settings/contexts-section.ts +45 -3
  169. package/src/settings/dependencies-editor.ts +43 -23
  170. package/src/settings/general-settings.ts +30 -12
  171. package/src/settings/preferences-accounts.ts +6 -19
  172. package/src/settings/preferences-dialog.ts +18 -41
  173. package/src/settings/preferences-keymap.ts +8 -18
  174. package/src/settings/preferences-sections.ts +57 -0
  175. package/src/settings/schema-field-ui.ts +2 -2
  176. package/src/settings/settings-document.ts +65 -9
  177. package/src/shell.ts +6 -14
  178. package/src/studio.ts +65 -7
  179. package/src/tabs/transact.ts +42 -19
  180. package/src/types.ts +164 -8
  181. package/src/ui/ai-credentials-form.ts +52 -27
  182. package/src/ui/ai-model-picker.ts +162 -0
  183. package/src/ui/choice-field.ts +72 -0
  184. package/src/ui/layers.ts +83 -5
  185. package/src/ui/media-picker.ts +6 -11
  186. package/src/utils/studio-utils.ts +43 -31
  187. package/styles/forced-colors.css +7 -0
  188. package/styles/overlays.css +256 -0
  189. package/styles/panels.css +13 -1
  190. package/styles/shell.css +111 -0
  191. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  192. package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
  193. package/dist/chunks/content-source-k9724vcv.js +0 -39
  194. package/dist/chunks/files-yew27sn9.js +0 -69
  195. package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
  196. package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
  197. package/dist/chunks/quick-search-9avyc4pc.js +0 -54
  198. package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
  199. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  200. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  201. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  202. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  203. package/dist/chunks/studio-a7pzveq1.js +0 -110
  204. package/dist/chunks/studio-c31yb4t6.js +0 -55
  205. package/dist/chunks/studio-ra7ar50f.js +0 -6682
  206. package/src/canvas/content-assets.ts +0 -263
  207. package/src/canvas/site-style-css.ts +0 -121
  208. package/src/services/settings-store.ts +0 -99
@@ -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
+ }
@@ -28,9 +28,24 @@ import type { ToolRegistry } from "@jxsuite/ai/tools";
28
28
  import type { Tab } from "../tabs/tab";
29
29
  import { batchTab, beginBatch, endBatch } from "../tabs/transact";
30
30
  import { beginTurn, endTurn } from "./ai-writes";
31
+ import { beginToolCall, beginTurnSignal, endTurnSignal } from "./ai-turn-signal";
31
32
 
32
33
  const MAX_ROUNDS = 5;
33
34
 
35
+ /**
36
+ * Hard ceiling on rounds of every kind, so the loop terminates whatever the model does.
37
+ *
38
+ * {@link MAX_ROUNDS} bounds AUTONOMOUS work, and a round that ends by blocking on a person is the
39
+ * opposite of runaway — it cannot advance without them, which is the property the cap was ever a
40
+ * proxy for. So an interactive round does not spend that budget. This one is the backstop that
41
+ * keeps `runAgentLoop` provably terminating anyway; a human answering ten questions will have
42
+ * stopped it long before.
43
+ */
44
+ const MAX_TOTAL_ROUNDS = 25;
45
+
46
+ /** Tools that suspend the turn on a human rather than doing work. */
47
+ const INTERACTIVE_TOOLS = new Set(["ask_user"]);
48
+
34
49
  interface RunAgentLoopOptions {
35
50
  chatState: ReturnType<typeof createChatState>;
36
51
  streamingClient: StreamingClient;
@@ -59,6 +74,11 @@ export async function runAgentLoop({
59
74
  const turnId = () => chatState.messages.at(-1)?.id ?? "";
60
75
  beginTurn(`turn:${chatState.messages.length}`);
61
76
 
77
+ /* The signal, published for the tools rather than passed to them: `ToolRegistry.execute` takes
78
+ none. Without it a tool that waits for a human never learns the turn was stopped, and the
79
+ `await` below is the whole loop. See services/ai-turn-signal.ts. */
80
+ beginTurnSignal(signal);
81
+
62
82
  // Batch all tool-call mutations into a single undo step, anchored on the tab being edited.
63
83
  if (getTab) {
64
84
  beginBatch(getTab());
@@ -83,7 +103,9 @@ export async function runAgentLoop({
83
103
  };
84
104
 
85
105
  try {
86
- for (let round = 1; round <= MAX_ROUNDS; round++) {
106
+ /* Rounds that DID something, which is what MAX_ROUNDS bounds — see MAX_TOTAL_ROUNDS. */
107
+ let workRounds = 0;
108
+ for (let round = 1; round <= MAX_TOTAL_ROUNDS && workRounds < MAX_ROUNDS; round++) {
87
109
  const messages = chatState.toMessagesArray();
88
110
  const tools = toolRegistry.listForLLM();
89
111
 
@@ -144,8 +166,11 @@ export async function runAgentLoop({
144
166
  return;
145
167
  }
146
168
 
169
+ let didWork = false;
147
170
  for (const [id, call] of toolCalls) {
148
171
  let result;
172
+ // Published, not passed: `ToolRegistry.execute` takes the args and nothing else.
173
+ beginToolCall(id);
149
174
  try {
150
175
  const args = call.arguments ? (JSON.parse(call.arguments) as object) : {};
151
176
  result = await toolRegistry.execute(call.name, args);
@@ -155,6 +180,9 @@ export async function runAgentLoop({
155
180
  error: `Failed to parse arguments: ${(error as Error).message}`,
156
181
  };
157
182
  }
183
+ if (!INTERACTIVE_TOOLS.has(call.name)) {
184
+ didWork = true;
185
+ }
158
186
  reanchorBatch();
159
187
  if (!result.success && result.error) {
160
188
  allErrors.push(result.error);
@@ -166,7 +194,10 @@ export async function runAgentLoop({
166
194
  chatState.pushToolResultMessage(id, JSON.stringify(result));
167
195
  }
168
196
 
169
- if (round < MAX_ROUNDS) {
197
+ if (didWork) {
198
+ workRounds += 1;
199
+ }
200
+ if (workRounds < MAX_ROUNDS && round < MAX_TOTAL_ROUNDS) {
170
201
  chatState.beginAssistantTurn();
171
202
  }
172
203
  }
@@ -196,6 +227,7 @@ export async function runAgentLoop({
196
227
  }
197
228
  chatState.setError(tail);
198
229
  } finally {
230
+ endTurnSignal();
199
231
  endBatch();
200
232
  endTurn(turnId());
201
233
  }
@@ -30,7 +30,7 @@
30
30
  * alone), parks any rejection under the control that caused it, and re-renders. §7.1's third tier:
31
31
  * a bad value belongs at its control, not in a toast that expires.
32
32
  *
33
- * @docs studio/projects/pages-layouts-components
33
+ * @docs studio/projects/settings
34
34
  */
35
35
 
36
36
  import { html, render as litRender, nothing } from "lit-html";
@@ -140,6 +140,15 @@ type ErrorTarget = string;
140
140
  */
141
141
  const errors = new WeakMap<HTMLElement, { target: ErrorTarget; message: string }>();
142
142
 
143
+ /**
144
+ * Schema errors the file already had when this section last wrote to it.
145
+ *
146
+ * Reported at section level rather than under a control, because they belong to no control — and
147
+ * kept per container for the same reason {@link errors} is. Populated by the first write, since
148
+ * validating on every render would compile a schema for a section nobody is editing.
149
+ */
150
+ const inheritedErrors = new WeakMap<HTMLElement, string[]>();
151
+
143
152
  /** Read the current failure for a container — exported for tests and for re-render helpers. */
144
153
  export function contextsError(container: HTMLElement): { target: string; message: string } | null {
145
154
  return errors.get(container) ?? null;
@@ -171,12 +180,26 @@ export function renderContextsSection(container: HTMLElement): void {
171
180
  * shape, and the disk can refuse the write. The predecessor surfaces (`Properties › Media` and
172
181
  * the CSS-variables "Enable dark scheme") did `void updateSiteConfig(...)` and dropped the
173
182
  * rejection, so a failed write read as the field snapping back for no reason.
183
+ *
184
+ * **Only the errors THIS edit introduces block it.** The candidate is the whole `project.json`,
185
+ * so a violation anywhere else in the file used to refuse every context edit and park the reason
186
+ * under whichever control had been touched — typing `1280px` into Base width reported three
187
+ * unevaluated-property errors about `title`, `description` and `$style`, none of which is a
188
+ * width. The baseline is validated first and subtracted, the same way `services/ai-tools.ts`
189
+ * subtracts it before blaming the model for a document it inherited. A pre-existing problem is
190
+ * still worth saying, so it is reported once at section level, where a whole-file problem belongs
191
+ * — and it is reported rather than repaired, because `project.json` is the author's file and a
192
+ * settings screen that silently drops keys it did not recognise is worse than one that names
193
+ * them.
174
194
  */
175
195
  const persistMedia = async (next: Record<string, string>, target: ErrorTarget) => {
176
196
  const candidate = { ...config, $media: next } as unknown;
177
197
  let schemaErrors: string[] = [];
198
+ let inherited: string[] = [];
178
199
  try {
179
- schemaErrors = await validateProjectConfig(candidate);
200
+ inherited = await validateProjectConfig(config);
201
+ const found = await validateProjectConfig(candidate);
202
+ schemaErrors = found.filter((error) => !inherited.includes(error));
180
203
  } catch (error) {
181
204
  /* A validator that cannot compile must not block the edit — but it must not be silent
182
205
  either, so the message rides the same inline slot the schema errors would have used. */
@@ -187,6 +210,7 @@ export function renderContextsSection(container: HTMLElement): void {
187
210
  reject(target, schemaErrors.join("; "));
188
211
  return;
189
212
  }
213
+ inheritedErrors.set(container, inherited);
190
214
  try {
191
215
  await updateSiteConfig({ $media: next });
192
216
  errors.delete(container);
@@ -199,6 +223,24 @@ export function renderContextsSection(container: HTMLElement): void {
199
223
  renderContextsSection(container);
200
224
  };
201
225
 
226
+ /**
227
+ * What is already wrong with `project.json`, said once and not blamed on a control.
228
+ *
229
+ * It does not block anything — the edit that surfaced it went through. It is here because a file
230
+ * that fails its own schema will keep failing it, and until this notice existed the only place
231
+ * that fact appeared was as an unexplained refusal of an unrelated field.
232
+ */
233
+ const inheritedNotice = () => {
234
+ const inherited = inheritedErrors.get(container) ?? [];
235
+ return inherited.length === 0
236
+ ? nothing
237
+ : html`<p class="settings-section-notice" role="status">
238
+ project.json has ${inherited.length} pre-existing schema
239
+ ${inherited.length === 1 ? "problem" : "problems"} that this section did not cause:
240
+ ${inherited.join("; ")}
241
+ </p>`;
242
+ };
243
+
202
244
  /** The error line for one control, when that is where the current failure belongs. */
203
245
  const errorFor = (target: ErrorTarget) =>
204
246
  shown?.target === target
@@ -370,7 +412,7 @@ export function renderContextsSection(container: HTMLElement): void {
370
412
  The conditions this project's pages are rendered under. Define them once here; choose
371
413
  between them on the pane's context control while you edit.
372
414
  </p>
373
- ${errorFor("section")}
415
+ ${errorFor("section")} ${inheritedNotice()}
374
416
 
375
417
  <div class="settings-field">
376
418
  <label class="settings-field-label">Base width</label>