@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
@@ -20,8 +20,9 @@ import type { ToolRegistry, ToolResult } from "@jxsuite/ai/tools";
20
20
  import type { JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
21
21
  import { getPlatform } from "../platform";
22
22
  import { workspace } from "../workspace/workspace";
23
+ import { projectState } from "../store";
23
24
  import type { Tab } from "../tabs/tab";
24
- import { beginBatch, endBatch, isBatching } from "../tabs/transact";
25
+ import { adoptCreatedProject } from "./project-adoption";
25
26
  import { translateValidationError } from "./ai-tools";
26
27
  import { validateDoc, validateProjectConfig } from "./jx-validate";
27
28
  import { recordWrite } from "./ai-writes";
@@ -355,8 +356,16 @@ export function registerProjectTools(
355
356
  /* The per-project entry document closes the composition over every enabled extension
356
357
  (extensions.md §5.2), so this is what catches a typo'd section key or a misshapen
357
358
  extension section. Without it the model shipped config its own tool call called clean
358
- and Monaco flagged the moment a human opened the file. */
359
- const configErrors = await validateProject(parsedConfig);
359
+ and Monaco flagged the moment a human opened the file.
360
+
361
+ Errors the file ALREADY had are subtracted, the same way `services/ai-tools.ts`
362
+ subtracts them from a document edit: a project whose config was written by something
363
+ else — an importer, a hand edit, an extension that was removed — would otherwise refuse
364
+ every write the model attempted, blaming it for a violation it did not introduce and
365
+ cannot reach from the edit it was asked to make. */
366
+ const before = new Set(await validateProject(projectState?.projectConfig ?? {}));
367
+ const found = await validateProject(parsedConfig);
368
+ const configErrors = found.filter((error) => !before.has(error));
360
369
  if (configErrors.length > 0) {
361
370
  const formatted = configErrors
362
371
  .map((e) => `- ${translateValidationError(e)}`)
@@ -585,30 +594,15 @@ export function registerProjectTools(
585
594
  }
586
595
 
587
596
  /*
588
- * Adoption runs the full project-open flow (closes all tabs, opens the home page). The
589
- * agent loop may hold an undo batch on the pre-adoption tab — flush it first and re-open
590
- * one on whatever tab adoption leaves active, mirroring open_document's batch dance.
591
- */
592
- const wasBatching = isBatching();
593
- if (wasBatching) {
594
- endBatch();
595
- }
596
- let adoptionError: string | null = null;
597
- try {
598
- await adoptProject(result.root);
599
- } catch (error) {
600
- adoptionError = error instanceof Error ? error.message : String(error);
601
- }
602
- if (wasBatching) {
603
- beginBatch(getTab());
604
- }
605
-
606
- /*
607
- * The adopter (openRecentProject) swallows failures into a status message, so a resolved
608
- * promise is not proof of adoption — verify against the workspace before re-keying the
609
- * chat session.
597
+ * Git init, then the full project-open flow, then a check that it landed — all three in
598
+ * `services/project-adoption.ts`, because `import_site` is the same act with a different
599
+ * backend. Git init in particular was missing here: `specs/desktop.md` §4.5 promises every
600
+ * create path initialises a repository, and only the New Project modal was keeping it.
610
601
  */
611
- const adopted = workspace.projectRoot === result.root;
602
+ const { adopted, error: adoptionError } = await adoptCreatedProject(result.root, {
603
+ adopt: adoptProject,
604
+ getTab,
605
+ });
612
606
  if (adopted) {
613
607
  onProjectAdopted?.(result.root);
614
608
  return {
@@ -1,52 +1,51 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Ai-settings.js — local persistence for the AI assistant's provider settings.
3
+ * Ai-settings.js — the AI assistant's provider settings, over the settings kernel.
4
4
  *
5
5
  * The Stack B proxy reads the OpenAI key from the `X-Api-Key` header (falling back to the server's
6
- * `OPENAI_API_KEY` env var). Studio stores a user-supplied key in localStorage so the browser/dev
7
- * build works without an env var. The key never leaves the machine except to the same-origin proxy.
6
+ * `OPENAI_API_KEY` env var). Studio stores a user-supplied key so the browser/dev build works
7
+ * without an env var. The key never leaves the machine except to the same-origin proxy.
8
+ *
9
+ * This module is now a typed façade over `settings/kernel.ts` — the storage keys, defaults and
10
+ * normalisation live in `settings/definitions.ts`, and the kernel owns the cache, the write queue
11
+ * and change notification. What it keeps is the vocabulary: callers say `getOpenAiKey()`, not
12
+ * `readSetting(SETTINGS.aiOpenAiKey)`.
13
+ *
14
+ * **Blank no longer deletes.** `setOpenAiKey("")` stores an empty string; {@link clearAiProvider}
15
+ * is the revoke path. The old conflation read as a convenience right up until a form that blanked
16
+ * its own drafts on Save called these setters with them, and a second Save revoked the credentials
17
+ * the first had stored.
8
18
  *
9
19
  * @license MIT
10
20
  */
11
21
 
12
- import { persistSettings } from "./settings-store";
13
-
14
- const KEY_STORAGE = "jx.ai.openaiKey";
15
- const BASE_URL_STORAGE = "jx.ai.baseUrl";
16
- const MODEL_STORAGE = "jx.ai.model";
17
- const DEFAULT_MODEL = "gpt-4o";
22
+ import { SETTINGS } from "./settings/definitions";
23
+ import {
24
+ clearSettings,
25
+ hasSetting,
26
+ readStoredSetting,
27
+ setSetting,
28
+ setSettings,
29
+ } from "./settings/kernel";
18
30
 
19
- /** @returns {string} The stored OpenAI key, or "" if none/unavailable. */
31
+ /** @returns {string} The stored OpenAI key, or "" if none. */
20
32
  export function getOpenAiKey() {
21
- try {
22
- return globalThis.localStorage?.getItem(KEY_STORAGE) ?? "";
23
- } catch {
24
- return "";
25
- }
33
+ return readStoredSetting(SETTINGS.aiOpenAiKey);
26
34
  }
27
35
 
28
36
  /**
29
- * Persist (or clear) the OpenAI key.
37
+ * Persist the OpenAI key.
30
38
  *
31
- * @param {string} key - The key to store; an empty/blank value clears it.
39
+ * @param {string} key - The key to store. A blank value stores blank; it does NOT clear — see
40
+ * {@link clearAiProvider}.
32
41
  */
33
42
  export function setOpenAiKey(key: string) {
34
- try {
35
- const trimmed = (key || "").trim();
36
- if (trimmed) {
37
- globalThis.localStorage?.setItem(KEY_STORAGE, trimmed);
38
- } else {
39
- globalThis.localStorage?.removeItem(KEY_STORAGE);
40
- }
41
- persistSettings();
42
- } catch {
43
- /* LocalStorage unavailable — settings are not persisted. */
44
- }
43
+ setSetting(SETTINGS.aiOpenAiKey, key || "");
45
44
  }
46
45
 
47
46
  /** @returns {boolean} Whether a non-empty key is stored. */
48
47
  export function hasOpenAiKey() {
49
- return getOpenAiKey().length > 0;
48
+ return hasSetting(SETTINGS.aiOpenAiKey);
50
49
  }
51
50
 
52
51
  /**
@@ -54,59 +53,58 @@ export function hasOpenAiKey() {
54
53
  * or "" to use the proxy's default (`https://api.openai.com/v1` / server `OPENAI_BASE_URL`).
55
54
  */
56
55
  export function getBaseUrl() {
57
- try {
58
- return globalThis.localStorage?.getItem(BASE_URL_STORAGE) ?? "";
59
- } catch {
60
- return "";
61
- }
56
+ return readStoredSetting(SETTINGS.aiBaseUrl);
62
57
  }
63
58
 
59
+ // ─── Model selection ────────────────────────────────────────────────────────
60
+
64
61
  /**
65
- * Persist (or clear) the base URL override.
62
+ * The model the user actually chose, with no default standing in for silence.
66
63
  *
67
- * @param {string} url - The base URL; an empty/blank value clears the override.
64
+ * The getter this replaced masked _unset_ as `"gpt-4o"`, which is right for a sender and wrong for
65
+ * anything that writes back: a form prefilled from it and then saved persists a choice nobody made,
66
+ * which is how `jx.ai.model: "gpt-4o"` came to be the only surviving key in a settings file whose
67
+ * owner had configured a different provider entirely. Senders want
68
+ * {@link ../services/ai-models!preferredModel} instead.
69
+ *
70
+ * @returns {string} The stored model ID, or "" when none has been chosen.
68
71
  */
69
- export function setBaseUrl(url: string) {
70
- try {
71
- const trimmed = (url || "").trim().replace(/\/+$/, "");
72
- if (trimmed) {
73
- globalThis.localStorage?.setItem(BASE_URL_STORAGE, trimmed);
74
- } else {
75
- globalThis.localStorage?.removeItem(BASE_URL_STORAGE);
76
- }
77
- persistSettings();
78
- } catch {
79
- /* LocalStorage unavailable — settings are not persisted. */
80
- }
72
+ export function storedModel() {
73
+ return readStoredSetting(SETTINGS.aiModel);
81
74
  }
82
75
 
83
- // ─── Model selection ────────────────────────────────────────────────────────
76
+ /**
77
+ * Persist the selected model ID.
78
+ *
79
+ * @param {string} modelId - The model ID to store. A blank value stores blank; it does NOT clear.
80
+ */
81
+ export function setModel(modelId: string) {
82
+ setSetting(SETTINGS.aiModel, modelId || "");
83
+ }
84
84
 
85
- /** @returns {string} The stored model ID, or the default ("gpt-4o"). */
86
- export function getModel() {
87
- try {
88
- return globalThis.localStorage?.getItem(MODEL_STORAGE) || DEFAULT_MODEL;
89
- } catch {
90
- return DEFAULT_MODEL;
91
- }
85
+ /**
86
+ * Store the provider's key, endpoint and model as ONE change.
87
+ *
88
+ * What a credentials form's Save should call. Three separate setters coalesce into one write
89
+ * anyway, but they announce three times, and each intermediate announcement describes a provider
90
+ * the user never asked for — a new key against the old endpoint, then against no model.
91
+ *
92
+ * @param {{ apiKey: string; baseUrl: string; model: string }} provider
93
+ */
94
+ export function saveAiProvider(provider: { apiKey: string; baseUrl: string; model: string }) {
95
+ setSettings([
96
+ [SETTINGS.aiOpenAiKey, provider.apiKey],
97
+ [SETTINGS.aiBaseUrl, provider.baseUrl],
98
+ [SETTINGS.aiModel, provider.model],
99
+ ]);
92
100
  }
93
101
 
94
102
  /**
95
- * Persist the selected model ID.
103
+ * Forget the provider entirely — key, endpoint and model.
96
104
  *
97
- * @param {string} modelId - The model ID to store; an empty/blank value clears it, reverting to
98
- * default.
105
+ * The one deletion path, and the one the Accounts list's Disconnect uses. Deliberately all three
106
+ * together: a key with an orphaned endpoint is a state no surface knows how to describe.
99
107
  */
100
- export function setModel(modelId: string) {
101
- try {
102
- const trimmed = (modelId || "").trim();
103
- if (trimmed) {
104
- globalThis.localStorage?.setItem(MODEL_STORAGE, trimmed);
105
- } else {
106
- globalThis.localStorage?.removeItem(MODEL_STORAGE);
107
- }
108
- persistSettings();
109
- } catch {
110
- /* LocalStorage unavailable — settings are not persisted. */
111
- }
108
+ export function clearAiProvider() {
109
+ clearSettings([SETTINGS.aiOpenAiKey, SETTINGS.aiBaseUrl, SETTINGS.aiModel]);
112
110
  }
@@ -29,6 +29,12 @@ interface BuildSystemPromptOptions {
29
29
  * prompt-shape tests — advertises the same list it always did.
30
30
  */
31
31
  treeEditable?: boolean | undefined;
32
+ /**
33
+ * Whether the platform can import a site. Defaults TRUE so this stays a pure function of what it
34
+ * is given — a caller that knows nothing about the platform gets the full list, and only
35
+ * `document-assistant.ts`, which does know, narrows it.
36
+ */
37
+ canImport?: boolean | undefined;
32
38
  /** Project-relative file paths for the inventory section (project modes; capped). */
33
39
  fileInventory?: string[] | undefined;
34
40
  }
@@ -52,13 +58,21 @@ const FILE_INVENTORY_CAP = 100;
52
58
  * a weaker test than `structurallyEditable`, so a repeater template or `$switch` case was removable
53
59
  * by the agent and not by the person.
54
60
  */
55
- export type AiToolTier = "no-project" | "project" | "document" | "document-tree";
61
+ export type AiToolTier = "always" | "no-project" | "project" | "document" | "document-tree";
56
62
 
57
63
  export interface AiToolInfo {
58
64
  name: string;
59
65
  tier: AiToolTier;
60
66
  /** One-line signature + purpose shown in the system prompt's tool list. */
61
67
  blurb: string;
68
+ /**
69
+ * A PAL member the tool needs, beyond whatever its tier requires.
70
+ *
71
+ * The tier answers "what is open"; this answers "can this backend do it at all". Cloud ships no
72
+ * `importSite`, so `import_site` must not be advertised there — and a tier cannot express that,
73
+ * because "no project is open" is exactly as true on cloud as anywhere else.
74
+ */
75
+ capability?: "importSite";
62
76
  }
63
77
 
64
78
  /**
@@ -67,7 +81,27 @@ export interface AiToolInfo {
67
81
  * list cannot drift (a test asserts the names match the registered tools).
68
82
  */
69
83
  export const AI_TOOL_TIERS: AiToolInfo[] = [
84
+ // Always — a question is not gated on what happens to be open
85
+ {
86
+ name: "ask_user",
87
+ tier: "always",
88
+ blurb:
89
+ "ask_user(question, options?, context?) — pause and put a question to the user; the turn " +
90
+ "waits for their reply and resumes with it. Only for a judgement that is genuinely theirs.",
91
+ },
70
92
  // Bootstrap (no project open)
93
+ {
94
+ name: "import_site",
95
+ tier: "no-project",
96
+ capability: "importSite",
97
+ blurb:
98
+ "import_site(url, directory?, depth?, maxPages?, aiComponents?, maxBreakpoints?, " +
99
+ "breakpointWidths?, breakpointRounding?) — clone a live website into a new Jx project and " +
100
+ "open it: pages, styles, assets, a shared layout and recurring components. The project opens " +
101
+ "as soon as the destination exists, so the user watches it fill up while the crawl runs. " +
102
+ "When the user came through the New Project Import form the destination and options are " +
103
+ "already settled — pass the url alone.",
104
+ },
71
105
  {
72
106
  name: "create_project",
73
107
  tier: "no-project",
@@ -174,6 +208,8 @@ export const AI_TOOL_TIERS: AiToolInfo[] = [
174
208
  export interface AiToolState {
175
209
  hasProject: boolean;
176
210
  hasDocument: boolean;
211
+ /** Whether the platform implements `importSite`. Cloud does not. */
212
+ canImport?: boolean;
177
213
  /**
178
214
  * The open document is an element tree the canvas is editing — `editor.kind === "canvas"`.
179
215
  *
@@ -189,6 +225,9 @@ export interface AiToolState {
189
225
  * tools need an active document (even in single-file mode without a project).
190
226
  */
191
227
  export function tierActive(tier: AiToolTier, state: AiToolState): boolean {
228
+ if (tier === "always") {
229
+ return true;
230
+ }
192
231
  if (tier === "no-project") {
193
232
  return !state.hasProject;
194
233
  }
@@ -201,12 +240,56 @@ export function tierActive(tier: AiToolTier, state: AiToolState): boolean {
201
240
  return state.hasDocument;
202
241
  }
203
242
 
243
+ /**
244
+ * Whether a tool is active: its tier, AND any capability it declares.
245
+ *
246
+ * The one predicate the prompt's tool list and the gating registry both run, which is the whole
247
+ * point of {@link AI_TOOL_TIERS} — a tool the model is told about and then refused is a surprise to
248
+ * it, and one it is refused without being told is invisible.
249
+ *
250
+ * @param {AiToolInfo} tool
251
+ * @param {AiToolState} state
252
+ * @returns {boolean}
253
+ */
254
+ export function toolActive(tool: AiToolInfo, state: AiToolState): boolean {
255
+ if (tool.capability === "importSite" && state.canImport === false) {
256
+ return false;
257
+ }
258
+ return tierActive(tool.tier, state);
259
+ }
260
+
204
261
  // ─── Jx Schema Reference (condensed) ────────────────────────────────────────
205
262
 
206
263
  /**
207
264
  * Condensed reference of Jx document structure rules. Included inline in the system prompt so the
208
265
  * LLM understands the schema without consuming excessive tokens.
209
266
  */
267
+ /**
268
+ * When to stop and ask, and — more importantly — when not to.
269
+ *
270
+ * Rendered into the closing section of every prompt, because `ask_user` is an `always` tier tool.
271
+ * The prohibitions carry most of the weight: a tool that suspends a turn on a human is only worth
272
+ * having if the questions are ones a human actually wants. A model that asks what it could have
273
+ * read, or asks about a knob its answer cannot reach, trains the author to stop reading the
274
+ * questions.
275
+ */
276
+ const ASKING_THE_USER = `## Asking the user
277
+
278
+ You can stop and ask with ask_user; the turn waits and resumes with the reply. Waiting on a person
279
+ is not free, so ask only when all three hold:
280
+
281
+ 1. It is genuinely THEIR judgement — taste, priority, or intent. "Which of these pages matter?",
282
+ "keep their brand colours or restyle?", "is this page close enough?"
283
+ 2. You could not answer it yourself with another tool. Read the file, list the directory, look at
284
+ the document. Asking what you could have discovered is the commonest way to waste the question.
285
+ 3. You can ACT on any answer they give. Never ask about an option you have no way to change.
286
+
287
+ Ask one question at a time. Offer options when the sensible answers are a short list; the user can
288
+ always reply in their own words instead. If they decline, decide yourself and say what you chose.
289
+
290
+ Prefer doing to asking. On a vague prompt, make a defensible choice, do the work, and say what you
291
+ assumed — one good question beats five, and beats none only when it changes what you build.`;
292
+
210
293
  const JX_SCHEMA_REFERENCE = `## Jx Document Format
211
294
 
212
295
  A Jx document is a JSON object. Key top-level fields:
@@ -560,6 +643,7 @@ export function buildSystemPrompt({
560
643
  projectRoot,
561
644
  hasProject = Boolean(projectRoot),
562
645
  treeEditable = true,
646
+ canImport = true,
563
647
  fileInventory,
564
648
  }: BuildSystemPromptOptions = {}) {
565
649
  const hasDocument = Boolean(document);
@@ -568,7 +652,7 @@ export function buildSystemPrompt({
568
652
  // The list the model is TOLD about and the list the gate will honour are the same filter, so a
569
653
  // Refusal is never a surprise to it.
570
654
  const toolList = AI_TOOL_TIERS.filter((t) =>
571
- tierActive(t.tier, { hasDocument, hasProject, treeEditable }),
655
+ toolActive(t, { canImport, hasDocument, hasProject, treeEditable }),
572
656
  )
573
657
  .map((t) => `- ${t.blurb}`)
574
658
  .join("\n");
@@ -608,6 +692,8 @@ Your document edits apply to the live canvas immediately and are individually un
608
692
 
609
693
  const closing = `You have a limited number of tool-call rounds per message. On vague or open-ended prompts ("make it look better", "improve this"), prefer a small number of targeted, high-impact changes over attempting to rebuild the entire page. Explain what you changed and offer to do more.
610
694
 
695
+ ${ASKING_THE_USER}
696
+
611
697
  Be concise. Don't explain what Jx is unless asked. Just build.`;
612
698
 
613
699
  const sections = [`${role}\n\n${workflow}\n\n${closing}`];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Ai-turn-signal.ts — what the agent loop knows that a tool needs and cannot be passed.
3
+ *
4
+ * `ToolRegistry.execute(name, args)` takes no signal, and its signature is fixed in the published
5
+ * `@jxsuite/ai`. So `runAgentLoop` could hand its signal to `streamChat` and to nothing else: a tool
6
+ * that ran for minutes (an import) could not be cancelled, and a tool that waits for a human
7
+ * (`ask_user`) would have hung on Stop forever, because the promise the loop is awaiting has no
8
+ * other way to learn the turn is over.
9
+ *
10
+ * An ambient slot rather than a parameter, for the same reason `services/ai-writes.ts` is one: the
11
+ * loop already brackets each turn with `beginTurn`/`endTurn` in a try/finally, and this rides that
12
+ * bracket. Only one turn runs at a time — `sendMessage` and `beginAssistantTurn` are both no-ops
13
+ * while streaming — so a module-level slot is the whole of the state.
14
+ *
15
+ * @license MIT
16
+ */
17
+
18
+ let _signal: AbortSignal | undefined;
19
+
20
+ /**
21
+ * Publish the turn's signal for the tools it will run. Called by `runAgentLoop`.
22
+ *
23
+ * @param {AbortSignal} [signal] - The turn's signal; `undefined` leaves tools uncancellable, which
24
+ * is what a caller that passed no signal asked for.
25
+ */
26
+ export function beginTurnSignal(signal?: AbortSignal): void {
27
+ _signal = signal;
28
+ }
29
+
30
+ /**
31
+ * The running turn's signal, or `undefined` outside a turn.
32
+ *
33
+ * A tool must treat `undefined` as "not cancellable" rather than as an error: the evals runner and
34
+ * several tests drive the loop with no signal at all.
35
+ *
36
+ * @returns {AbortSignal | undefined}
37
+ */
38
+ export function turnSignal(): AbortSignal | undefined {
39
+ return _signal;
40
+ }
41
+
42
+ /** Clear the slots. Called from `runAgentLoop`'s `finally`, so it runs on every exit path. */
43
+ export function endTurnSignal(): void {
44
+ _signal = undefined;
45
+ _toolCallId = "";
46
+ }
47
+
48
+ let _toolCallId = "";
49
+
50
+ /**
51
+ * Publish the id of the call about to execute. Called by `runAgentLoop` per tool call.
52
+ *
53
+ * A tool that outlives its own execution — `ask_user`, whose question stays on screen until a human
54
+ * answers — needs the id to join what it registered to the chip the transcript is drawing. Without
55
+ * it a restored, permanently unanswered question is indistinguishable from the live one.
56
+ *
57
+ * @param {string} id
58
+ */
59
+ export function beginToolCall(id: string): void {
60
+ _toolCallId = id;
61
+ }
62
+
63
+ /**
64
+ * The id of the tool call currently executing, or `""` outside one.
65
+ *
66
+ * @returns {string}
67
+ */
68
+ export function currentToolCallId(): string {
69
+ return _toolCallId;
70
+ }
@@ -1,58 +1,40 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Cf-settings — local persistence for the Cloudflare publish connection on
4
- * platforms without a hosted OAuth broker (dev server, desktop). The API
5
- * token is stored like the AI key: localStorage first, mirrored through the
6
- * platform settings backend where one exists. It only ever leaves the machine
7
- * to the same-origin `/__studio/cf/proxy`, which forwards it as a bearer to
3
+ * Cf-settings — the Cloudflare publish connection on platforms without a hosted OAuth broker (dev
4
+ * server, desktop). The API token is stored like the AI key, over `settings/kernel.ts`. It only ever
5
+ * leaves the machine to the same-origin `/__studio/cf/proxy`, which forwards it as a bearer to
8
6
  * api.cloudflare.com (not CORS-enabled, hence the proxy).
9
7
  *
8
+ * As with the AI provider, a blank value is a VALUE: {@link clearCfConnection} is the deletion path,
9
+ * so nothing can revoke a connection by rendering an empty field into a setter.
10
+ *
10
11
  * @license MIT
11
12
  */
12
13
 
13
- import { persistSettings } from "./settings-store";
14
-
15
- const TOKEN_STORAGE = "jx.cf.token";
16
- const ACCOUNT_STORAGE = "jx.cf.accountId";
17
-
18
- function read(key: string): string {
19
- try {
20
- return globalThis.localStorage?.getItem(key) ?? "";
21
- } catch {
22
- return "";
23
- }
24
- }
25
-
26
- function write(key: string, value: string): void {
27
- try {
28
- const trimmed = (value || "").trim();
29
- if (trimmed) {
30
- globalThis.localStorage?.setItem(key, trimmed);
31
- } else {
32
- globalThis.localStorage?.removeItem(key);
33
- }
34
- } catch {
35
- // Storage unavailable (private mode) — the connection just won't persist.
36
- }
37
- persistSettings();
38
- }
14
+ import { SETTINGS } from "./settings/definitions";
15
+ import { clearSettings, readStoredSetting, setSetting } from "./settings/kernel";
39
16
 
40
17
  /** The stored Cloudflare API token, or "" when not connected. */
41
18
  export function getCfToken(): string {
42
- return read(TOKEN_STORAGE);
19
+ return readStoredSetting(SETTINGS.cfToken);
43
20
  }
44
21
 
45
- /** Persist (or clear, with a blank value) the Cloudflare API token. */
22
+ /** Persist the Cloudflare API token. Blank stores blank — see {@link clearCfConnection}. */
46
23
  export function setCfToken(token: string): void {
47
- write(TOKEN_STORAGE, token);
24
+ setSetting(SETTINGS.cfToken, token || "");
48
25
  }
49
26
 
50
27
  /** The selected Cloudflare account id, or "" when none chosen yet. */
51
28
  export function getCfAccountId(): string {
52
- return read(ACCOUNT_STORAGE);
29
+ return readStoredSetting(SETTINGS.cfAccountId);
53
30
  }
54
31
 
55
- /** Persist (or clear) the selected Cloudflare account id. */
32
+ /** Persist the selected Cloudflare account id. Blank stores blank. */
56
33
  export function setCfAccountId(accountId: string): void {
57
- write(ACCOUNT_STORAGE, accountId);
34
+ setSetting(SETTINGS.cfAccountId, accountId || "");
35
+ }
36
+
37
+ /** Forget the connection — token and account together. What Disconnect calls. */
38
+ export function clearCfConnection(): void {
39
+ clearSettings([SETTINGS.cfToken, SETTINGS.cfAccountId]);
58
40
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The backend connection, said out loud.
3
+ *
4
+ * Only one launcher can lose a backend mid-session: the chromium desktop shell holds a single
5
+ * long-lived WebSocket to its project server. When that socket died, every later call returned a
6
+ * promise that NEVER SETTLED — `WebSocket.send()` on a closed socket discards the frame instead of
7
+ * throwing, so nothing rejected, nothing resolved, and the visible symptom was **Open Project**
8
+ * doing nothing at all. No toast, no log, no failed state. Restarting Studio was the only
9
+ * recovery.
10
+ *
11
+ * The transport now rejects those calls, and this is the other half: a person needs to be told
12
+ * which of "it is broken" and "it is coming back" they are in. One keyed problem so a flapping
13
+ * connection cannot stack up a column of identical rows, dismissed on recovery so a fixed thing
14
+ * does not stay on the Problems list (§16.1's third tier, and §16.6's rule that a record is retired
15
+ * by whoever owns its life).
16
+ *
17
+ * A platform without `subscribeConnection` — the dev server, electrobun — installs nothing. There
18
+ * is no connection to lose there, and a notice about one would be a lie.
19
+ *
20
+ * @docs studio/interface/problems-and-progress
21
+ */
22
+
23
+ import { getPlatform } from "../platform";
24
+ import { dismiss, notify } from "./notify";
25
+ import type { Notification } from "./notify";
26
+
27
+ /** One key for every connection notice, so a flapping socket replaces rather than accumulates. */
28
+ const CONNECTION_KEY = "platform:connection";
29
+
30
+ /** The outstanding "we are disconnected" record, so recovery can retire it. */
31
+ let outstanding: Notification | null = null;
32
+
33
+ /**
34
+ * Start reporting the backend connection. Returns an unsubscribe, or null on a platform that has no
35
+ * connection to report.
36
+ *
37
+ * @returns {(() => void) | null}
38
+ */
39
+ export function watchConnection(): (() => void) | null {
40
+ const subscribe = getPlatform().subscribeConnection;
41
+ if (!subscribe) {
42
+ return null;
43
+ }
44
+ const unsubscribe = subscribe(({ online, reason }) => {
45
+ if (online) {
46
+ if (outstanding) {
47
+ dismiss(outstanding.id);
48
+ outstanding = null;
49
+ notify.success("Reconnected to the Jx backend.", { source: "Backend" });
50
+ }
51
+ return;
52
+ }
53
+ /* Already reported. A reconnect attempt failing is the same state, not a new one. */
54
+ if (outstanding) {
55
+ return;
56
+ }
57
+ outstanding = notify.error(reason ?? "Lost connection to the Jx backend.", {
58
+ detail:
59
+ "Editing continues, but anything that reads or writes files will fail until it comes " +
60
+ "back. Studio is retrying.",
61
+ key: CONNECTION_KEY,
62
+ source: "Backend",
63
+ });
64
+ });
65
+ /* Unsubscribing retires the notice with it: the watcher is the only thing that would ever have
66
+ dismissed it, so leaving it on the Problems list would leave a report nobody can clear. */
67
+ return () => {
68
+ unsubscribe();
69
+ if (outstanding) {
70
+ dismiss(outstanding.id);
71
+ outstanding = null;
72
+ }
73
+ };
74
+ }