@jxsuite/studio 5.0.0 → 5.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 (94) hide show
  1. package/dist/chunks/{cf-account-picker-fyf63pmy.js → cf-account-picker-qwkm9zrf.js} +5 -5
  2. package/dist/chunks/{components-2w1f35qs.js → components-vj72gqtq.js} +6 -4
  3. package/dist/chunks/{content-source-3wqeas45.js → content-source-5mex4x1h.js} +20 -20
  4. package/dist/chunks/{convert-to-repeater-d0cgnb6d.js → convert-to-repeater-n64h8znc.js} +20 -20
  5. package/dist/chunks/entry-commands-234hjnqw.js +42 -0
  6. package/dist/chunks/{extension-sections-fy7q4fe9.js → extension-sections-3hpcambp.js} +22 -22
  7. package/dist/chunks/{file-ops-3btj6st0.js → file-ops-8yhd83xr.js} +19 -19
  8. package/dist/chunks/{files-ef8fgs3w.js → files-hnxqxtsg.js} +26 -26
  9. package/dist/chunks/{format-host-3c33nwr8.js → format-host-779sfp2n.js} +2 -2
  10. package/dist/chunks/{fs-events-13tj6sex.js → fs-events-b88z7g4h.js} +4 -4
  11. package/dist/chunks/{git-panel-4wp2rn9b.js → git-panel-xwj8fpab.js} +18 -18
  12. package/dist/chunks/{iframe-host-jgesd924.js → iframe-host-syr0f9sb.js} +9 -9
  13. package/dist/chunks/{layers-1nq5evsh.js → layers-ff81wthn.js} +5 -4
  14. package/dist/chunks/layers-panel-pnnghw12.js +40 -0
  15. package/dist/chunks/{media-picker-2qbetq73.js → media-picker-cqhhxhhe.js} +10 -10
  16. package/dist/chunks/menu-9p2w46gj.js +15 -0
  17. package/dist/chunks/{publish-panel-98f7sr3g.js → publish-panel-5zt840sy.js} +9 -9
  18. package/dist/chunks/quick-search-3748564r.js +45 -0
  19. package/dist/chunks/{references-n553f0e1.js → references-qvvxej7h.js} +3 -3
  20. package/dist/chunks/right-panel-eccz92qt.js +45 -0
  21. package/dist/chunks/{studio-x3rty6tr.js → studio-1knfqgy7.js} +6 -6
  22. package/dist/chunks/{studio-rkstz940.js → studio-1z1kcjv4.js} +17 -8
  23. package/dist/chunks/{studio-qahxvnan.js → studio-3773086z.js} +2 -2
  24. package/dist/chunks/{studio-arfpe389.js → studio-3rz5md06.js} +4 -4
  25. package/dist/chunks/{studio-pm6hgy7r.js → studio-5wt9nvhd.js} +16 -16
  26. package/dist/chunks/{studio-akbww0j5.js → studio-7xnmgv66.js} +19 -19
  27. package/dist/chunks/{studio-26d7myd1.js → studio-8scdew0e.js} +3 -3
  28. package/dist/chunks/{studio-3hxkd92t.js → studio-93zafjcm.js} +8 -8
  29. package/dist/chunks/{studio-5rt7sqa0.js → studio-a79k90n9.js} +11 -17
  30. package/dist/chunks/{studio-3gxv60tn.js → studio-aj37eer3.js} +3 -3
  31. package/dist/chunks/{studio-18avnhjp.js → studio-b0k5tqgp.js} +23 -21
  32. package/dist/chunks/{studio-fabxk6r9.js → studio-b4s9eykd.js} +13 -13
  33. package/dist/chunks/{studio-8qtrpzr3.js → studio-e1pgga3k.js} +5 -5
  34. package/dist/chunks/{studio-8jn5yhyn.js → studio-g3jevyjt.js} +2 -2
  35. package/dist/chunks/{studio-rpyah2ew.js → studio-gb13095a.js} +18 -17
  36. package/dist/chunks/{studio-57mf66w2.js → studio-js8nv53n.js} +5 -5
  37. package/dist/chunks/{studio-vz68yn9e.js → studio-jxp12rnj.js} +4 -4
  38. package/dist/chunks/{studio-az3zd49t.js → studio-k75n7vhp.js} +56 -4
  39. package/dist/chunks/{studio-5jy81t3s.js → studio-kkqkcp3d.js} +221 -16
  40. package/dist/chunks/{studio-tb3ev01j.js → studio-mhhyamxf.js} +4 -4
  41. package/dist/chunks/{studio-81390n6f.js → studio-nq5yf5mp.js} +1 -1
  42. package/dist/chunks/{studio-4m0haseg.js → studio-nzzpvzx9.js} +3 -3
  43. package/dist/chunks/{studio-em9nw8eh.js → studio-pxbgypmg.js} +12 -12
  44. package/dist/chunks/{studio-sgdc5pfc.js → studio-qhhm6hbt.js} +3 -3
  45. package/dist/chunks/{studio-d1ja2x9r.js → studio-qk4bfsq2.js} +3 -3
  46. package/dist/chunks/{studio-25hkdrkw.js → studio-qk90fgqm.js} +9 -9
  47. package/dist/chunks/{studio-s6z853aq.js → studio-r6nvacqs.js} +18 -17
  48. package/dist/chunks/{studio-7xptrg0j.js → studio-sbjyc8bt.js} +6 -6
  49. package/dist/chunks/{studio-5523bpzt.js → studio-syg2tadj.js} +3 -3
  50. package/dist/chunks/{studio-cpvb2ed1.js → studio-tqgryxwg.js} +7 -7
  51. package/dist/chunks/{studio-xsykpvsh.js → studio-try13h8c.js} +11 -11
  52. package/dist/chunks/{studio-erdatzvp.js → studio-vfq8qpjt.js} +34 -5
  53. package/dist/chunks/{studio-79y5sz51.js → studio-vw7mzp6z.js} +2 -2
  54. package/dist/chunks/{studio-9xxz1q69.js → studio-wqz6dggm.js} +5 -5
  55. package/dist/chunks/studio-zdn2g6dt.js +148 -0
  56. package/dist/chunks/{workspace-wrew8zk4.js → workspace-t55jdjbx.js} +3 -3
  57. package/dist/iframe-entry.js +97 -10
  58. package/dist/studio.js +127 -244
  59. package/package.json +6 -6
  60. package/src/canvas/canvas-utils.ts +29 -2
  61. package/src/commands/command-args.ts +37 -2
  62. package/src/commands/registry.ts +18 -8
  63. package/src/commands/run-reported.ts +120 -0
  64. package/src/editor/shortcuts.ts +11 -23
  65. package/src/files/components.ts +39 -0
  66. package/src/files/files.ts +3 -2
  67. package/src/i18n/i18n-commands.ts +10 -0
  68. package/src/packages/pull-package-sync.ts +5 -1
  69. package/src/panels/ai-panel.ts +5 -2
  70. package/src/panels/block-action-bar.ts +7 -2
  71. package/src/panels/i18n-panel.ts +8 -15
  72. package/src/panels/jump-bar.ts +3 -2
  73. package/src/panels/problems-panel.ts +6 -2
  74. package/src/panels/quick-search.ts +6 -12
  75. package/src/panels/settings-menu.ts +5 -15
  76. package/src/panels/tab-strip.ts +3 -3
  77. package/src/services/ai-command-tools.ts +78 -8
  78. package/src/services/ai-project-tools.ts +8 -2
  79. package/src/services/ai-system-prompt.ts +4 -8
  80. package/src/services/ai-tools.ts +8 -69
  81. package/src/services/document-assistant.ts +9 -6
  82. package/src/studio.ts +14 -2
  83. package/src/surfaces/commandbar.json +13 -12
  84. package/src/surfaces/commandbar.ts +6 -5
  85. package/src/surfaces/statusbar.ts +2 -1
  86. package/src/tabs/project-config.ts +45 -11
  87. package/src/ui/layers.ts +4 -1
  88. package/src/workspace/workspace.ts +60 -0
  89. package/dist/chunks/entry-commands-d6g50j7s.js +0 -42
  90. package/dist/chunks/layers-panel-ar2gh90g.js +0 -40
  91. package/dist/chunks/menu-rf7n72mf.js +0 -14
  92. package/dist/chunks/quick-search-srzz83k2.js +0 -45
  93. package/dist/chunks/right-panel-rrgx2dn3.js +0 -45
  94. package/dist/chunks/studio-d055c37e.js +0 -62
@@ -58,6 +58,7 @@
58
58
  import { nothing } from "lit-html";
59
59
  import { effect, effectScope } from "../reactivity";
60
60
  import { activeRegistry } from "../commands/active-registry";
61
+ import { runReported } from "../commands/run-reported";
61
62
  import { clearProblems, dismiss, problemCount, problems } from "../services/notify";
62
63
  import { mountProblemsSurface } from "../surfaces/panel-problems";
63
64
  import { registerPanel } from "./panel-registry";
@@ -184,14 +185,17 @@ const ACTIONS: ProblemsActions = {
184
185
  openProblemPath(path);
185
186
  },
186
187
  /* By id, because a document can only hand back a value: the record and the registry are both
187
- looked up at the moment of the click, which is also the moment the gate was last checked. */
188
+ looked up at the moment of the click, which is also the moment the gate was last checked.
189
+ The action's `actionArgs` were written by whoever filed the problem, against a schema that
190
+ may have moved since, so the run goes through `commands/run-reported.ts`: a refusal is a new
191
+ row in this same list, keyed by the command, rather than an exception out of the button. */
188
192
  runAction: (id) => {
189
193
  const record = problems.find((candidate) => candidate.id === id);
190
194
  const registry = activeRegistry();
191
195
  if (record?.action === undefined || !registry) {
192
196
  return;
193
197
  }
194
- void registry.run(record.action, record.actionArgs);
198
+ void runReported(registry, record.action, record.actionArgs, "Problems");
195
199
  },
196
200
  };
197
201
 
@@ -41,6 +41,7 @@ import { reactive } from "../reactivity";
41
41
  import paletteDoc from "../surfaces/palette.json";
42
42
  import { activeTab } from "../workspace/workspace";
43
43
  import { activeRegistry } from "../commands/active-registry";
44
+ import { runActiveReported, runReported } from "../commands/run-reported";
44
45
  import type { PaletteMode } from "../commands/defaults";
45
46
  import type { AnyCommand, CommandRegistry } from "../commands/registry";
46
47
  import type { JxDocument, JxMutableNode } from "@jxsuite/schema/types";
@@ -671,17 +672,11 @@ function runCommand(command: AnyCommand, args?: Record<string, unknown>) {
671
672
  }
672
673
  trackRecentCommand(command.id);
673
674
  closeQuickSearch();
674
- const report = (error: unknown) => {
675
- console.error(`palette: command "${command.id}" failed`, error);
676
- };
677
- // Both shapes, deliberately: `run` may throw synchronously (a coercion refusal from
675
+ // Both shapes, through the one helper: `run` may throw synchronously (a coercion refusal from
678
676
  // `command-args.ts` does) or reject later. A palette that let either escape would take the
679
- // Keydown listener down with it, and the overlay is already closed by then.
680
- try {
681
- void Promise.resolve(registry.run(command.id, args)).catch(report);
682
- } catch (error) {
683
- report(error);
684
- }
677
+ // Keydown listener down with it, and the overlay is already closed by then — and a refusal that
678
+ // Went to the console, as this one did, was read by nobody. Problems is where it is read.
679
+ void runReported(registry, command.id, args, "Palette");
685
680
  }
686
681
 
687
682
  function selectRow(row: PaletteRow) {
@@ -702,9 +697,8 @@ function selectRow(row: PaletteRow) {
702
697
  return;
703
698
  }
704
699
  case "node": {
705
- const registry = activeRegistry();
706
700
  closeQuickSearch();
707
- void registry?.run("selection.set", { path: row.path });
701
+ void runActiveReported("selection.set", { path: row.path }, "Palette");
708
702
  return;
709
703
  }
710
704
  case "arg": {
@@ -36,10 +36,9 @@
36
36
  * @docs studio/projects/settings
37
37
  */
38
38
 
39
- import { errorMessage } from "@jxsuite/schema/parse";
40
39
  import { activeRegistry } from "../commands/active-registry";
40
+ import { runReported } from "../commands/run-reported";
41
41
  import { LEVELS } from "../commands/levels";
42
- import { notify } from "../services/notify";
43
42
  import { onSettingsDocumentChanged, sortedSettingsSections } from "../settings/section-registry";
44
43
  import { PREFERENCES_SECTIONS } from "../settings/preferences-sections";
45
44
  import { openMenu } from "../surfaces/menu";
@@ -92,25 +91,16 @@ function menuSectionsFor(id: string): readonly MenuSection[] {
92
91
  * Run a row's command, surfacing a refusal instead of stranding it.
93
92
  *
94
93
  * `settings.open`'s `run` is async and throws a `RangeError` AFTER awaiting the contributed-section
95
- * sync, and `registry.run` does not catch. `editor/context-menu.ts`'s bare `void result` is safe
96
- * only because every element verb is synchronous; here an unknown section would reject into
97
- * nothing.
94
+ * sync, and `registry.run` does not catch; a gate that turned false between the render and the
95
+ * click throws BEFORE `run`, synchronously. `commands/run-reported.ts` catches both and files the
96
+ * sentence in Problems, under this menu's name.
98
97
  */
99
98
  function runRow(id: string, args?: Record<string, unknown>): void {
100
99
  const registry = activeRegistry();
101
100
  if (!registry) {
102
101
  return;
103
102
  }
104
- let result: unknown;
105
- try {
106
- result = registry.run(id, args as never);
107
- } catch (error) {
108
- notify.error(errorMessage(error), { source: id });
109
- return;
110
- }
111
- void Promise.resolve(result).catch((error: unknown) => {
112
- notify.error(errorMessage(error), { source: id });
113
- });
103
+ void runReported(registry, id, args, "Settings");
114
104
  }
115
105
 
116
106
  // ─── Rows ─────────────────────────────────────────────────────────────────────
@@ -60,6 +60,7 @@ import { DRAFT_FIELD, isDraftEntry } from "../content/draft-state";
60
60
  import { entryFields } from "../content/entry-fields";
61
61
  import { collectionOfPath } from "../content/entry-model";
62
62
  import { activeRegistry } from "../commands/active-registry";
63
+ import { runActiveReported, runReported } from "../commands/run-reported";
63
64
  import { PRESET_LABELS } from "../workspace/pane-derive";
64
65
  import { localeLabel } from "@jxsuite/schema/locale";
65
66
  import { tabOfPane } from "../canvas/canvas-surface";
@@ -575,8 +576,7 @@ function openTrailing(host: HTMLElement, anchor: HTMLElement | null) {
575
576
  return;
576
577
  }
577
578
  if (_drawing.get(host)?.mode === "derivation") {
578
- const registry = activeRegistry();
579
- void registry?.run("pane.unsplit");
579
+ void runActiveReported("pane.unsplit", undefined, "Tabs");
580
580
  return;
581
581
  }
582
582
  openOverflowMenu(pane, tabLabels(pane), anchor);
@@ -807,7 +807,7 @@ function placedTabItems(tab: Tab): MenuRowProjection[] {
807
807
  ? {
808
808
  disabled: false,
809
809
  run: () => {
810
- void registry.run(command.id, args);
810
+ void runReported(registry, command.id, args, "Tabs");
811
811
  },
812
812
  }
813
813
  : { disabled: true, requires: reason }),
@@ -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) {
@@ -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
@@ -160,7 +160,7 @@ import {
160
160
  mountSignalsPanel,
161
161
  registerSignalsCommands,
162
162
  } from "./panels/signals-panel";
163
- import { loadComponentRegistry } from "./files/components";
163
+ import { loadComponentRegistry, noteComponentSaved } from "./files/components";
164
164
  import { ensureDependenciesInstalled } from "./packages/ensure-deps";
165
165
  import { maybePromptJxsuiteUpdate } from "./packages/jxsuite-update";
166
166
  import { autoSyncProjectOnOpen } from "./packages/pull-package-sync";
@@ -234,7 +234,11 @@ import {
234
234
  platformUsesRepoPicker,
235
235
  } from "./new-project/add-repo-modal";
236
236
  import { openNewProjectModal, registerNewProjectCommands } from "./new-project/new-project-modal";
237
- import { invalidatePageRouteCache, registerInspectorCommands } from "./panels/properties-panel";
237
+ import {
238
+ invalidatePageRouteCache,
239
+ registerInspectorCommands,
240
+ renderPropertiesPanel,
241
+ } from "./panels/properties-panel";
238
242
  import { liveElementCommands, setContextMenuNavigate } from "./editor/context-menu";
239
243
  import { registerSeoCommands, renderSeoModal } from "./panels/seo-modal";
240
244
  import { ensurePopoverRevealWatch, setOpenPopover } from "./canvas/popover-state";
@@ -1497,9 +1501,17 @@ const liveChrome = createLiveSurfaceSaver({
1497
1501
  redefineOnCanvases: redefineElementOnCanvases,
1498
1502
  workspace,
1499
1503
  });
1504
+ /* The third is the component registry: a saved definition rewrites its own entry, so an instance's
1505
+ Component Settings and the Library read the props the file has now rather than the ones the scan
1506
+ saw when the project opened. The Content tab and the Library are told, because neither reads the
1507
+ registry reactively. */
1500
1508
  setDocumentSavedListener((path, doc) => {
1501
1509
  void noteFileSaved(path);
1502
1510
  void liveChrome(path, doc as unknown as JxDocument);
1511
+ if (noteComponentSaved(path, doc)) {
1512
+ renderPropertiesPanel();
1513
+ renderLeftPanel();
1514
+ }
1503
1515
  });
1504
1516
  /* Walking a comparison. No deps: the stepper reads the pane-keyed diff store directly and the
1505
1517
  toolbar redraws itself, so there is nothing for the bootstrap to inject. */
@@ -78,7 +78,7 @@
78
78
  "gap": "2px",
79
79
  "flexShrink": "0"
80
80
  },
81
- "& [part=\"layout\"], & [part=\"layout-add\"]": {
81
+ "& [part=\"layout\"]": {
82
82
  "padding": "2px var(--jx-space-3)",
83
83
  "border": "none",
84
84
  "borderRadius": "var(--jx-radius-sm)",
@@ -101,9 +101,6 @@
101
101
  "color": "var(--jx-fg)",
102
102
  "background": "var(--jx-hover-bg)"
103
103
  },
104
- "& [part=\"layout-add\"]": {
105
- "padding": "2px var(--jx-space-2)"
106
- },
107
104
  "& [part=\"center\"]": {
108
105
  "display": "flex",
109
106
  "alignItems": "center",
@@ -479,14 +476,16 @@
479
476
  }
480
477
  },
481
478
  {
482
- "tagName": "button",
479
+ "tagName": "jx-action-button",
480
+ "$props": {
481
+ "label": "Save layout",
482
+ "hint": "Save the current arrangement as a layout",
483
+ "icon": "plus",
484
+ "size": "sm"
485
+ },
483
486
  "attributes": {
484
- "part": "layout-add",
485
- "type": "button",
486
- "title": "Save the current arrangement as a layout",
487
- "aria-label": "Save layout"
487
+ "part": "layout-add"
488
488
  },
489
- "textContent": "+",
490
489
  "onclick": {
491
490
  "$prototype": "Function",
492
491
  "body": [
@@ -657,6 +656,9 @@
657
656
  "label": {
658
657
  "$ref": "$map/item/title"
659
658
  },
659
+ "hint": {
660
+ "$ref": "$map/item/tooltip"
661
+ },
660
662
  "disabled": {
661
663
  "$ref": "$map/item/disabled"
662
664
  },
@@ -664,8 +666,7 @@
664
666
  "quiet": true
665
667
  },
666
668
  "attributes": {
667
- "data-command-id": "${$map.item.id}",
668
- "title": "${$map.item.tooltip}"
669
+ "data-command-id": "${$map.item.id}"
669
670
  },
670
671
  "onclick": {
671
672
  "$prototype": "Function",
@@ -43,6 +43,7 @@ import { armPreviewOverlay, flushPreviewOverlay } from "../preview/preview-overl
43
43
  import { documentUrlPattern, dynamicRouteParams } from "../page-params";
44
44
  import { getNodeAtPath, nodeLabel, projectState } from "../store";
45
45
  import { activeRegistry } from "../commands/active-registry";
46
+ import { runActiveReported, runReported } from "../commands/run-reported";
46
47
  import { notify } from "../services/notify";
47
48
  import { mountSurface, registerSurface } from "../ui/surface";
48
49
  import { rectOf } from "../utils/geometry";
@@ -521,7 +522,7 @@ export async function saveLayoutPrompt(registry: CommandRegistry): Promise<void>
521
522
  placeholder: "Layout name",
522
523
  });
523
524
  if (name) {
524
- await registry.run("view.saveLayout", { name });
525
+ await runReported(registry, "view.saveLayout", { name }, "Command Bar");
525
526
  }
526
527
  }
527
528
 
@@ -533,7 +534,7 @@ export async function renameLayoutPrompt(
533
534
  ): Promise<void> {
534
535
  const name = await showPromptDialog("Rename layout", { confirmLabel: "Rename", value: current });
535
536
  if (name) {
536
- await registry.run("view.renameLayout", { layout, name });
537
+ await runReported(registry, "view.renameLayout", { layout, name }, "Command Bar");
537
538
  }
538
539
  }
539
540
 
@@ -707,7 +708,7 @@ function openStudioMenu(opener: HTMLElement): void {
707
708
  rows: overflowRows(registry),
708
709
  run: (id) => {
709
710
  if (registry.isEnabled(id)) {
710
- void registry.run(id);
711
+ void runReported(registry, id, undefined, "Command Bar");
711
712
  }
712
713
  },
713
714
  });
@@ -751,7 +752,7 @@ function state(): CommandBarScope {
751
752
  run: (id) => {
752
753
  const registry = activeRegistry();
753
754
  if (registry?.isEnabled(id)) {
754
- void registry.run(id);
755
+ void runReported(registry, id, undefined, "Command Bar");
755
756
  }
756
757
  },
757
758
  saveLayout: () => {
@@ -762,7 +763,7 @@ function state(): CommandBarScope {
762
763
  },
763
764
  segments: [],
764
765
  setLayout: (id) => {
765
- void activeRegistry()?.run("view.setLayout", { layout: id });
766
+ void runActiveReported("view.setLayout", { layout: id }, "Command Bar");
766
767
  },
767
768
  windowControl: (action) => {
768
769
  windowControls()?.[action]();