@jxsuite/studio 5.0.0 → 5.2.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 (132) hide show
  1. package/dist/chunks/{cf-account-picker-fyf63pmy.js → cf-account-picker-h4dab9bq.js} +10 -10
  2. package/dist/chunks/{cf-settings-71bv5zn9.js → cf-settings-adg74qsk.js} +4 -4
  3. package/dist/chunks/components-5y78e05d.js +20 -0
  4. package/dist/chunks/content-source-sgg1sv6n.js +46 -0
  5. package/dist/chunks/{convert-to-repeater-d0cgnb6d.js → convert-to-repeater-mf1cxgfn.js} +25 -25
  6. package/dist/chunks/entry-commands-w9qsxa6q.js +42 -0
  7. package/dist/chunks/{extension-sections-fy7q4fe9.js → extension-sections-c6zwzbre.js} +27 -27
  8. package/dist/chunks/file-ops-vrpq1jjk.js +47 -0
  9. package/dist/chunks/{files-ef8fgs3w.js → files-msaf95y6.js} +33 -32
  10. package/dist/chunks/{format-host-3c33nwr8.js → format-host-f64fnesp.js} +2 -2
  11. package/dist/chunks/{fs-events-13tj6sex.js → fs-events-3y6y0nrm.js} +6 -6
  12. package/dist/chunks/git-panel-bv5e22j0.js +50 -0
  13. package/dist/chunks/grid-view-1p6hsyjx.css +8406 -0
  14. package/dist/chunks/grid-view-paj67nr5.js +15322 -0
  15. package/dist/chunks/{iframe-host-jgesd924.js → iframe-host-dh5np8vk.js} +14 -14
  16. package/dist/chunks/{layers-1nq5evsh.js → layers-1vcfdbet.js} +7 -6
  17. package/dist/chunks/layers-panel-fksbdg4q.js +40 -0
  18. package/dist/chunks/{media-picker-2qbetq73.js → media-picker-p49nhqhs.js} +14 -14
  19. package/dist/chunks/menu-qht48vnc.js +15 -0
  20. package/dist/chunks/{monaco-buffer-bhy3hb6m.js → monaco-buffer-pykwbm92.js} +3 -3
  21. package/dist/chunks/{notify-628t0anh.js → notify-42gk8ha1.js} +2 -2
  22. package/dist/chunks/{parse-0zx14y15.js → parse-reg9eevt.js} +2 -2
  23. package/dist/chunks/publish-panel-vz23wwmg.js +27 -0
  24. package/dist/chunks/quick-search-e9rp74m8.js +46 -0
  25. package/dist/chunks/{references-n553f0e1.js → references-h69xp9zz.js} +4 -4
  26. package/dist/chunks/right-panel-j97t2yvy.js +45 -0
  27. package/dist/chunks/{studio-18avnhjp.js → studio-01jeck2y.js} +36 -30
  28. package/dist/chunks/{studio-9xxz1q69.js → studio-14d9tq4d.js} +9 -9
  29. package/dist/chunks/{studio-xsykpvsh.js → studio-15f3kxqe.js} +19 -19
  30. package/dist/chunks/studio-1tbjfqv4.js +146 -0
  31. package/dist/chunks/{studio-79y5sz51.js → studio-28vs5k2y.js} +2 -2
  32. package/dist/chunks/{studio-3hxkd92t.js → studio-2mdbbc76.js} +10 -10
  33. package/dist/chunks/{studio-7r42c6ca.js → studio-3ntvq6cg.js} +5 -5
  34. package/dist/chunks/{studio-8jn5yhyn.js → studio-4c041r8y.js} +3 -3
  35. package/dist/chunks/{studio-qahxvnan.js → studio-4r14yzyy.js} +4 -4
  36. package/dist/chunks/{studio-arfpe389.js → studio-4xmjcrg3.js} +6 -6
  37. package/dist/chunks/{studio-5523bpzt.js → studio-5nz00bhh.js} +3 -3
  38. package/dist/chunks/{studio-x3rty6tr.js → studio-5t7ena5z.js} +8 -8
  39. package/dist/chunks/{studio-5rt7sqa0.js → studio-66arbpc2.js} +13 -19
  40. package/dist/chunks/{studio-fabxk6r9.js → studio-68fydsrj.js} +15 -15
  41. package/dist/chunks/{studio-tb3ev01j.js → studio-9j6sfeba.js} +6 -6
  42. package/dist/chunks/{studio-akbww0j5.js → studio-a1pswps3.js} +38 -38
  43. package/dist/chunks/{studio-6xa21ej9.js → studio-c04nzbby.js} +2 -2
  44. package/dist/chunks/{studio-4m0haseg.js → studio-c07s5ckd.js} +4 -4
  45. package/dist/chunks/{studio-57mf66w2.js → studio-ctm7bhx5.js} +14 -14
  46. package/dist/chunks/{studio-p1dw8wfw.js → studio-d8z5bajd.js} +14 -10
  47. package/dist/chunks/{studio-vz68yn9e.js → studio-en82c639.js} +6 -6
  48. package/dist/chunks/{studio-rkstz940.js → studio-etvx9s2g.js} +21 -12
  49. package/dist/chunks/{studio-az3zd49t.js → studio-ez11dmdh.js} +127 -37
  50. package/dist/chunks/{studio-sgdc5pfc.js → studio-hdc2jt3z.js} +4 -4
  51. package/dist/chunks/{studio-s6z853aq.js → studio-hhskq2mr.js} +24 -23
  52. package/dist/chunks/{studio-6ewqv390.js → studio-m1p9b6c3.js} +5 -5
  53. package/dist/chunks/{studio-25hkdrkw.js → studio-mpzwze9x.js} +12 -12
  54. package/dist/chunks/{studio-8qtrpzr3.js → studio-ncn9e189.js} +7 -7
  55. package/dist/chunks/{studio-81390n6f.js → studio-nq5yf5mp.js} +1 -1
  56. package/dist/chunks/{studio-5jy81t3s.js → studio-p6pmwkvh.js} +229 -24
  57. package/dist/chunks/{studio-3gxv60tn.js → studio-qa7fzxan.js} +4 -4
  58. package/dist/chunks/{studio-7xptrg0j.js → studio-sar8g54p.js} +8 -8
  59. package/dist/chunks/studio-t171hxhe.js +23 -0
  60. package/dist/chunks/{studio-em9nw8eh.js → studio-t3adrr56.js} +20 -17
  61. package/dist/chunks/{studio-hhvs4sdg.js → studio-vn5hske5.js} +10 -4
  62. package/dist/chunks/{studio-pm6hgy7r.js → studio-vt0tj32j.js} +29 -42
  63. package/dist/chunks/{studio-d1ja2x9r.js → studio-wjpgxywa.js} +4 -4
  64. package/dist/chunks/{studio-q3x06vmj.js → studio-wnsyffg1.js} +2 -2
  65. package/dist/chunks/{studio-rpyah2ew.js → studio-x10fp4tp.js} +64 -24
  66. package/dist/chunks/{studio-26d7myd1.js → studio-x72j58vw.js} +4 -4
  67. package/dist/chunks/{studio-cpvb2ed1.js → studio-y4f9fdge.js} +8 -8
  68. package/dist/chunks/{studio-erdatzvp.js → studio-ympe0n7a.js} +35 -6
  69. package/dist/chunks/{studio-y9zedyjn.js → studio-z2smx5ce.js} +2 -2
  70. package/dist/chunks/studio-zjjde71a.js +148 -0
  71. package/dist/chunks/{workspace-wrew8zk4.js → workspace-bnw54cha.js} +18 -10
  72. package/dist/iframe-entry.js +102 -11
  73. package/dist/studio.css +1 -2
  74. package/dist/studio.js +1113 -16062
  75. package/package.json +11 -11
  76. package/src/canvas/canvas-utils.ts +29 -2
  77. package/src/commands/command-args.ts +37 -2
  78. package/src/commands/registry.ts +18 -8
  79. package/src/commands/run-reported.ts +120 -0
  80. package/src/editor/shortcuts.ts +11 -23
  81. package/src/files/components.ts +39 -0
  82. package/src/files/files.ts +41 -17
  83. package/src/grid/grid-lazy.ts +59 -0
  84. package/src/grid/grid-open.ts +20 -3
  85. package/src/grid/grid-panel.ts +43 -9
  86. package/src/i18n/i18n-commands.ts +10 -0
  87. package/src/media/media-open.ts +14 -2
  88. package/src/packages/pull-package-sync.ts +5 -1
  89. package/src/panels/ai-panel.ts +5 -2
  90. package/src/panels/block-action-bar.ts +7 -2
  91. package/src/panels/i18n-panel.ts +8 -15
  92. package/src/panels/jump-bar.ts +3 -2
  93. package/src/panels/pane-context.ts +5 -4
  94. package/src/panels/pane-grid.ts +100 -10
  95. package/src/panels/problems-panel.ts +6 -2
  96. package/src/panels/quick-search.ts +6 -12
  97. package/src/panels/settings-menu.ts +5 -15
  98. package/src/panels/tab-drop.ts +220 -0
  99. package/src/panels/tab-strip.ts +238 -33
  100. package/src/services/ai-command-tools.ts +78 -8
  101. package/src/services/ai-models.ts +13 -0
  102. package/src/services/ai-project-tools.ts +8 -2
  103. package/src/services/ai-system-prompt.ts +4 -8
  104. package/src/services/ai-tools.ts +8 -69
  105. package/src/services/document-assistant.ts +9 -6
  106. package/src/studio.ts +24 -8
  107. package/src/surfaces/commandbar.json +35 -24
  108. package/src/surfaces/commandbar.ts +6 -5
  109. package/src/surfaces/pane-grid.json +35 -3
  110. package/src/surfaces/pane-grid.ts +26 -4
  111. package/src/surfaces/statusbar.ts +2 -1
  112. package/src/surfaces/tab-strip.json +16 -39
  113. package/src/surfaces/tab-strip.ts +76 -12
  114. package/src/tabs/project-config.ts +45 -11
  115. package/src/ui/ai-credentials-form.ts +12 -1
  116. package/src/ui/layers.ts +4 -1
  117. package/src/workspace/pane-derive.ts +7 -5
  118. package/src/workspace/session.ts +3 -3
  119. package/src/workspace/workspace.ts +220 -43
  120. package/styles/shell-frame.css +8 -0
  121. package/styles/shell-frame.json +4 -0
  122. package/dist/chunks/components-2w1f35qs.js +0 -18
  123. package/dist/chunks/content-source-3wqeas45.js +0 -46
  124. package/dist/chunks/entry-commands-d6g50j7s.js +0 -42
  125. package/dist/chunks/file-ops-3btj6st0.js +0 -47
  126. package/dist/chunks/git-panel-4wp2rn9b.js +0 -49
  127. package/dist/chunks/layers-panel-ar2gh90g.js +0 -40
  128. package/dist/chunks/menu-rf7n72mf.js +0 -14
  129. package/dist/chunks/publish-panel-98f7sr3g.js +0 -27
  130. package/dist/chunks/quick-search-srzz83k2.js +0 -45
  131. package/dist/chunks/right-panel-rrgx2dn3.js +0 -45
  132. package/dist/chunks/studio-d055c37e.js +0 -62
@@ -32,6 +32,25 @@
32
32
  * the model is told "changed nothing" rather than congratulated. This is what closes the
33
33
  * phantom-success finding for every bridged document write, not just the one tool it was found on.
34
34
  *
35
+ * **The project witness** is the same comparison over `project.json`. Every configuration write is
36
+ * a transaction on the configuration document (`tabs/project-config.ts`), whose bind effect keeps
37
+ * `projectState.projectConfig` equal to `toRaw(document.doc.document)` — so a record with `undo:
38
+ * "project"` that left that reference alone wrote nothing, whatever its report says. The ledger
39
+ * then files nothing (`wrote: []`, mechanically, for every project record), and a report that
40
+ * named no `wrote` of its own — one with no latch to know it was a no-op — has its sentence
41
+ * replaced by "changed nothing", because "Wrote project.json" over an untouched file is the
42
+ * phantom the ledger exists to keep out. A record whose report answered `wrote: []` itself is one
43
+ * that KNEW, and its sentence ("already enabled") is the one the witness cannot write. That is why
44
+ * `add_project_locale` keeps its latch: the sentence, not the ledger, is what the latch is for.
45
+ *
46
+ * **A tool with nothing it could be called with is withheld.** A `derivedEnumProperty` on a
47
+ * REQUIRED argument serialises `enum: []` when the list behind it is empty — `enable_extension`
48
+ * with no catalogue — and `@jxsuite/ai`'s validator tolerates that only because every projected
49
+ * tool is `strict: false`. The record's gate cannot see its own argument list, so the bridge reads
50
+ * it: an enabled record whose required enum is empty is not advertised this round, and comes back
51
+ * the round the list does. `getDefinition` still answers, so a call the model makes anyway meets
52
+ * the coercion's own refusal (`is not declared — declared: none`) rather than `Unknown tool`.
53
+ *
35
54
  * **`validate()` is a no-op, on purpose.** `@jxsuite/ai`'s validator never checks `enum` and treats
36
55
  * a required `null` as missing (`packages/ai/src/tools.ts`), which would refuse `select_node
37
56
  * { path: null }`. The registry's coercion is the single validator, so every projected tool is
@@ -46,6 +65,7 @@ import { activeRegistry } from "../commands/active-registry";
46
65
  import { argsSchema } from "../commands/command-args";
47
66
  import { inCanvas } from "../commands/context";
48
67
  import { toRaw } from "../reactivity";
68
+ import { projectState } from "../state";
49
69
  import { recordWrite } from "./ai-writes";
50
70
  import { reportDocumentWrite, snapshotBeforeWrite } from "./ai-write-report";
51
71
  import type { WriteReportDeps, WriteSnapshot } from "./ai-write-report";
@@ -76,10 +96,25 @@ const EMPTY_ARGS = argsSchema({});
76
96
 
77
97
  /** The loosely-typed view of a record's `args` this module reads. */
78
98
  interface ArgsShape {
79
- properties?: Record<string, object>;
99
+ properties?: Record<string, { enum?: unknown }>;
80
100
  required?: readonly string[];
81
101
  }
82
102
 
103
+ /**
104
+ * Whether a required argument's choice list is empty right now.
105
+ *
106
+ * Reading `enum` is what serialises a `derivedEnumProperty` — the getter runs here exactly as it
107
+ * runs on the wire — so this is the same answer the model would have been shown. Only a REQUIRED
108
+ * property counts: an optional enum that is empty is an argument the model can leave out.
109
+ */
110
+ function hasEmptyRequiredEnum(args: object | undefined): boolean {
111
+ const shape = (args ?? EMPTY_ARGS) as ArgsShape;
112
+ return (shape.required ?? []).some((key) => {
113
+ const declared = shape.properties?.[key]?.enum;
114
+ return Array.isArray(declared) && declared.length === 0;
115
+ });
116
+ }
117
+
83
118
  /**
84
119
  * The selector's own `paths` property object, taken BY REFERENCE from the live record so the
85
120
  * description the model reads is the selector's. `undefined` when the selector is not registered,
@@ -141,12 +176,14 @@ function selectionAdvertised(registry: CommandRegistry): boolean {
141
176
  /**
142
177
  * Whether one record's tool is advertised this round. For a project- or document-level record it is
143
178
  * the record's own gate, evaluated as the palette evaluates it — a module-state `enablement`
144
- * closure included. Selection-level records are the one derived case, above.
179
+ * closure included. Selection-level records are the one derived case, above. And a record the gate
180
+ * admits is still withheld while a required argument has an empty choice list: there is nothing it
181
+ * could be called with, and a tool advertised with `enum: []` costs a round to discover that.
145
182
  */
146
183
  function advertised(registry: CommandRegistry, command: AnyCommand): boolean {
147
- return command.level === "selection"
148
- ? selectionAdvertised(registry)
149
- : registry.isEnabled(command.id);
184
+ const open =
185
+ command.level === "selection" ? selectionAdvertised(registry) : registry.isEnabled(command.id);
186
+ return open && !hasEmptyRequiredEnum(command.args);
150
187
  }
151
188
 
152
189
  /** Every record carrying a projection, enabled or not. */
@@ -251,6 +288,19 @@ function normaliseReport(report: string | AiToolReport): AiToolReport {
251
288
  return typeof report === "string" ? { summary: report } : report;
252
289
  }
253
290
 
291
+ /**
292
+ * The configuration object the app is live on, unwrapped, or `null` before a project has one.
293
+ *
294
+ * The same read `tabs/project-config.ts`'s `liveConfig()` makes: `projectState.projectConfig` is
295
+ * held to `toRaw(document.doc.document)` by the bind effect, so a `transactDoc` on the
296
+ * configuration document is a new reference here — and an unchanged reference is a run that never
297
+ * transacted, however the record's report reads.
298
+ */
299
+ function projectConfigRef(): object | null {
300
+ const config = projectState?.projectConfig;
301
+ return config ? toRaw(config as unknown as object) : null;
302
+ }
303
+
254
304
  /**
255
305
  * The ledger paths a record's write defaults to when `report` names none, by undo scope. `"none"`
256
306
  * has no default on purpose — see {@link AiToolReport.wrote}.
@@ -322,6 +372,10 @@ export function createCommandToolRegistry(deps: CommandToolDeps): ToolRegistry {
322
372
  what `transactDoc` replaces, and the snapshot is what the verdict compares against. */
323
373
  const tab = command.undo === "document" ? deps.getTab() : null;
324
374
  const rootBefore = tab ? toRaw(tab.doc.document) : null;
375
+ /* The project witness's half: the configuration object the chokepoint holds, which every
376
+ applied `project.json` transaction replaces. Read for `undo: "project"` records only — the
377
+ comparison is meaningless for a record that does not claim the file. */
378
+ const configBefore = command.undo === "project" ? projectConfigRef() : null;
325
379
  let snapshot: WriteSnapshot | null = null;
326
380
  let before: CommandContext;
327
381
  try {
@@ -357,6 +411,12 @@ export function createCommandToolRegistry(deps: CommandToolDeps): ToolRegistry {
357
411
  };
358
412
  }
359
413
 
414
+ /* The project witness's verdict, decided before the report so a report that throws is filed
415
+ against it too. Unlike the document witness this is not a refusal: an idempotent project verb
416
+ asked for a state it already had is an ordinary answer, and the record's own sentence may
417
+ say why. What the witness owns is the LEDGER — nothing landed, so nothing is filed. */
418
+ const projectUnchanged = command.undo === "project" && projectConfigRef() === configBefore;
419
+
360
420
  const after = registry.context();
361
421
  let report: AiToolReport;
362
422
  try {
@@ -366,13 +426,23 @@ export function createCommandToolRegistry(deps: CommandToolDeps): ToolRegistry {
366
426
  Left to the loop, the throw would be labelled "Failed to parse arguments" and the ledger
367
427
  would never be filed. So the ledger is filed from `undo`'s defaults — the report that would
368
428
  have named the paths is the thing that failed — and the model is told both halves. */
369
- fileLedger(command, tab);
429
+ fileLedger(command, tab, projectUnchanged ? [] : undefined);
370
430
  const message = error instanceof Error ? error.message : String(error);
371
431
  return { error: `${command.title} ran, but its report failed: ${message}`, success: false };
372
432
  }
373
- fileLedger(command, tab, report.wrote);
374
433
 
375
- let { summary } = report;
434
+ let { summary, wrote } = report;
435
+ if (projectUnchanged) {
436
+ /* The ledger files nothing whatever the report named. A report that did not itself know it
437
+ was a no-op has its sentence replaced — a claim of a write over an untouched file is
438
+ exactly the phantom this exists to catch. A report that answered `wrote: []` knew, and its
439
+ sentence is kept: it says WHY nothing changed, which the witness cannot. */
440
+ wrote = [];
441
+ if (report.wrote === undefined || report.wrote.length > 0) {
442
+ summary = `${command.title} changed nothing: project.json is exactly as it was.`;
443
+ }
444
+ }
445
+ fileLedger(command, tab, wrote);
376
446
  if (tab && snapshot) {
377
447
  const verdict = await reportDocumentWrite(tab, snapshot, summary, deps);
378
448
  if (!verdict.success) {
@@ -75,6 +75,7 @@ let proxyConfigured = false;
75
75
  let proxyManaged = false;
76
76
  let proxyDefaultModel = "";
77
77
  let proxyCode: AiModelsResponse["code"];
78
+ let proxyModelsError = "";
78
79
 
79
80
  /**
80
81
  * One-shot capability probe shared by every credentials gate, plus the hosts to repaint when it
@@ -116,6 +117,7 @@ export function resetModelCache() {
116
117
  proxyManaged = false;
117
118
  proxyDefaultModel = "";
118
119
  proxyCode = undefined;
120
+ proxyModelsError = "";
119
121
  /* The probe's result IS the flags above. Clearing them while keeping the settled promise
120
122
  would strand every gate on a permanent "unconfigured, unmanaged" reading — ensureProxyProbe
121
123
  would no-op forever and the managed option would vanish until a full reload. */
@@ -266,6 +268,16 @@ export function proxyStateCode(): AiModelsResponse["code"] {
266
268
  return proxyCode;
267
269
  }
268
270
 
271
+ /**
272
+ * The upstream's own error message from the last fetch, when it reported one (e.g. Cloudflare's "No
273
+ * route for that URI" for a base URL that doesn't serve `/models`). Empty when the last fetch had
274
+ * no upstream error to report — a stale message from an earlier failure must not survive a fetch
275
+ * that succeeded.
276
+ */
277
+ export function proxyModelsErrorMessage(): string {
278
+ return proxyModelsError;
279
+ }
280
+
269
281
  /** The proxy's preferred model id ("" when it does not declare one). */
270
282
  export function getProxyDefaultModel(): string {
271
283
  return proxyDefaultModel;
@@ -356,6 +368,7 @@ export async function fetchAvailableModels(
356
368
  proxyManaged = data.managed === true;
357
369
  proxyDefaultModel = data.defaultModel ?? "";
358
370
  proxyCode = data.code;
371
+ proxyModelsError = data.upstreamError !== undefined ? (data.upstreamMessage ?? "") : "";
359
372
  /* Capabilities are kept, not dropped. The backend has reported `toolSupport` all along and the
360
373
  ingest mapped `{id, name}` only, so a Workers AI model that cannot call tools looked exactly
361
374
  like one that can — the agent loop ran, called nothing, and answered as if that were normal.
@@ -115,8 +115,14 @@ export interface ProjectToolsCtx {
115
115
  * tab happened to be re-read from disk afterwards ({@link ProjectToolsCtx.reloadTab}); that
116
116
  * re-read is gone, because a second parse of `project.json` is the very rival object the
117
117
  * configuration document exists to prevent.
118
+ *
119
+ * `text` is the bytes that reached the file, of which `config` is the parse. The document keeps a
120
+ * layout record for the file it holds (§9.4), and the assistant's write replaces the file — so
121
+ * the record has to travel with the write, or the next settings commit re-lays the file the model
122
+ * just formatted with the record of the file it had read before (issue 331). Handing the text
123
+ * over is what lets the document derive that record without reading the file back.
118
124
  */
119
- onProjectConfigWritten?: (config: ProjectConfig) => void | Promise<void>;
125
+ onProjectConfigWritten?: (config: ProjectConfig, text: string) => void | Promise<void>;
120
126
  }
121
127
 
122
128
  /**
@@ -399,7 +405,7 @@ export function registerProjectTools(
399
405
  that tab, which is the split the adoption just closed. Every other path is a plain file
400
406
  and its tab has to be told from disk. */
401
407
  if (projectConfig) {
402
- await onProjectConfigWritten?.(projectConfig);
408
+ await onProjectConfigWritten?.(projectConfig, content);
403
409
  } else if (openTab) {
404
410
  await reloadTab(relPath);
405
411
  }
@@ -117,8 +117,8 @@ export interface AiToolInfo {
117
117
  *
118
118
  * The other kind of tool is not here and must not be added here: a command record that declares
119
119
  * `aiTool` is projected by `services/ai-command-tools.ts`, gated by the record's own `when` /
120
- * `enablement`, and described by the record. `enable_extension`, `disable_extension` and
121
- * `delete_node` (né `remove_node`) left this table for that one.
120
+ * `enablement`, and described by the record. `enable_extension`, `disable_extension`, `delete_node`
121
+ * (né `remove_node`) and `open_document` left this table for that one.
122
122
  */
123
123
  export const AI_TOOL_TIERS: AiToolInfo[] = [
124
124
  // Always — a question is not gated on what happens to be open
@@ -185,12 +185,8 @@ export const AI_TOOL_TIERS: AiToolInfo[] = [
185
185
  tier: "project",
186
186
  blurb: "create_page(path, content) — create a new .json page file on disk.",
187
187
  },
188
- {
189
- name: "open_document",
190
- tier: "project",
191
- blurb:
192
- "open_document(path) — open a file on the canvas as the active document; the document tools then operate on it. Use when the user should SEE the page, or for iterative visual refinement.",
193
- },
188
+ /* `open_document` is not a row: it is the projection of the `document.open` record
189
+ (`workspace/workspace.ts`), advertised by the record's own gate and described by the record. */
194
190
  // Document (an active document on the canvas)
195
191
  {
196
192
  name: "read_document",
@@ -14,9 +14,6 @@ import type { JxMutableNode, JxPath, JxStateDefinition } from "@jxsuite/schema/t
14
14
  import { getNodeAtPath } from "../state";
15
15
  import type { Tab } from "../tabs/tab";
16
16
  import {
17
- beginBatch,
18
- endBatch,
19
- isBatching,
20
17
  mutateInsertNode,
21
18
  mutateMoveNode,
22
19
  mutateUpdateProperty,
@@ -110,7 +107,6 @@ async function applyAndValidate(
110
107
  * validate?: (doc: unknown) => Promise<string[]>;
111
108
  * saveFile?: (relPath: string, content: string) => Promise<void>;
112
109
  * renderCheck?: (doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>;
113
- * openDocument?: (path: string) => Promise<void>;
114
110
  * projectStyle?: Record<string, string>;
115
111
  * getProjectStyle?: () => Record<string, string> | undefined;
116
112
  * findOpenTab?: (path: string) => import("../tabs/tab").Tab | null;
@@ -124,7 +120,6 @@ export function registerAiTools(
124
120
  validate = validateDoc,
125
121
  saveFile,
126
122
  renderCheck,
127
- openDocument,
128
123
  projectStyle,
129
124
  getProjectStyle,
130
125
  findOpenTab,
@@ -134,7 +129,6 @@ export function registerAiTools(
134
129
  validate?: (doc: unknown) => Promise<string[]>;
135
130
  saveFile?: (relPath: string, content: string) => Promise<void>;
136
131
  renderCheck?: (doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>;
137
- openDocument?: (path: string) => Promise<void>;
138
132
  projectStyle?: Record<string, string> | undefined;
139
133
  /**
140
134
  * Live variant of `projectStyle` — takes precedence; re-read per tool call so a project
@@ -776,67 +770,12 @@ export function registerAiTools(
776
770
  }),
777
771
  );
778
772
 
779
- // ── open_document ─────────────────────────────────────────────────────
780
-
781
- registry.register(
782
- createToolDefinition({
783
- name: "open_document",
784
- description:
785
- "Switch the active document to another file in the project. After opening, all " +
786
- "tools (read_document, set_property, add_child, etc.) operate on the newly-active " +
787
- "document. Use this to iteratively refine pages or components after creating them " +
788
- "with create_page or create_component.",
789
- parameters: {
790
- type: "object",
791
- properties: {
792
- path: {
793
- type: "string",
794
- description:
795
- 'File path relative to the project root, e.g. "pages/about.json" or ' +
796
- '"components/nav-bar.json". Must be an existing file.',
797
- },
798
- },
799
- required: ["path"],
800
- },
801
- async execute(args) {
802
- if (!openDocument) {
803
- return {
804
- success: false,
805
- error: "File navigation is not available in this environment.",
806
- };
807
- }
808
- const { path: relPath } = args as { path: string };
809
- try {
810
- await openDocument(relPath);
811
- const tab = getTab();
812
- if (!tab) {
813
- return {
814
- success: false,
815
- error: `File "${relPath}" could not be opened — no active tab after navigation.`,
816
- };
817
- }
818
- /*
819
- * The agent loop opens a single undo batch on the tab that was active at loop start
820
- * (tool-executor.js → beginBatch). Switching the active document mid-loop would strand
821
- * the new tab's edits with no history snapshot — undo would have nothing to roll back.
822
- * Flush the previous tab's batch and re-open one on the newly-active tab so edits in
823
- * each document remain individually undoable.
824
- */
825
- if (isBatching()) {
826
- endBatch();
827
- beginBatch(tab);
828
- }
829
- return {
830
- success: true,
831
- summary: `Switched to "${relPath}". All tools now operate on this document.`,
832
- };
833
- } catch (error) {
834
- return {
835
- success: false,
836
- error: `Failed to open "${relPath}": ${error instanceof Error ? error.message : String(error)}`,
837
- };
838
- }
839
- },
840
- }),
841
- );
773
+ /*
774
+ * `open_document` is NOT here any more. It is the projection of the `document.open` record
775
+ * (`workspace/workspace.ts`'s `tabCommands`), so the person's palette row and the model's tool
776
+ * are one `run` and one gate (§12.4). The hand tool read `getTab()` after the open and called
777
+ * whatever it found a success — a missing file left the previous document active and reported
778
+ * "Switched to" — and it re-anchored the undo batch itself, which `tool-executor.ts` has done
779
+ * after EVERY tool since project adoption started replacing tabs mid-loop.
780
+ */
842
781
  }
@@ -40,7 +40,7 @@ import { preferredModel } from "./ai-models";
40
40
  import { pruneOrphanToolMessages, trimContext } from "./context-manager";
41
41
  import { renderCheck } from "./render-critic";
42
42
  import { validateDoc } from "./jx-validate";
43
- import { openFileInTab, reloadFileInTab } from "../files/files";
43
+ import { reloadFileInTab } from "../files/files";
44
44
  import { getExtensionCatalog, refreshExtensionUi } from "../format/format-host";
45
45
  import * as sessionStore from "./ai-session-store";
46
46
 
@@ -98,7 +98,6 @@ export function createDocumentAssistant() {
98
98
  renderCheck: renderCheck as (
99
99
  doc: unknown,
100
100
  ) => Promise<{ ok: true } | { ok: false; error: string }>,
101
- openDocument: openFileInTab,
102
101
  getProjectStyle,
103
102
  findOpenTab,
104
103
  reloadTab: reloadFileInTab,
@@ -130,12 +129,15 @@ export function createDocumentAssistant() {
130
129
  sessionStore.moveSession("", root, sessionId);
131
130
  }
132
131
  },
133
- onProjectConfigWritten: async (config: ProjectConfig) => {
132
+ onProjectConfigWritten: async (config: ProjectConfig, text: string) => {
134
133
  /* Into the configuration document, not beside it. This used to assign a fresh object to
135
134
  `projectState.projectConfig`, leaving the document holding the previous configuration —
136
135
  and the next settings commit wrote that stale document back over the assistant's file.
137
- `adoptProjectConfig` is the one door for a config that reached disk another way. */
138
- await adoptProjectConfig(config);
136
+ `adoptProjectConfig` is the one door for a config that reached disk another way. The text
137
+ goes with it: the document derives the file's layout record from the bytes the tool wrote,
138
+ so the next settings commit is a one-line diff on the file as the model laid it out, not a
139
+ re-layout of it with the record of the file it read before (issue 331). */
140
+ await adoptProjectConfig(config, text);
139
141
  /* An `extensions` edit changes what the generated entry documents compose, and the backends
140
142
  regenerate them from project.json on demand — so without this the editor and the assistant
141
143
  both keep judging by the PREVIOUS project's schemas until the window reopens. */
@@ -157,7 +159,8 @@ export function createDocumentAssistant() {
157
159
  document: "an open document (use open_document first)",
158
160
  "document-tree":
159
161
  "an open document whose element tree the canvas is editing — Project Settings and the " +
160
- "grid editors are documents, but not trees to restructure",
162
+ "grid editors are documents, but not trees to restructure, and a page left in Code view " +
163
+ "returns to the canvas with set_canvas_mode",
161
164
  } as const;
162
165
 
163
166
  /**
package/src/studio.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
 
28
28
  import {
29
29
  PRIMARY_PANE,
30
+ activePane,
30
31
  activeTab,
31
32
  closeAllTabs,
32
33
  openTab,
@@ -160,7 +161,7 @@ import {
160
161
  mountSignalsPanel,
161
162
  registerSignalsCommands,
162
163
  } from "./panels/signals-panel";
163
- import { loadComponentRegistry } from "./files/components";
164
+ import { loadComponentRegistry, noteComponentSaved } from "./files/components";
164
165
  import { ensureDependenciesInstalled } from "./packages/ensure-deps";
165
166
  import { maybePromptJxsuiteUpdate } from "./packages/jxsuite-update";
166
167
  import { autoSyncProjectOnOpen } from "./packages/pull-package-sync";
@@ -234,7 +235,11 @@ import {
234
235
  platformUsesRepoPicker,
235
236
  } from "./new-project/add-repo-modal";
236
237
  import { openNewProjectModal, registerNewProjectCommands } from "./new-project/new-project-modal";
237
- import { invalidatePageRouteCache, registerInspectorCommands } from "./panels/properties-panel";
238
+ import {
239
+ invalidatePageRouteCache,
240
+ registerInspectorCommands,
241
+ renderPropertiesPanel,
242
+ } from "./panels/properties-panel";
238
243
  import { liveElementCommands, setContextMenuNavigate } from "./editor/context-menu";
239
244
  import { registerSeoCommands, renderSeoModal } from "./panels/seo-modal";
240
245
  import { ensurePopoverRevealWatch, setOpenPopover } from "./canvas/popover-state";
@@ -380,9 +385,11 @@ function setCanvasMode(tab: Tab | null, mode: string) {
380
385
  * 1. **To the side.** §8.2 has promised this since P3 and it never shipped — the chain ran
381
386
  * `openFileInTab` → `openTab` → `activePane()`, so "open the layout that wraps this page" opened
382
387
  * it ON TOP of the page it was teaching about.
383
- * 2. **Focus stays in the page.** An assistant pane that takes the keyboard means the author's next
384
- * keystroke edits the definition instead of the document they are looking at — and a following
385
- * pane would immediately have nothing to follow.
388
+ * 2. **The focus FOLLOWS.** The open is a gesture, not a read: the author asked to edit the component,
389
+ * so the pane it lands in takes the keyboard. The failure it replaces is the opposite one — an
390
+ * assistant pane that takes the keyboard means the author's next keystroke edits the definition
391
+ * instead of the document they are looking at, which is why the READS (`pane.compareWith`, the
392
+ * derivation follow, session restore) browse with `focus: false`.
386
393
  * 3. **A PREVIEW tab**, because drilling in is browsing: the second drill-in takes the same slot
387
394
  * instead of littering the side strip, and an edit promotes it (`promoteDirtyPreviewTabs`).
388
395
  * 4. **An ordinary tab, not a derivation.** "Edit definition" is a commitment to edit one component; a
@@ -392,16 +399,17 @@ function setCanvasMode(tab: Tab | null, mode: string) {
392
399
  * `openedFrom` is unchanged — §14.2's relationship, which nothing pops and nothing restores from.
393
400
  *
394
401
  * @param {string} componentPath
402
+ * @docs studio/interface/tabs
395
403
  */
396
404
  async function navigateToComponent(componentPath: string) {
397
405
  const from = activeTab.value;
398
- /* {@link receivingPane}, not `sidePane`: the pane beside this one may be a LENS, which owns no
406
+ /* {@link receivingPane}, not `paneBeside`: the pane beside this one may be a LENS, which owns no
399
407
  tab. The open then landed in a `tabOrder` `tabOfPane` hops straight past, so the read below got
400
408
  the SOURCE tab back, `opened.documentPath !== componentPath`, and the one relationship this
401
409
  function exists to record (§14.2) was skipped without a sound. */
402
- const target = receivingPane();
410
+ const target = receivingPane(activePane().id);
403
411
  const alreadyOpen = [...workspace.tabs.values()].some((t) => t.documentPath === componentPath);
404
- await openFileInTab(componentPath, { focus: false, paneId: target.id, preview: true });
412
+ await openFileInTab(componentPath, { paneId: target.id, preview: true });
405
413
  const opened = tabOfPane(target.id);
406
414
  if (!alreadyOpen && from && opened && opened.documentPath === componentPath) {
407
415
  opened.session.openedFrom = { documentPath: from.documentPath, tabId: from.id };
@@ -1497,9 +1505,17 @@ const liveChrome = createLiveSurfaceSaver({
1497
1505
  redefineOnCanvases: redefineElementOnCanvases,
1498
1506
  workspace,
1499
1507
  });
1508
+ /* The third is the component registry: a saved definition rewrites its own entry, so an instance's
1509
+ Component Settings and the Library read the props the file has now rather than the ones the scan
1510
+ saw when the project opened. The Content tab and the Library are told, because neither reads the
1511
+ registry reactively. */
1500
1512
  setDocumentSavedListener((path, doc) => {
1501
1513
  void noteFileSaved(path);
1502
1514
  void liveChrome(path, doc as unknown as JxDocument);
1515
+ if (noteComponentSaved(path, doc)) {
1516
+ renderPropertiesPanel();
1517
+ renderLeftPanel();
1518
+ }
1503
1519
  });
1504
1520
  /* Walking a comparison. No deps: the stepper reads the pane-keyed diff store directly and the
1505
1521
  toolbar redraws itself, so there is nothing for the bootstrap to inject. */
@@ -21,9 +21,6 @@
21
21
  "flex": "1",
22
22
  "flexShrink": "999"
23
23
  },
24
- "& jx-button, & jx-action-button, & button": {
25
- "WebkitAppRegion": "no-drag"
26
- },
27
24
  "& [part=\"csd-lead\"], & [part=\"csd-trail\"], & [part=\"menu-slot\"], & [part=\"layouts-slot\"], & [part=\"presence-slot\"]": {
28
25
  "display": "contents"
29
26
  },
@@ -78,7 +75,7 @@
78
75
  "gap": "2px",
79
76
  "flexShrink": "0"
80
77
  },
81
- "& [part=\"layout\"], & [part=\"layout-add\"]": {
78
+ "& [part=\"layout\"]": {
82
79
  "padding": "2px var(--jx-space-3)",
83
80
  "border": "none",
84
81
  "borderRadius": "var(--jx-radius-sm)",
@@ -101,9 +98,6 @@
101
98
  "color": "var(--jx-fg)",
102
99
  "background": "var(--jx-hover-bg)"
103
100
  },
104
- "& [part=\"layout-add\"]": {
105
- "padding": "2px var(--jx-space-2)"
106
- },
107
101
  "& [part=\"center\"]": {
108
102
  "display": "flex",
109
103
  "alignItems": "center",
@@ -267,7 +261,8 @@
267
261
  "size": "sm"
268
262
  },
269
263
  "attributes": {
270
- "data-control": "close"
264
+ "data-control": "close",
265
+ "class": "electrobun-webkit-app-region-no-drag"
271
266
  },
272
267
  "onclick": {
273
268
  "$prototype": "Function",
@@ -291,7 +286,8 @@
291
286
  "size": "sm"
292
287
  },
293
288
  "attributes": {
294
- "data-control": "minimize"
289
+ "data-control": "minimize",
290
+ "class": "electrobun-webkit-app-region-no-drag"
295
291
  },
296
292
  "onclick": {
297
293
  "$prototype": "Function",
@@ -315,7 +311,8 @@
315
311
  "size": "sm"
316
312
  },
317
313
  "attributes": {
318
- "data-control": "maximize"
314
+ "data-control": "maximize",
315
+ "class": "electrobun-webkit-app-region-no-drag"
319
316
  },
320
317
  "onclick": {
321
318
  "$prototype": "Function",
@@ -357,7 +354,8 @@
357
354
  }
358
355
  },
359
356
  "attributes": {
360
- "data-menu": "studio"
357
+ "data-menu": "studio",
358
+ "class": "electrobun-webkit-app-region-no-drag"
361
359
  },
362
360
  "onclick": {
363
361
  "$prototype": "Function",
@@ -441,7 +439,8 @@
441
439
  "aria-selected": "${$map.item.active ? 'true' : 'false'}",
442
440
  "data-layout": "${$map.item.id}",
443
441
  "data-active": "${$map.item.active ? '' : null}",
444
- "title": "${$map.item.title}"
442
+ "title": "${$map.item.title}",
443
+ "class": "electrobun-webkit-app-region-no-drag"
445
444
  },
446
445
  "textContent": "${$map.item.name}",
447
446
  "onclick": {
@@ -479,14 +478,17 @@
479
478
  }
480
479
  },
481
480
  {
482
- "tagName": "button",
481
+ "tagName": "jx-action-button",
482
+ "$props": {
483
+ "label": "Save layout",
484
+ "hint": "Save the current arrangement as a layout",
485
+ "icon": "plus",
486
+ "size": "sm"
487
+ },
483
488
  "attributes": {
484
489
  "part": "layout-add",
485
- "type": "button",
486
- "title": "Save the current arrangement as a layout",
487
- "aria-label": "Save layout"
490
+ "class": "electrobun-webkit-app-region-no-drag"
488
491
  },
489
- "textContent": "+",
490
492
  "onclick": {
491
493
  "$prototype": "Function",
492
494
  "body": [
@@ -515,7 +517,8 @@
515
517
  "attributes": {
516
518
  "part": "center",
517
519
  "role": "group",
518
- "aria-label": "Command Center"
520
+ "aria-label": "Command Center",
521
+ "class": "electrobun-webkit-app-region-no-drag"
519
522
  },
520
523
  "onclick": {
521
524
  "$prototype": "Function",
@@ -582,7 +585,8 @@
582
585
  "part": "segment",
583
586
  "type": "button",
584
587
  "data-segment": "${$map.item.key}",
585
- "title": "${$map.item.title}"
588
+ "title": "${$map.item.title}",
589
+ "class": "electrobun-webkit-app-region-no-drag"
586
590
  },
587
591
  "textContent": "${$map.item.label}",
588
592
  "onclick": {
@@ -657,6 +661,9 @@
657
661
  "label": {
658
662
  "$ref": "$map/item/title"
659
663
  },
664
+ "hint": {
665
+ "$ref": "$map/item/tooltip"
666
+ },
660
667
  "disabled": {
661
668
  "$ref": "$map/item/disabled"
662
669
  },
@@ -665,7 +672,7 @@
665
672
  },
666
673
  "attributes": {
667
674
  "data-command-id": "${$map.item.id}",
668
- "title": "${$map.item.tooltip}"
675
+ "class": "electrobun-webkit-app-region-no-drag"
669
676
  },
670
677
  "onclick": {
671
678
  "$prototype": "Function",
@@ -864,7 +871,8 @@
864
871
  "size": "sm"
865
872
  },
866
873
  "attributes": {
867
- "data-command-id": "${$map.item.id}"
874
+ "data-command-id": "${$map.item.id}",
875
+ "class": "electrobun-webkit-app-region-no-drag"
868
876
  },
869
877
  "onchange": {
870
878
  "$prototype": "Function",
@@ -913,7 +921,8 @@
913
921
  "size": "sm"
914
922
  },
915
923
  "attributes": {
916
- "data-control": "minimize"
924
+ "data-control": "minimize",
925
+ "class": "electrobun-webkit-app-region-no-drag"
917
926
  },
918
927
  "onclick": {
919
928
  "$prototype": "Function",
@@ -937,7 +946,8 @@
937
946
  "size": "sm"
938
947
  },
939
948
  "attributes": {
940
- "data-control": "maximize"
949
+ "data-control": "maximize",
950
+ "class": "electrobun-webkit-app-region-no-drag"
941
951
  },
942
952
  "onclick": {
943
953
  "$prototype": "Function",
@@ -961,7 +971,8 @@
961
971
  "size": "sm"
962
972
  },
963
973
  "attributes": {
964
- "data-control": "close"
974
+ "data-control": "close",
975
+ "class": "electrobun-webkit-app-region-no-drag"
965
976
  },
966
977
  "onclick": {
967
978
  "$prototype": "Function",