@jxsuite/studio 3.0.0 → 4.1.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 (197) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  3. package/dist/chunks/Overlay.dev-etqjwyf9.js +23 -0
  4. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  5. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  6. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  7. package/dist/chunks/content-source-48xg86wj.js +49 -0
  8. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-43tfn7cs.js} +33 -25
  9. package/dist/chunks/entry-commands-e63f1xc0.js +46 -0
  10. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-txv08xxg.js} +34 -26
  11. package/dist/chunks/file-ops-wbh4hhsx.js +48 -0
  12. package/dist/chunks/files-3kfkwhk4.js +93 -0
  13. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  14. package/dist/chunks/{format-host-8fr3545e.js → format-host-w16q81tf.js} +3 -3
  15. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  16. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-532whr1y.js} +22 -16
  17. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  18. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8er6gbxy.js} +6 -4
  19. package/dist/chunks/layers-panel-07hm7g5j.js +67 -0
  20. package/dist/chunks/layers-q75tj2mf.js +45 -0
  21. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  22. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  23. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-vx9bhp1d.js} +4 -4
  24. package/dist/chunks/notify-yjm79n4s.js +30 -0
  25. package/dist/chunks/parse-24d0gay3.js +19 -0
  26. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  27. package/dist/chunks/publish-panel-v9st96ja.js +29 -0
  28. package/dist/chunks/quick-search-zccjp6gv.js +66 -0
  29. package/dist/chunks/references-3qx8fkyp.js +29 -0
  30. package/dist/chunks/right-panel-vm3b6kwy.js +46 -0
  31. package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +18 -0
  32. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  33. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  34. package/dist/chunks/sp-tooltip.dev-4sastdnw.js +15 -0
  35. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  36. package/dist/chunks/{studio-wdt9y92a.js → studio-0bq6eh7v.js} +2 -2
  37. package/dist/chunks/studio-1ngbejft.js +181 -0
  38. package/dist/chunks/studio-1wagb550.js +118 -0
  39. package/dist/chunks/{studio-j595e748.js → studio-2b946z26.js} +172 -95
  40. package/dist/chunks/{studio-pkp61mh8.js → studio-3bqpr0zh.js} +25 -17
  41. package/dist/chunks/{studio-k4jvjcfx.js → studio-4b5z1x43.js} +46 -12
  42. package/dist/chunks/{studio-ry1vz5sv.js → studio-4n2b5gzb.js} +15 -13
  43. package/dist/chunks/{studio-p5dja96s.js → studio-4r1pv6zf.js} +2 -2
  44. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  45. package/dist/chunks/{studio-tzx3xmvx.js → studio-5fx3g9jg.js} +33 -52
  46. package/dist/chunks/{studio-rh1cnamv.js → studio-5myknm0a.js} +565 -135
  47. package/dist/chunks/studio-6yw4y3my.js +91 -0
  48. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  49. package/dist/chunks/studio-92ab2jqe.js +282 -0
  50. package/dist/chunks/{studio-6z5eyjwn.js → studio-950k0dx6.js} +72 -135
  51. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  52. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  53. package/dist/chunks/{studio-hcxzyk3x.js → studio-ahn3pdgx.js} +6 -6
  54. package/dist/chunks/studio-b55y6ewx.js +498 -0
  55. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  56. package/dist/chunks/studio-cft9r4r7.js +119 -0
  57. package/dist/chunks/{studio-dc8s8heb.js → studio-cmb03bg5.js} +6904 -8321
  58. package/dist/chunks/studio-dm19q43r.js +195 -0
  59. package/dist/chunks/{studio-2x9zbxmx.js → studio-ef49pnzx.js} +11 -9
  60. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  61. package/dist/chunks/{studio-5efrtn1m.js → studio-ew0djmhg.js} +23 -19
  62. package/dist/chunks/{studio-tr20m8g8.js → studio-f3wqjtk5.js} +39 -3
  63. package/dist/chunks/{studio-xntfdy1x.js → studio-fbf92ckf.js} +36 -23
  64. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  65. package/dist/chunks/{studio-7p35254k.js → studio-gfgb8pe6.js} +36 -193
  66. package/dist/chunks/studio-jc994dnr.js +1066 -0
  67. package/dist/chunks/{studio-hj2se583.js → studio-jkftxyvz.js} +69 -3423
  68. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  69. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  70. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  71. package/dist/chunks/studio-msd847zn.js +1315 -0
  72. package/dist/chunks/{studio-g5gdqnyd.js → studio-mtfnaqs9.js} +19 -19
  73. package/dist/chunks/{studio-7ejemhej.js → studio-n6j9t10q.js} +6 -6
  74. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  75. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  76. package/dist/chunks/studio-q43a5y9h.js +394 -0
  77. package/dist/chunks/studio-q7v10cg2.js +106 -0
  78. package/dist/chunks/{studio-6kv3apa9.js → studio-q9jd1a60.js} +27 -11
  79. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  80. package/dist/chunks/{studio-a0yp7syn.js → studio-qjy2z9bv.js} +29 -132
  81. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  82. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  83. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  84. package/dist/chunks/studio-t7qb9db8.js +665 -0
  85. package/dist/chunks/studio-tmc5emrw.js +8 -0
  86. package/dist/chunks/{studio-r5xbtctf.js → studio-trxrabqf.js} +292 -2297
  87. package/dist/chunks/studio-wcy8trfx.js +263 -0
  88. package/dist/chunks/{studio-3dkcd4c5.js → studio-x4qkdat5.js} +62 -57
  89. package/dist/chunks/studio-x813zp48.js +195 -0
  90. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  91. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  92. package/dist/chunks/{studio-8ezdegcx.js → studio-zqazsct2.js} +40 -36
  93. package/dist/chunks/workspace-fjx52ryv.js +89 -0
  94. package/dist/iframe-entry.js +141 -56
  95. package/dist/manifest.json +7 -0
  96. package/dist/studio.js +2605 -1950
  97. package/favicon.ico +0 -0
  98. package/index.html +1 -0
  99. package/package.json +20 -18
  100. package/src/about/about-modal.ts +4 -3
  101. package/src/browse/library-pane.ts +57 -3
  102. package/src/canvas/canvas-render.ts +23 -1
  103. package/src/canvas/iframe-patch.ts +47 -21
  104. package/src/canvas/iframe-render.ts +22 -6
  105. package/src/commands/app-commands.ts +4 -0
  106. package/src/commands/command-args.ts +29 -0
  107. package/src/commands/context.ts +15 -1
  108. package/src/commands/defaults.ts +29 -1
  109. package/src/commands/levels.ts +16 -0
  110. package/src/commands/live-context.ts +3 -0
  111. package/src/content/collection-match.ts +267 -0
  112. package/src/content/entry-commands.ts +38 -29
  113. package/src/content/entry-fields.ts +6 -0
  114. package/src/editor/shortcuts.ts +5 -0
  115. package/src/files/file-ops.ts +10 -42
  116. package/src/files/files.ts +487 -59
  117. package/src/files/fs-events.ts +9 -0
  118. package/src/files/gitignore.ts +432 -0
  119. package/src/files/media-paths.ts +13 -42
  120. package/src/files/media-usage.ts +41 -131
  121. package/src/files/serialize-document.ts +60 -0
  122. package/src/format/convert-file.ts +511 -0
  123. package/src/format/format-choices.ts +199 -0
  124. package/src/grid/grid-view.ts +16 -2
  125. package/src/hosting/document.ts +2 -0
  126. package/src/hosting/layout.ts +8 -0
  127. package/src/media/media-open.ts +58 -0
  128. package/src/media/media-pane.ts +310 -0
  129. package/src/new-project/import-tab.ts +357 -163
  130. package/src/new-project/location-fields.ts +8 -5
  131. package/src/new-project/new-project-modal.ts +27 -34
  132. package/src/packages/ensure-deps.ts +14 -7
  133. package/src/packages/jxsuite-update.ts +11 -11
  134. package/src/page-params.ts +10 -46
  135. package/src/panels/activity-bar.ts +45 -16
  136. package/src/panels/ai-chat/attached-context.ts +6 -1
  137. package/src/panels/ai-chat/chat-view.ts +255 -13
  138. package/src/panels/ai-chat/composer.ts +31 -97
  139. package/src/panels/ai-panel.ts +116 -2
  140. package/src/panels/navigator-panels.ts +7 -6
  141. package/src/panels/settings-menu.ts +799 -0
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +261 -70
  144. package/src/platforms/devserver.ts +67 -33
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/publish/publish-panel.ts +62 -2
  147. package/src/services/ai-ask.ts +247 -0
  148. package/src/services/ai-import-tools.ts +532 -0
  149. package/src/services/ai-models.ts +108 -1
  150. package/src/services/ai-project-tools.ts +20 -26
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/connection.ts +74 -0
  154. package/src/services/context-manager.ts +101 -1
  155. package/src/services/document-assistant.ts +45 -4
  156. package/src/services/import-client.ts +33 -6
  157. package/src/services/import-run.ts +158 -0
  158. package/src/services/import-seed.ts +120 -0
  159. package/src/services/jx-validate.ts +58 -6
  160. package/src/services/project-adoption.ts +71 -0
  161. package/src/services/references.ts +0 -0
  162. package/src/services/settings/definitions.ts +17 -0
  163. package/src/services/tool-executor.ts +45 -2
  164. package/src/settings/contexts-section.ts +45 -3
  165. package/src/settings/dependencies-editor.ts +43 -23
  166. package/src/settings/general-settings.ts +30 -12
  167. package/src/settings/preferences-accounts.ts +193 -12
  168. package/src/settings/preferences-dialog.ts +93 -59
  169. package/src/settings/preferences-sections.ts +57 -0
  170. package/src/settings/settings-document.ts +65 -9
  171. package/src/studio.ts +33 -6
  172. package/src/tabs/transact.ts +42 -19
  173. package/src/types.ts +129 -7
  174. package/src/ui/ai-managed-connect.ts +62 -13
  175. package/src/ui/ai-model-picker.ts +189 -0
  176. package/src/ui/cf-account-picker.ts +200 -0
  177. package/src/ui/choice-field.ts +72 -0
  178. package/src/ui/layers.ts +83 -5
  179. package/src/utils/studio-utils.ts +22 -52
  180. package/styles/forced-colors.css +7 -0
  181. package/styles/overlays.css +256 -0
  182. package/styles/panels.css +13 -1
  183. package/styles/shell.css +147 -0
  184. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  185. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  186. package/dist/chunks/files-xcybanxe.js +0 -70
  187. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  188. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  189. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  190. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  191. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  192. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  193. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  194. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  195. package/dist/chunks/studio-bw5dz6km.js +0 -608
  196. package/dist/chunks/studio-c31yb4t6.js +0 -55
  197. package/src/canvas/site-style-css.ts +0 -117
@@ -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 {
@@ -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
+ }
@@ -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
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { createChatState } from "@jxsuite/ai/chat-state";
15
+ import { modelContextWindow } from "./ai-models";
15
16
 
16
17
  /** Shape of a single entry returned by `chatState.toMessagesArray()`. */
17
18
  interface MessageArrayEntry {
@@ -52,7 +53,13 @@ const BUDGET_FRACTION = 0.8;
52
53
  const WARN_FRACTION = 0.5;
53
54
 
54
55
  /**
55
- * Resolve the context window (tokens) for a model id via longest-prefix match.
56
+ * Resolve the context window (tokens) for a model id: what the backend reported, else a
57
+ * longest-prefix match on the table above, else the conservative default.
58
+ *
59
+ * The backend's number goes first because the table can only ever know the names it was written
60
+ * with. Every `@cf/*` model missed it and was budgeted at {@link DEFAULT_CONTEXT_WINDOW}, so a
61
+ * managed 128k model started dropping the oldest turns at about 25.6k — a fifth of what it could
62
+ * hold, on the one backend that actually publishes the figure.
56
63
  *
57
64
  * @param {string | undefined} model
58
65
  * @returns {number}
@@ -61,6 +68,10 @@ function contextWindowFor(model: string | undefined): number {
61
68
  if (!model) {
62
69
  return DEFAULT_CONTEXT_WINDOW;
63
70
  }
71
+ const reported = modelContextWindow(model);
72
+ if (reported !== undefined && reported > 0) {
73
+ return reported;
74
+ }
64
75
  const id = model.toLowerCase();
65
76
  let best = 0;
66
77
  let window = DEFAULT_CONTEXT_WINDOW;
@@ -198,3 +209,92 @@ export function trimContext(
198
209
 
199
210
  return { estimatedTokens: newTotal, droppedCount };
200
211
  }
212
+
213
+ // ─── Orphaned tool calls ────────────────────────────────────────────────────
214
+
215
+ /** What a synthesized result says, so a reader and the model see the same reason. */
216
+ const UNANSWERED_TOOL_RESULT = JSON.stringify({
217
+ success: false,
218
+ error:
219
+ "This tool call was never completed — the session was reloaded or the history was trimmed.",
220
+ });
221
+
222
+ /**
223
+ * Repair tool-call pairing in place, and report how many messages it touched.
224
+ *
225
+ * `toMessagesArray()` serializes the array verbatim: an assistant message with `tool_calls` becomes
226
+ * a `tool_calls` request, and a `tool` message becomes a reply carrying `tool_call_id`. Providers
227
+ * require the two to come in pairs, and **three ordinary things break the pairing**:
228
+ *
229
+ * 1. `saveSession` persists `msgs.slice(-MAX_PERSIST_MESSAGES)` and `restoreChat` pushes the result
230
+ * back, so a session longer than the cap can be restored starting mid-pair.
231
+ * 2. {@link trimContext} splices from the front for the token budget, with no more regard for pairs.
232
+ * 3. A turn that stopped while a tool was still running — which `ask_user` makes routine, because its
233
+ * tool is _designed_ to be outstanding — leaves a request with no reply.
234
+ *
235
+ * Each of those produced a 400 on the NEXT send, from a conversation the user could see was fine.
236
+ * So the repair belongs on the send path, after trimming, rather than at any one of the three
237
+ * sites: a request with no reply gets a synthesized failure (keeping the assistant's text, which
238
+ * dropping the message would lose), and a reply with no request is dropped.
239
+ *
240
+ * @param {ReturnType<typeof createChatState>} chatState
241
+ * @returns {{ sealed: number; dropped: number }}
242
+ */
243
+ export function pruneOrphanToolMessages(chatState: ReturnType<typeof createChatState>): {
244
+ sealed: number;
245
+ dropped: number;
246
+ } {
247
+ const { messages } = chatState;
248
+
249
+ const requested = new Set<string>();
250
+ const answered = new Set<string>();
251
+ for (const msg of messages) {
252
+ if (msg.role === "assistant" && msg.toolCalls) {
253
+ for (const call of msg.toolCalls) {
254
+ requested.add(call.id);
255
+ }
256
+ } else if (msg.role === "tool" && msg.toolCallId) {
257
+ answered.add(msg.toolCallId);
258
+ }
259
+ }
260
+
261
+ // Replies with no request, and replies with no id at all — neither can be serialized into a
262
+ // Well-formed pair, and a reply is the half that carries no author text worth keeping.
263
+ let dropped = 0;
264
+ for (let i = messages.length - 1; i >= 0; i--) {
265
+ const msg = messages[i]!;
266
+ if (msg.role === "tool" && (!msg.toolCallId || !requested.has(msg.toolCallId))) {
267
+ messages.splice(i, 1);
268
+ dropped += 1;
269
+ }
270
+ }
271
+
272
+ /* Requests with no reply. Walk backwards so each insertion is past the indices still to visit,
273
+ and seal immediately after the requesting message — `toMessagesArray` emits in array order, so
274
+ that is the only position the pair is adjacent in. */
275
+ let sealed = 0;
276
+ for (let i = messages.length - 1; i >= 0; i--) {
277
+ const msg = messages[i]!;
278
+ if (msg.role !== "assistant" || !msg.toolCalls) {
279
+ continue;
280
+ }
281
+ const missing = msg.toolCalls.filter((call) => !answered.has(call.id));
282
+ if (missing.length === 0) {
283
+ continue;
284
+ }
285
+ messages.splice(
286
+ i + 1,
287
+ 0,
288
+ ...missing.map((call, n) => ({
289
+ content: UNANSWERED_TOOL_RESULT,
290
+ id: `sealed_${call.id}_${n}`,
291
+ role: "tool" as const,
292
+ timestamp: msg.timestamp,
293
+ toolCallId: call.id,
294
+ })),
295
+ );
296
+ sealed += missing.length;
297
+ }
298
+
299
+ return { dropped, sealed };
300
+ }
@@ -20,15 +20,18 @@ import type { Tab } from "../tabs/tab";
20
20
  import { componentRegistry } from "../files/components";
21
21
  import { activeRegistry } from "../commands/active-registry";
22
22
  import { registerAiTools } from "./ai-tools";
23
+ import { cancelAsk, registerAskTool, resetAsk } from "./ai-ask";
23
24
  import { registerProjectTools } from "./ai-project-tools";
25
+ import { registerImportTools, resetImportGuard } from "./ai-import-tools";
24
26
  import { createGatedToolRegistry } from "./gated-registry";
25
27
  import type { ToolAvailability } from "./gated-registry";
26
28
  import { adoptProject } from "./project-adoption";
29
+ import { abortImportRun, resetImportRuns } from "./import-run";
27
30
  import { runAgentLoop } from "./tool-executor";
28
- import { AI_TOOL_TIERS, buildSystemPrompt, tierActive } from "./ai-system-prompt";
31
+ import { AI_TOOL_TIERS, buildSystemPrompt, toolActive } from "./ai-system-prompt";
29
32
  import { getBaseUrl, getOpenAiKey } from "./ai-settings";
30
33
  import { preferredModel } from "./ai-models";
31
- import { trimContext } from "./context-manager";
34
+ import { pruneOrphanToolMessages, trimContext } from "./context-manager";
32
35
  import { renderCheck } from "./render-critic";
33
36
  import { openFileInTab, reloadFileInTab } from "../files/files";
34
37
  import { refreshExtensionUi } from "../format/format-host";
@@ -77,6 +80,8 @@ export function createDocumentAssistant() {
77
80
  };
78
81
 
79
82
  const innerRegistry = createToolRegistry();
83
+ // Ungated: a question is not a function of what happens to be open.
84
+ registerAskTool(innerRegistry);
80
85
  registerAiTools(innerRegistry, {
81
86
  getTab: () => activeTab.value,
82
87
  saveFile: async (relPath: string, content: string) => {
@@ -91,6 +96,17 @@ export function createDocumentAssistant() {
91
96
  findOpenTab,
92
97
  reloadTab: reloadFileInTab,
93
98
  });
99
+ registerImportTools(innerRegistry, {
100
+ adoptProject,
101
+ getTab: () => activeTab.value,
102
+ // Same re-keying as `create_project`: an import bootstraps a project, so the bootstrap
103
+ // Conversation has to follow it out of the unscoped store.
104
+ onProjectAdopted: (root: string) => {
105
+ if (sessionId) {
106
+ sessionStore.moveSession("", root, sessionId);
107
+ }
108
+ },
109
+ });
94
110
  registerProjectTools(innerRegistry, {
95
111
  getTab: () => activeTab.value,
96
112
  renderCheck: renderCheck as (
@@ -126,6 +142,9 @@ export function createDocumentAssistant() {
126
142
  * every agent-loop round, so a mid-loop create_project unlocks the higher tiers immediately.
127
143
  */
128
144
  const TIER_REQUIREMENTS = {
145
+ /* Never refused, so the sentence is unreachable — it exists because the map is derived from
146
+ the tier union, and a tier without one would not compile. */
147
+ always: "nothing",
129
148
  "no-project": "no project to be open (it bootstraps one)",
130
149
  project: "an open project",
131
150
  document: "an open document (use open_document first)",
@@ -155,12 +174,18 @@ export function createDocumentAssistant() {
155
174
  t.name,
156
175
  {
157
176
  when: () =>
158
- tierActive(t.tier, {
177
+ toolActive(t, {
178
+ /* The platform's own answer, read live rather than captured: the assistant is
179
+ constructed at module load, before a platform is registered in some hosts. */
180
+ canImport: Boolean(getPlatform().importSite),
159
181
  hasDocument: Boolean(activeTab.value),
160
182
  hasProject: Boolean(workspace.projectRoot),
161
183
  treeEditable: treeEditable(),
162
184
  }),
163
- requires: TIER_REQUIREMENTS[t.tier],
185
+ requires:
186
+ t.capability === "importSite"
187
+ ? "a platform with a site-import backend"
188
+ : TIER_REQUIREMENTS[t.tier],
164
189
  },
165
190
  ]),
166
191
  );
@@ -182,6 +207,7 @@ export function createDocumentAssistant() {
182
207
  components: componentRegistry.length > 0 ? componentRegistry : undefined,
183
208
  projectRoot: workspace.projectRoot || undefined,
184
209
  hasProject: Boolean(workspace.projectRoot),
210
+ canImport: Boolean(getPlatform().importSite),
185
211
  ...(inventory && inventory.length > 0 ? { fileInventory: inventory } : {}),
186
212
  });
187
213
  }
@@ -205,6 +231,11 @@ export function createDocumentAssistant() {
205
231
  chatState.setTokenCount(trimmed.estimatedTokens);
206
232
  }
207
233
 
234
+ /* AFTER the trim, not before: the trim splices from the front and is itself one of the three
235
+ ways a tool_calls request loses its reply (see pruneOrphanToolMessages). Repairing first
236
+ would leave the very pair the trim then broke. */
237
+ pruneOrphanToolMessages(chatState);
238
+
208
239
  // Persist after trimming so the saved history reflects what's actually sent.
209
240
  persistChat();
210
241
 
@@ -248,11 +279,19 @@ export function createDocumentAssistant() {
248
279
 
249
280
  function stop() {
250
281
  controller?.abort();
282
+ /* The abort settles an outstanding question through the turn signal, but only when the turn
283
+ HAS one — the evals runner and several tests drive the loop without. Settling here as well
284
+ is what makes Stop terminal in every case rather than in most of them. */
285
+ cancelAsk();
286
+ abortImportRun();
251
287
  chatState.cancelStream();
252
288
  }
253
289
 
254
290
  function newChat() {
255
291
  stop();
292
+ resetAsk();
293
+ resetImportRuns();
294
+ resetImportGuard();
256
295
  chatState.clearChat();
257
296
  sessionId = null;
258
297
  sessionStore.setActiveSession(projectRoot(), null);
@@ -272,6 +311,7 @@ export function createDocumentAssistant() {
272
311
  return;
273
312
  }
274
313
  stop();
314
+ resetAsk();
275
315
  chatState.clearChat();
276
316
  pushRestoredMessages(msgs);
277
317
  sessionId = id;
@@ -283,6 +323,7 @@ export function createDocumentAssistant() {
283
323
  sessionStore.deleteSession(projectRoot(), id);
284
324
  if (sessionId === id) {
285
325
  stop();
326
+ resetAsk();
286
327
  chatState.clearChat();
287
328
  sessionId = null;
288
329
  }