@jxsuite/studio 8.0.1 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/dist/chunks/{2020-5wtzce5g.js → 2020-89fz3pfy.js} +3 -3
  2. package/dist/chunks/adapter-ay13gema.js +4 -0
  3. package/dist/chunks/add-new-flow-nmkqpjym.js +4 -0
  4. package/dist/chunks/cf-account-picker-2e06qxey.js +4 -0
  5. package/dist/chunks/cf-settings-5y04810c.js +4 -0
  6. package/dist/chunks/color-04361ttk.js +4 -0
  7. package/dist/chunks/{color-area-6rbra5dy.js → color-area-h8dbasp1.js} +2 -2
  8. package/dist/chunks/{color-field-gy56c94g.js → color-field-qv5r2b16.js} +3 -3
  9. package/dist/chunks/{color-slider-sqqdyf6c.js → color-slider-nz2mrzky.js} +2 -2
  10. package/dist/chunks/{combobox-72h32ejk.js → combobox-6hs00996.js} +2 -2
  11. package/dist/chunks/components-3vf75n83.js +4 -0
  12. package/dist/chunks/content-source-4f2raf4a.js +4 -0
  13. package/dist/chunks/{convert-to-repeater-he70fc2b.js → convert-to-repeater-j9hfcj4f.js} +3 -3
  14. package/dist/chunks/editor-645krvtz.js +4 -0
  15. package/dist/chunks/entry-commands-3qmv8wkr.js +4 -0
  16. package/dist/chunks/{extension-sections-0egb88dm.js → extension-sections-thfke054.js} +3 -3
  17. package/dist/chunks/file-ops-kkhp9ma7.js +4 -0
  18. package/dist/chunks/files-yswdhxrf.js +4 -0
  19. package/dist/chunks/format-host-4g8ex6bz.js +4 -0
  20. package/dist/chunks/fs-events-z9gpvc6t.js +4 -0
  21. package/dist/chunks/grid-open-fm4j183c.js +4 -0
  22. package/dist/chunks/{grid-view-qg1b5eqx.js → grid-view-7d2nx83y.js} +16 -15
  23. package/dist/chunks/{icons-63bjtbs7.js → icons-853n6qt3.js} +2 -2
  24. package/dist/chunks/{iframe-entry-pkgmcgp0.js → iframe-entry-2x9bn5ng.js} +4 -4
  25. package/dist/chunks/{iframe-entry-99nr5zfb.js → iframe-entry-42hpmwz5.js} +4 -4
  26. package/dist/chunks/{iframe-entry-76j8vwwx.js → iframe-entry-b5qgggyp.js} +2 -2
  27. package/dist/chunks/{iframe-entry-2zggzajv.js → iframe-entry-btck0e9s.js} +4 -4
  28. package/dist/chunks/iframe-host-q7fedsmr.js +4 -0
  29. package/dist/chunks/{index-32eqzv1k.js → index-jxmt7vh8.js} +3 -3
  30. package/dist/chunks/index-pt4wh98x.js +4 -0
  31. package/dist/chunks/{javascript-pjp734q4.js → javascript-vmqp56ke.js} +2 -2
  32. package/dist/chunks/{jsonMode-njkvsa02.js → jsonMode-n3wmpant.js} +5 -5
  33. package/dist/chunks/jx-validate-19y62knt.js +4 -0
  34. package/dist/chunks/layers-panel-1qvc83y9.js +4 -0
  35. package/dist/chunks/layers-qf0bc7ze.js +4 -0
  36. package/dist/chunks/{listbox-c421cf8c.js → listbox-7gka1h86.js} +2 -2
  37. package/dist/chunks/lit-html-wyqw04y2.js +4 -0
  38. package/dist/chunks/media-library-f9d5qcjb.js +4 -0
  39. package/dist/chunks/media-picker-rsb1fmk2.js +4 -0
  40. package/dist/chunks/{menu-bdpzbfvs.js → menu-d2jxpgma.js} +2 -2
  41. package/dist/chunks/{monaco-binding-vq1hgnn0.js → monaco-binding-wfhmz89d.js} +3 -3
  42. package/dist/chunks/monaco-buffer-ak0raadw.js +4 -0
  43. package/dist/chunks/{monaco-setup-xrd2cbkp.js → monaco-setup-fty7a8vg.js} +3 -3
  44. package/dist/chunks/notify-4qj70q0x.js +4 -0
  45. package/dist/chunks/parse-n8dfxnqf.js +4 -0
  46. package/dist/chunks/platform-ehg6t9vd.js +4 -0
  47. package/dist/chunks/{popover-b89sfej7.js → popover-9tf4cjrk.js} +2 -2
  48. package/dist/chunks/publish-panel-yhmda3cd.js +4 -0
  49. package/dist/chunks/{publish-quick-qx2455cj.js → publish-quick-12hh8926.js} +3 -3
  50. package/dist/chunks/publish-workspace-hx8qdv9p.js +4 -0
  51. package/dist/chunks/quick-search-r955m3q1.js +4 -0
  52. package/dist/chunks/references-8gxv0x7t.js +4 -0
  53. package/dist/chunks/right-panel-jarwqjpy.js +4 -0
  54. package/dist/chunks/source-control-84qt25m9.js +4 -0
  55. package/dist/chunks/{split-c9zfdgxf.js → split-m5rwn31z.js} +2 -2
  56. package/dist/chunks/studio-0nefxhar.js +7 -0
  57. package/dist/chunks/studio-0rg30pvs.js +5 -0
  58. package/dist/chunks/{studio-s32578pn.js → studio-14hh60j8.js} +4 -4
  59. package/dist/chunks/studio-1cfyd1ys.js +5 -0
  60. package/dist/chunks/studio-2bq7hheq.js +5 -0
  61. package/dist/chunks/{studio-zz9qe3rw.js → studio-32d8wyxq.js} +37 -37
  62. package/dist/chunks/studio-3hpcawh8.js +4 -0
  63. package/dist/chunks/{studio-svb7yyve.js → studio-4j35hgsw.js} +4 -4
  64. package/dist/chunks/{studio-x1x167jh.js → studio-4qg61g9j.js} +4 -4
  65. package/dist/chunks/studio-4rgem12q.js +5 -0
  66. package/dist/chunks/studio-6k9djnxw.js +5 -0
  67. package/dist/chunks/studio-6rbaedw2.js +9 -0
  68. package/dist/chunks/studio-732mw0g8.js +5 -0
  69. package/dist/chunks/studio-79r8msnr.js +7 -0
  70. package/dist/chunks/studio-7q6jdd4h.js +5 -0
  71. package/dist/chunks/{studio-r5qy30vx.js → studio-8hf8qcm1.js} +4 -4
  72. package/dist/chunks/{studio-8tr6cfrc.js → studio-8pne0rx4.js} +4 -4
  73. package/dist/chunks/{studio-y0n7yxea.js → studio-9179bsm7.js} +7 -7
  74. package/dist/chunks/{studio-pbxa5fpc.js → studio-9ff0vqff.js} +3 -3
  75. package/dist/chunks/studio-9gzvypw4.js +5 -0
  76. package/dist/chunks/studio-9ndgd55w.js +5 -0
  77. package/dist/chunks/studio-9ra9e7m4.js +5 -0
  78. package/dist/chunks/studio-a6e5fzve.js +5 -0
  79. package/dist/chunks/studio-a9k86nq6.js +5 -0
  80. package/dist/chunks/studio-ae7a69jc.js +6 -0
  81. package/dist/chunks/studio-aghsg0ds.js +7 -0
  82. package/dist/chunks/{studio-k3xxbagr.js → studio-ajw4k8hp.js} +3 -3
  83. package/dist/chunks/{studio-qmn1xc24.js → studio-apapy17g.js} +4 -4
  84. package/dist/chunks/{studio-8m0y2qe7.js → studio-by8x0jvc.js} +4 -4
  85. package/dist/chunks/{studio-wbcjbhy1.js → studio-c88gq88j.js} +5 -5
  86. package/dist/chunks/{studio-n0c5h6mf.js → studio-cg54vtet.js} +4 -4
  87. package/dist/chunks/studio-dvhxxxbd.js +5 -0
  88. package/dist/chunks/{studio-3q40e3rj.js → studio-e4rq6x0q.js} +4 -4
  89. package/dist/chunks/{studio-fbhxxc5f.js → studio-ekzbgnjp.js} +4 -4
  90. package/dist/chunks/studio-fg4fj9ec.js +10 -0
  91. package/dist/chunks/studio-fj6jzgxw.js +5 -0
  92. package/dist/chunks/{studio-nh6d342n.js → studio-g45r5vm8.js} +7 -7
  93. package/dist/chunks/{studio-a2bvaptz.js → studio-gczve8zf.js} +4 -4
  94. package/dist/chunks/studio-ge08zwpv.js +5 -0
  95. package/dist/chunks/studio-gk9yk8dj.js +12 -0
  96. package/dist/chunks/{studio-bbe4c90a.js → studio-gskkgfe3.js} +4 -4
  97. package/dist/chunks/{studio-83mk4bgg.js → studio-h4jbgne2.js} +8 -8
  98. package/dist/chunks/{studio-b7y5mr30.js → studio-hcy9zeac.js} +4 -4
  99. package/dist/chunks/studio-ht81xjkr.js +5 -0
  100. package/dist/chunks/{studio-jaaz8ce9.js → studio-jr30g4ae.js} +4 -4
  101. package/dist/chunks/{studio-qgh13jtz.js → studio-k5n4es74.js} +4 -4
  102. package/dist/chunks/{studio-ccv2svc6.js → studio-k6hbx2fc.js} +4 -4
  103. package/dist/chunks/{studio-9tmebf0s.js → studio-kqh7fxn6.js} +4 -4
  104. package/dist/chunks/{studio-q0mvbaq6.js → studio-kyw47pet.js} +4 -4
  105. package/dist/chunks/studio-mkw17ngg.js +5 -0
  106. package/dist/chunks/{studio-x9hsze61.js → studio-myb4mf0h.js} +4 -4
  107. package/dist/chunks/studio-n4qq1n57.js +5 -0
  108. package/dist/chunks/studio-nfmpmkqb.js +5 -0
  109. package/dist/chunks/studio-nfwacw31.js +5 -0
  110. package/dist/chunks/{studio-rthyg42z.js → studio-nm86874b.js} +172 -172
  111. package/dist/chunks/studio-nqexty8x.js +16 -0
  112. package/dist/chunks/studio-nr0kcqc6.js +5 -0
  113. package/dist/chunks/{studio-jsgf2q5k.js → studio-nvfa137z.js} +5 -5
  114. package/dist/chunks/studio-psh8mwnw.js +4 -0
  115. package/dist/chunks/studio-qd0cycm4.js +9 -0
  116. package/dist/chunks/{studio-tfkznv39.js → studio-qevf5z9d.js} +4 -4
  117. package/dist/chunks/studio-qs5fmhtj.js +14 -0
  118. package/dist/chunks/studio-ry4789ng.js +7 -0
  119. package/dist/chunks/studio-s4fq9jht.js +5 -0
  120. package/dist/chunks/studio-sc35ttdk.js +5 -0
  121. package/dist/chunks/studio-sm2f0sc6.js +6 -0
  122. package/dist/chunks/{studio-3qeh9t0h.js → studio-spza69tb.js} +5 -5
  123. package/dist/chunks/studio-t6dqnnft.js +5 -0
  124. package/dist/chunks/{studio-43sd2mam.js → studio-tfmhnwyr.js} +3 -3
  125. package/dist/chunks/studio-tn7v8gey.js +6 -0
  126. package/dist/chunks/{studio-tft6r105.js → studio-trcfer2b.js} +4 -4
  127. package/dist/chunks/studio-tyw7tk0x.js +5 -0
  128. package/dist/chunks/{studio-jcnsdaep.js → studio-tyzgte21.js} +4 -4
  129. package/dist/chunks/studio-vtey9w98.js +5 -0
  130. package/dist/chunks/studio-ws2r1d7e.js +5 -0
  131. package/dist/chunks/studio-x2trkhk5.js +5 -0
  132. package/dist/chunks/{studio-mn0476ag.js → studio-xzgp0cp7.js} +4 -4
  133. package/dist/chunks/studio-y47090vq.js +5 -0
  134. package/dist/chunks/{studio-5g0rprdt.js → studio-yjh9gddd.js} +5 -5
  135. package/dist/chunks/studio-ywf9y1e2.js +5 -0
  136. package/dist/chunks/{studio-k7evxqh4.js → studio-z1p91z5a.js} +4 -4
  137. package/dist/chunks/studio-ztppbam0.js +5 -0
  138. package/dist/chunks/{toast-host-1yg5ar8d.js → toast-host-g3r2s0kn.js} +2 -2
  139. package/dist/chunks/toast-w11qpkyz.js +4 -0
  140. package/dist/chunks/{tooltip-yypz28gq.js → tooltip-dj1pvgz7.js} +2 -2
  141. package/dist/chunks/{tree-9xa4hm7n.js → tree-j0ebwr5s.js} +2 -2
  142. package/dist/chunks/{tsMode-epyfz1hv.js → tsMode-byje4a01.js} +5 -5
  143. package/dist/chunks/workspace-7q4arnt3.js +4 -0
  144. package/dist/chunks/workspace-home-ge027kr6.js +4 -0
  145. package/dist/chunks/{ws-client-bv552sfx.js → ws-client-h09h6sqg.js} +3 -3
  146. package/dist/iframe-entry.js +14 -14
  147. package/dist/manifest.json +76 -76
  148. package/dist/studio.js +88 -88
  149. package/package.json +15 -16
  150. package/src/browse/component-examples.ts +15 -224
  151. package/src/browse/library-preview.ts +6 -13
  152. package/src/browse/preview-root.ts +7 -4
  153. package/src/canvas/canvas-utils.ts +18 -4
  154. package/src/canvas/iframe-editable-root.ts +13 -4
  155. package/src/canvas/iframe-entry.ts +3 -1
  156. package/src/canvas/iframe-interaction.ts +11 -2
  157. package/src/canvas/iframe-render.ts +25 -3
  158. package/src/canvas/preview-placeholder.ts +120 -0
  159. package/src/collab/collab-session.ts +6 -2
  160. package/src/commands/context.ts +4 -0
  161. package/src/commands/defaults.ts +2 -0
  162. package/src/commands/live-context.ts +2 -0
  163. package/src/editor/shortcuts.ts +3 -0
  164. package/src/files/files.ts +6 -3
  165. package/src/new-project/new-project-modal.ts +1 -4
  166. package/src/panels/ai-panel.ts +27 -34
  167. package/src/panels/component-preview.ts +5 -0
  168. package/src/panels/pane-context.ts +43 -18
  169. package/src/panels/site-model.ts +7 -19
  170. package/src/panels/site-panel.ts +2 -25
  171. package/src/panels/stylebook-doc.ts +2 -53
  172. package/src/platform.ts +20 -0
  173. package/src/platforms/devserver.ts +2 -120
  174. package/src/services/ai-ask.ts +29 -171
  175. package/src/services/ai-command-tools.ts +40 -50
  176. package/src/services/ai-import-tools.ts +4 -5
  177. package/src/services/ai-project-tools.ts +13 -3
  178. package/src/services/ai-tools.ts +26 -564
  179. package/src/services/ai-writes.ts +84 -2
  180. package/src/services/document-assistant.ts +29 -58
  181. package/src/services/harness/assistant-tools.ts +68 -0
  182. package/src/services/harness/interaction-store.ts +118 -0
  183. package/src/services/harness/studio-documents.ts +105 -0
  184. package/src/services/harness/turn-hooks.ts +1 -6
  185. package/src/services/project-adoption.ts +8 -16
  186. package/src/services/references.ts +0 -0
  187. package/src/services/tool-executor.ts +25 -22
  188. package/src/state.ts +3 -13
  189. package/src/style/stylebook-specimens.ts +11 -15
  190. package/src/surfaces/files-panel.json +12 -0
  191. package/src/surfaces/files-panel.ts +5 -2
  192. package/src/surfaces/pane-context.json +286 -265
  193. package/src/surfaces/pane-context.ts +17 -8
  194. package/src/surfaces/welcome.ts +4 -1
  195. package/src/tabs/selection.ts +14 -109
  196. package/src/tabs/tab.ts +15 -7
  197. package/src/tabs/transact.ts +316 -124
  198. package/src/types.ts +13 -8
  199. package/src/utils/geometry.ts +49 -2
  200. package/dist/chunks/adapter-5tphg7xe.js +0 -4
  201. package/dist/chunks/add-new-flow-psym14sn.js +0 -4
  202. package/dist/chunks/cf-account-picker-haqazq5r.js +0 -4
  203. package/dist/chunks/cf-settings-h2wrh6hq.js +0 -4
  204. package/dist/chunks/color-m7r1vk54.js +0 -4
  205. package/dist/chunks/components-r22xca09.js +0 -4
  206. package/dist/chunks/content-source-mmppmcyg.js +0 -4
  207. package/dist/chunks/editor-ccd9jwsa.js +0 -4
  208. package/dist/chunks/entry-commands-6qxz9e8p.js +0 -4
  209. package/dist/chunks/file-ops-qnab36ek.js +0 -4
  210. package/dist/chunks/files-rcn56qvm.js +0 -4
  211. package/dist/chunks/format-host-yxz0kjjs.js +0 -4
  212. package/dist/chunks/fs-events-d1qxq4z4.js +0 -4
  213. package/dist/chunks/grid-open-4z1g062t.js +0 -4
  214. package/dist/chunks/iframe-host-ssv7kyj0.js +0 -4
  215. package/dist/chunks/index-f0jn7nnp.js +0 -4
  216. package/dist/chunks/jx-validate-ttasrbh7.js +0 -4
  217. package/dist/chunks/layers-bgyr11tr.js +0 -4
  218. package/dist/chunks/layers-panel-7ez66zeq.js +0 -4
  219. package/dist/chunks/lit-html-v9gh687x.js +0 -4
  220. package/dist/chunks/media-library-sq468wts.js +0 -4
  221. package/dist/chunks/media-picker-shfe59pt.js +0 -4
  222. package/dist/chunks/monaco-buffer-3afgwvbp.js +0 -4
  223. package/dist/chunks/notify-kzrjr3fv.js +0 -4
  224. package/dist/chunks/parse-v77xtwv7.js +0 -4
  225. package/dist/chunks/platform-7v4rtd7m.js +0 -4
  226. package/dist/chunks/publish-panel-2z9gw8ee.js +0 -4
  227. package/dist/chunks/publish-workspace-wrc1dgwx.js +0 -4
  228. package/dist/chunks/quick-search-n0t0eqxb.js +0 -4
  229. package/dist/chunks/references-9547rg5s.js +0 -4
  230. package/dist/chunks/right-panel-wsgnr9pn.js +0 -4
  231. package/dist/chunks/source-control-kwktr9y6.js +0 -4
  232. package/dist/chunks/studio-03ct6yfv.js +0 -9
  233. package/dist/chunks/studio-07rcfje3.js +0 -5
  234. package/dist/chunks/studio-3e54qh39.js +0 -7
  235. package/dist/chunks/studio-3exxhz5g.js +0 -5
  236. package/dist/chunks/studio-3qqjeep5.js +0 -6
  237. package/dist/chunks/studio-4hp8pkyt.js +0 -5
  238. package/dist/chunks/studio-58xg6j2q.js +0 -9
  239. package/dist/chunks/studio-6m380wp7.js +0 -5
  240. package/dist/chunks/studio-80jyh1xh.js +0 -4
  241. package/dist/chunks/studio-8vcvfs7r.js +0 -6
  242. package/dist/chunks/studio-bavb31r0.js +0 -5
  243. package/dist/chunks/studio-c05wdnt3.js +0 -5
  244. package/dist/chunks/studio-d5grtb9e.js +0 -16
  245. package/dist/chunks/studio-df0baea9.js +0 -5
  246. package/dist/chunks/studio-dgk4dqtx.js +0 -7
  247. package/dist/chunks/studio-e1g64q43.js +0 -5
  248. package/dist/chunks/studio-ed1hp6zf.js +0 -6
  249. package/dist/chunks/studio-ed3xx40h.js +0 -7
  250. package/dist/chunks/studio-f14e1zdj.js +0 -5
  251. package/dist/chunks/studio-fyh0jc96.js +0 -5
  252. package/dist/chunks/studio-gcxk20n7.js +0 -5
  253. package/dist/chunks/studio-kt3pjkwd.js +0 -5
  254. package/dist/chunks/studio-m8g16bgz.js +0 -5
  255. package/dist/chunks/studio-mx1smyq0.js +0 -5
  256. package/dist/chunks/studio-nqxqf8p2.js +0 -4
  257. package/dist/chunks/studio-p4nrkvqr.js +0 -5
  258. package/dist/chunks/studio-pgmvdv7z.js +0 -7
  259. package/dist/chunks/studio-ph84avkf.js +0 -5
  260. package/dist/chunks/studio-q0tccd43.js +0 -5
  261. package/dist/chunks/studio-q7rtx6zq.js +0 -14
  262. package/dist/chunks/studio-qwdqwkj0.js +0 -5
  263. package/dist/chunks/studio-rj9eamf8.js +0 -5
  264. package/dist/chunks/studio-rptgsk96.js +0 -5
  265. package/dist/chunks/studio-s19tp1xr.js +0 -5
  266. package/dist/chunks/studio-syreb644.js +0 -5
  267. package/dist/chunks/studio-tf9jryb1.js +0 -10
  268. package/dist/chunks/studio-txf2kysx.js +0 -5
  269. package/dist/chunks/studio-v8867ddv.js +0 -12
  270. package/dist/chunks/studio-vxq7fcvv.js +0 -5
  271. package/dist/chunks/studio-w4rz9hdk.js +0 -5
  272. package/dist/chunks/studio-wc649wss.js +0 -5
  273. package/dist/chunks/studio-wys23mxz.js +0 -5
  274. package/dist/chunks/studio-y3ahhdpx.js +0 -5
  275. package/dist/chunks/studio-z97c48fk.js +0 -5
  276. package/dist/chunks/studio-zat62m0c.js +0 -5
  277. package/dist/chunks/studio-zcd2e7yz.js +0 -5
  278. package/dist/chunks/toast-1gv0h53h.js +0 -4
  279. package/dist/chunks/workspace-dz7nxfhv.js +0 -4
  280. package/dist/chunks/workspace-home-xsd7hkjw.js +0 -4
  281. package/src/services/directory-picker.ts +0 -187
  282. package/src/services/gated-registry.ts +0 -77
@@ -1,7 +1,7 @@
1
1
  /// <reference lib="dom" />
2
2
  import { toRaw } from "../reactivity";
3
3
  import { jsonClone } from "../utils/studio-utils";
4
- import { childIndex, getNodeAtPath, parentElementPath, pathsEqual } from "../state";
4
+ import { getNodeAtPath, pathsEqual } from "../state";
5
5
  import {
6
6
  cloneSelection,
7
7
  primarySelection,
@@ -10,6 +10,7 @@ import {
10
10
  structuralBatch,
11
11
  } from "./selection";
12
12
  import { applyDocOpToDoc, childArray, cloneValue, inverseOf } from "./doc-op-apply";
13
+ import { insertionSlot, planMove, shiftPrefixedIndex } from "@jxsuite/schema/doc-ops";
13
14
  import {
14
15
  beginRecording,
15
16
  endRecording,
@@ -20,8 +21,9 @@ import {
20
21
  } from "./patch-ops";
21
22
 
22
23
  import type { JxDocOp, JxDocOpPair, TransactionRecord } from "./patch-ops";
24
+ import type { SelectionEffect } from "@jxsuite/ai/jx-tools";
23
25
 
24
- import type { Tab } from "../tabs/tab";
26
+ import type { HistorySnapshot, Tab } from "../tabs/tab";
25
27
  import type { JxPath } from "../state";
26
28
 
27
29
  import type { JsonValue } from "../types";
@@ -38,7 +40,6 @@ import {
38
40
  getNestedStyle,
39
41
  isEventBinding,
40
42
  isNonNullObject,
41
- isNumber,
42
43
  } from "@jxsuite/schema/guards";
43
44
 
44
45
  const HISTORY_LIMIT = 100;
@@ -81,33 +82,6 @@ function setKeyPair(
81
82
  };
82
83
  }
83
84
 
84
- /**
85
- * Translate `path` into post-splice coordinates when it descends through `parentPath`'s children at
86
- * or beyond the spliced index: a removal (delta -1) at fromIndex shifts deeper paths down; an
87
- * insertion (delta +1, inclusive) shifts them up. Returns `path` unchanged when the splice doesn't
88
- * affect it. Used to express a move's inverse in post-mutation coordinates.
89
- */
90
- function shiftPrefixedIndex(
91
- parentPath: JxPath,
92
- path: JxPath,
93
- spliceIndex: number,
94
- delta: number,
95
- inclusive: boolean,
96
- ): JxPath {
97
- const d = parentPath.length;
98
- const isProperPrefix = d < path.length && parentPath.every((seg, i) => seg === path[i]);
99
- const idx = path[d + 1];
100
- if (!isProperPrefix || path[d] !== "children" || !isNumber(idx)) {
101
- return path;
102
- }
103
- if (inclusive ? idx < spliceIndex : idx <= spliceIndex) {
104
- return path;
105
- }
106
- const shifted = [...path];
107
- shifted[d + 1] = idx + delta;
108
- return shifted;
109
- }
110
-
111
85
  /** Any value that can sit at a document node key (undefined/null/"" deletes it). */
112
86
  export type JxNodeValue =
113
87
  | JsonValue
@@ -214,20 +188,21 @@ function rollbackFailedTransaction(
214
188
  * with no prompt. A refusal is not an exception (it is an ordinary, expected state of the room), so
215
189
  * it is a return value: `false` means the document is exactly as it was.
216
190
  *
191
+ * **AND IT SAYS WHO WROTE.** `actor` (else the scope {@link runAsActor} opened, else the person at
192
+ * the keyboard) decides where the step lands: an actor that owns an open {@link BatchLane} folds it
193
+ * into that lane's segment on this tab, and anyone else first closes whatever segment is open here,
194
+ * so their edit is never folded into someone else's step (specs/ai.md §3.3).
195
+ *
217
196
  * @param {Tab | null} tab
218
197
  * @param {(tab: Tab) => void} mutationFn
219
- * @param {{ skipHistory?: boolean; origin?: TransactOrigin }} [opts]
198
+ * @param {TransactOptions} [opts]
220
199
  * @returns {boolean} True when the mutation was applied; false when there was no tab, or the gate
221
200
  * refused it.
222
201
  */
223
202
  export function transactDoc(
224
203
  tab: Tab | null,
225
204
  mutationFn: (tab: Tab) => void,
226
- {
227
- skipHistory = false,
228
- origin = "user",
229
- coalesceKey = null,
230
- }: { skipHistory?: boolean; origin?: TransactOrigin; coalesceKey?: string | null } = {},
205
+ { skipHistory = false, origin = "user", coalesceKey = null, actor = null }: TransactOptions = {},
231
206
  ): boolean {
232
207
  if (!tab) {
233
208
  return false;
@@ -235,6 +210,12 @@ export function transactDoc(
235
210
  if (origin !== "remote" && _transactGate?.(tab, origin)) {
236
211
  return false;
237
212
  }
213
+ const writer = actor ?? _scopeActor;
214
+ /* A remote collaborator's ops are the room's, not a local writer's: they skip history and are
215
+ never published back, so they neither join a lane nor close one. */
216
+ if (origin !== "remote") {
217
+ closeForeignSegment(tab, writer);
218
+ }
238
219
  const selectionBefore = cloneSelection(tab.session.selection);
239
220
  const dirtyBefore = tab.doc.dirty;
240
221
  beginRecording();
@@ -287,8 +268,13 @@ export function transactDoc(
287
268
  }
288
269
  }
289
270
 
290
- if (!skipHistory && !_batchTab) {
291
- pushHistoryEntry(tab, raw, record, selectionBefore, coalesceKey);
271
+ if (!skipHistory) {
272
+ const lane = writer ? laneOwnedBy(writer) : null;
273
+ if (lane) {
274
+ joinSegment(lane, tab, record, selectionBefore);
275
+ } else {
276
+ pushHistoryEntry(tab, raw, record, selectionBefore, coalesceKey);
277
+ }
292
278
  }
293
279
 
294
280
  tab.doc.dirty = true;
@@ -404,7 +390,7 @@ function materializeState(
404
390
  export function transact(
405
391
  tab: Tab | null,
406
392
  fn: (doc: JxMutableNode) => void,
407
- opts?: { skipHistory?: boolean; origin?: TransactOrigin },
393
+ opts?: TransactOptions,
408
394
  ): boolean {
409
395
  return transactDoc(tab, (t) => fn(t.doc.document), opts);
410
396
  }
@@ -431,6 +417,63 @@ export function applyExternalDocOps(tab: Tab, ops: JxDocOp[]) {
431
417
  );
432
418
  }
433
419
 
420
+ /** What {@link applyDocOpsAsUser} did: wrote, had nothing to write, or was refused by the gate. */
421
+ export type ApplyDocOpsResult = "applied" | "unchanged" | "refused";
422
+
423
+ /**
424
+ * Apply ops a tool PRODUCED (rather than a mutator performing the edit itself) as one ordinary
425
+ * transaction: history, lanes, the gate, the canvas patch and the collab observer all see it as
426
+ * they see any edit. Each op's inverse is computed against the raw document just before that op
427
+ * lands, so a later op may address what an earlier one created, and the selection rule the producer
428
+ * named is applied inside the same transaction, so a rollback restores it too.
429
+ *
430
+ * Empty ops are no transaction at all: no new root, no history entry, nothing published
431
+ * (specs/ai.md §3.9). An op that cannot apply throws from `inverseOf` before it lands, and
432
+ * `transactDoc` rolls back the ones before it and rethrows.
433
+ *
434
+ * @param {Tab} tab
435
+ * @param {readonly JxDocOp[]} ops
436
+ * @param {{ actor?: ActorRef | null; selection?: SelectionEffect }} [options]
437
+ * @returns {ApplyDocOpsResult}
438
+ */
439
+ export function applyDocOpsAsUser(
440
+ tab: Tab,
441
+ ops: readonly JxDocOp[],
442
+ {
443
+ actor = null,
444
+ selection = { kind: "keep" },
445
+ }: { actor?: ActorRef | null; selection?: SelectionEffect } = {},
446
+ ): ApplyDocOpsResult {
447
+ if (ops.length === 0) {
448
+ return "unchanged";
449
+ }
450
+ const applied = transactDoc(
451
+ tab,
452
+ (t) => {
453
+ for (const op of ops) {
454
+ applyDocOp(t, op, inverseOf(toRaw(t.doc.document), op));
455
+ }
456
+ applySelectionEffect(t, selection);
457
+ },
458
+ { actor },
459
+ );
460
+ return applied ? "applied" : "refused";
461
+ }
462
+
463
+ /** Carry the tab's selection across an edit, as the producer's {@link SelectionEffect} says. */
464
+ function applySelectionEffect(tab: Tab, effect: SelectionEffect) {
465
+ if (effect.kind === "prune") {
466
+ for (const under of effect.under) {
467
+ tab.session.selection = pruneSelection(tab.session.selection, under);
468
+ }
469
+ } else if (
470
+ effect.kind === "follow" &&
471
+ pathsEqual(primarySelection(tab.session.selection), effect.from)
472
+ ) {
473
+ tab.session.selection = [[...effect.to]];
474
+ }
475
+ }
476
+
434
477
  // ─── Document-op application ─────────────────────────────────────────────────
435
478
 
436
479
  /** Document-level keys whose changes require a full scope/panel rebuild on the canvas. */
@@ -442,8 +485,10 @@ const DOC_META_KEYS = new Set(["state", "$media", "$head", "$elements", "imports
442
485
  *
443
486
  * @param {Tab} tab
444
487
  * @param {JxDocOp} op
488
+ * @param {JxDocOp} inverse - The op that undoes this one: replay passes none (see below), a new
489
+ * edit its real inverse
445
490
  */
446
- function applyDocOp(tab: Tab, op: JxDocOp) {
491
+ function applyDocOp(tab: Tab, op: JxDocOp, inverse: JxDocOp = op) {
447
492
  let prev: JxDocumentValue | undefined | null = null;
448
493
  if (op.op === "set-key") {
449
494
  const node = getNodeAtPath(tab.doc.document, op.path);
@@ -456,10 +501,10 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
456
501
  // Classified as applicable (suppressing the full render), and the canvas silently kept showing
457
502
  // The pre-undo content. Replaying an op IS a forward op — record it as one.
458
503
  //
459
- // History never reads the inverse here: undo/redo run with `skipHistory`, and the entry they
504
+ // On replay history never reads the inverse: undo/redo run with `skipHistory`, and the entry they
460
505
  // Replay already holds the pair. `markNonInvertible` would be wrong (it forces a whole-document
461
- // Checkpoint), so the op is paired with itself.
462
- recordDocOp({ forward: op, inverse: op });
506
+ // Checkpoint), so a replayed op is paired with itself.
507
+ recordDocOp({ forward: op, inverse });
463
508
  switch (op.op) {
464
509
  case "set-key": {
465
510
  if (DOC_META_KEYS.has(op.key)) {
@@ -504,11 +549,78 @@ function applyDocOp(tab: Tab, op: JxDocOp) {
504
549
  }
505
550
  }
506
551
 
507
- // ─── Batch (group multiple mutations into one undo step) ────────────────────
552
+ // ─── Batch lanes (one actor's run of writes, one undo step per document) ─────
553
+
554
+ /**
555
+ * An actor that is not the person at the keyboard writing to a document. The assistant's
556
+ * `@jxsuite/ai` `Actor` is one: its `id` is `assistant:<sessionId|local>:<turnId>`.
557
+ */
558
+ export interface ActorRef {
559
+ readonly kind: "user" | "assistant" | "remote";
560
+ readonly id: string;
561
+ readonly turnId?: string;
562
+ }
563
+
564
+ /** The options {@link transactDoc} takes. */
565
+ export interface TransactOptions {
566
+ skipHistory?: boolean;
567
+ origin?: TransactOrigin;
568
+ coalesceKey?: string | null;
569
+ /** Who is writing; `null` (the default) is the person at the keyboard, or the open scope's. */
570
+ actor?: ActorRef | null;
571
+ }
572
+
573
+ /**
574
+ * One actor's run of writes that undoes as ONE step.
575
+ *
576
+ * A lane is open for a whole assistant turn. Each document it writes gets a **segment**, opened on
577
+ * the lane's first write there and closed when the lane flushes, or earlier when anyone else writes
578
+ * that document: the segment becomes one history entry and the other writer's edit becomes its own
579
+ * after it, so a person's edit is never folded into the assistant's step and the steps keep the
580
+ * order they happened in. A document the lane never wrote gets no entry at all.
581
+ */
582
+ export interface BatchLane {
583
+ readonly owner: ActorRef;
584
+ /** Close every open segment now, one entry per document that recorded something. */
585
+ readonly flush: () => void;
586
+ /** Flush and retire the lane; returns where each of its entries landed, oldest first. */
587
+ readonly close: () => readonly BatchReceipt[];
588
+ }
589
+
590
+ /** Where one of a lane's segments landed in its tab's history. */
591
+ export interface BatchReceipt {
592
+ readonly tab: Tab;
593
+ /**
594
+ * The history entry the segment became, compared by identity: its index moves when the history
595
+ * limit drops the oldest entry, the entry does not. Null on a tab whose undo a history delegate
596
+ * owns (a shared live document), where the step is the delegate's and no entry names it.
597
+ */
598
+ readonly entry: HistorySnapshot | null;
599
+ readonly turnId: string;
600
+ }
601
+
602
+ interface LaneState {
603
+ readonly owner: ActorRef;
604
+ readonly receipts: BatchReceipt[];
605
+ }
606
+
607
+ interface Segment {
608
+ readonly lane: LaneState;
609
+ readonly docOps: JxDocOpPair[];
610
+ readonly fmOps: TransactionRecord["fmOps"];
611
+ /** False once any write in it could not be inverted, or recorded no ops: the entry snapshots. */
612
+ invertible: boolean;
613
+ readonly selectionBefore: JxPath[];
614
+ }
508
615
 
509
- let _batchTab: Tab | null = null;
616
+ /** The open lanes, by owner id. */
617
+ const _lanes = new Map<string, LaneState>();
618
+ /** The open segment on each tab: at most one, since a second writer closes the first's. */
619
+ const _segments = new Map<Tab, Segment>();
620
+ /** The actor {@link runAsActor} is running as, for a writer that cannot pass one. */
621
+ let _scopeActor: ActorRef | null = null;
510
622
 
511
- /** Module-level hook invoked when a batch closes (collab flushes its buffered ops as one step). */
623
+ /** Module-level hook invoked when a segment closes (collab flushes its buffered ops as one step). */
512
624
  export type BatchEndNotifier = (tab: Tab) => void;
513
625
 
514
626
  let _batchEndNotifier: BatchEndNotifier | null = null;
@@ -517,48 +629,132 @@ export function setBatchEndNotifier(fn: BatchEndNotifier | null) {
517
629
  _batchEndNotifier = fn;
518
630
  }
519
631
 
520
- export function beginBatch(tab: Tab | null) {
521
- _batchTab = tab;
632
+ /**
633
+ * Open a lane for `owner`. Every transaction `owner` makes until {@link BatchLane.close} joins it;
634
+ * opening a second lane for the same owner replaces the first, after flushing it.
635
+ */
636
+ export function openBatchLane(owner: ActorRef): BatchLane {
637
+ const previous = _lanes.get(owner.id);
638
+ if (previous) {
639
+ flushLane(previous);
640
+ }
641
+ const lane: LaneState = { owner, receipts: [] };
642
+ _lanes.set(owner.id, lane);
643
+ return {
644
+ owner,
645
+ flush: () => flushLane(lane),
646
+ close: () => {
647
+ flushLane(lane);
648
+ if (_lanes.get(owner.id) === lane) {
649
+ _lanes.delete(owner.id);
650
+ }
651
+ return [...lane.receipts];
652
+ },
653
+ };
522
654
  }
523
655
 
524
- export function endBatch() {
525
- const tab = _batchTab;
526
- // A history delegate owns undo grouping while registered; the snapshot push would be dead weight.
527
- if (tab && !_historyDelegates.has(tab)) {
528
- const raw = toRaw(tab.doc.document);
529
- const snapshot = {
530
- document: jsonClone(raw),
531
- selection: cloneSelection(tab.session.selection),
532
- };
533
- const truncated = tab.history.snapshots.slice(0, tab.history.index + 1);
534
- truncated.push(snapshot);
535
- if (truncated.length > HISTORY_LIMIT) {
536
- truncated.shift();
656
+ /**
657
+ * Run `fn` with every transaction it makes attributed to `actor`.
658
+ *
659
+ * **Synchronous only.** The scope ends when `fn` returns, before any await inside it resumes, so a
660
+ * write made after an await is the person's again. That is deliberate: a scope that outlived its
661
+ * call would attribute whatever the person did in the meantime to the assistant. A writer that
662
+ * awaits before it writes passes `actor` to {@link transactDoc} instead.
663
+ */
664
+ export function runAsActor<T>(actor: ActorRef, fn: () => T): T {
665
+ const previous = _scopeActor;
666
+ _scopeActor = actor;
667
+ try {
668
+ return fn();
669
+ } finally {
670
+ _scopeActor = previous;
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Whether `tab` has an open segment: owned by `actor` when one is given, by anyone otherwise.
676
+ * Collab reads this to buffer the owner's ops until the segment closes.
677
+ */
678
+ export function isBatched(tab: Tab, actor?: ActorRef | null): boolean {
679
+ const segment = _segments.get(tab);
680
+ return segment !== undefined && (!actor || segment.lane.owner.id === actor.id);
681
+ }
682
+
683
+ /** Close every open segment of every lane (a project is about to be replaced under them). */
684
+ export function flushOpenBatches(): void {
685
+ for (const lane of _lanes.values()) {
686
+ flushLane(lane);
687
+ }
688
+ }
689
+
690
+ function laneOwnedBy(actor: ActorRef): LaneState | null {
691
+ return _lanes.get(actor.id) ?? null;
692
+ }
693
+
694
+ function flushLane(lane: LaneState): void {
695
+ // Deleting the entry being visited is safe: a Map iterator skips removed entries and goes on.
696
+ for (const [tab, segment] of _segments) {
697
+ if (segment.lane === lane) {
698
+ closeSegment(tab, segment);
537
699
  }
538
- tab.history.snapshots = truncated;
539
- tab.history.index = truncated.length - 1;
540
700
  }
541
- _batchTab = null;
542
- if (tab) {
543
- _batchEndNotifier?.(tab);
701
+ }
702
+
703
+ /** Fold a lane owner's transaction into its segment on `tab`, opening the segment if needed. */
704
+ function joinSegment(
705
+ lane: LaneState,
706
+ tab: Tab,
707
+ record: TransactionRecord,
708
+ selectionBefore: JxPath[],
709
+ ): void {
710
+ let segment = _segments.get(tab);
711
+ if (!segment) {
712
+ segment = { docOps: [], fmOps: [], invertible: true, lane, selectionBefore };
713
+ _segments.set(tab, segment);
714
+ }
715
+ segment.docOps.push(...record.docOps);
716
+ segment.fmOps.push(...record.fmOps);
717
+ /* An unrecorded mutation changed the document without saying how, so only a snapshot of the
718
+ result can undo the segment it is in. */
719
+ if (!record.invertible || (record.docOps.length === 0 && record.fmOps.length === 0)) {
720
+ segment.invertible = false;
544
721
  }
545
722
  }
546
723
 
547
- export function isBatching(): boolean {
548
- return _batchTab !== null;
724
+ /** A writer other than the segment's owner is about to write `tab`: close the segment first. */
725
+ function closeForeignSegment(tab: Tab, writer: ActorRef | null): void {
726
+ const segment = _segments.get(tab);
727
+ if (segment && segment.lane.owner.id !== writer?.id) {
728
+ closeSegment(tab, segment);
729
+ }
549
730
  }
550
731
 
551
- /**
552
- * Which tab the open batch belongs to, or `null` when none is open.
553
- *
554
- * The agent loop needs this to keep a batch honest across a tab change. A batch is opened against
555
- * one tab and closed against the same one — {@link endBatch} pushes ITS history snapshot and fires
556
- * the collab notifier for IT — so a loop whose tools moved to a second document has to close the
557
- * first batch and open a second, or the second document's edits get neither a history entry nor a
558
- * collab publish. Reading the batched tab is how the loop can tell.
559
- */
560
- export function batchTab(): Tab | null {
561
- return _batchTab;
732
+ /** Push the segment as one history entry tagged with its owner, then let collab publish it. */
733
+ function closeSegment(tab: Tab, segment: Segment): void {
734
+ _segments.delete(tab);
735
+ const { owner } = segment.lane;
736
+ const turnId = owner.turnId ?? "";
737
+ /* A history delegate owns undo grouping while registered; an entry here would be dead weight.
738
+ The receipt is still issued, with no entry, so Restore can tell a step it cannot check from a
739
+ document the turn never wrote. */
740
+ if (_historyDelegates.has(tab)) {
741
+ segment.lane.receipts.push({ entry: null, tab, turnId });
742
+ } else {
743
+ const record: TransactionRecord = {
744
+ docOps: segment.docOps,
745
+ fmOps: segment.fmOps,
746
+ invertible: segment.invertible,
747
+ ops: [],
748
+ };
749
+ pushHistoryEntry(tab, toRaw(tab.doc.document), record, segment.selectionBefore);
750
+ const entry = tab.history.snapshots[tab.history.index]!;
751
+ entry.actor = owner.id;
752
+ if (owner.turnId !== undefined) {
753
+ entry.turnId = owner.turnId;
754
+ }
755
+ segment.lane.receipts.push({ entry, tab, turnId });
756
+ }
757
+ _batchEndNotifier?.(tab);
562
758
  }
563
759
 
564
760
  // ─── Undo / Redo ─────────────────────────────────────────────────────────────
@@ -591,12 +787,17 @@ export function getHistoryDelegate(tab: Tab): HistoryDelegate | null {
591
787
 
592
788
  export function canUndo(tab: Tab): boolean {
593
789
  const delegate = _historyDelegates.get(tab);
594
- return delegate ? delegate.canUndo(tab) : tab.history.index > 0;
790
+ // An open segment is a step that has not been pushed yet; undo closes it and then takes it back.
791
+ return delegate ? delegate.canUndo(tab) : tab.history.index > 0 || _segments.has(tab);
595
792
  }
596
793
 
597
794
  export function canRedo(tab: Tab): boolean {
598
795
  const delegate = _historyDelegates.get(tab);
599
- return delegate ? delegate.canRedo(tab) : tab.history.index < tab.history.snapshots.length - 1;
796
+ /* An open segment has already changed the document past the redo stack, and closing it truncates
797
+ that stack, so there is nothing to redo while one is open. */
798
+ return delegate
799
+ ? delegate.canRedo(tab)
800
+ : !_segments.has(tab) && tab.history.index < tab.history.snapshots.length - 1;
600
801
  }
601
802
 
602
803
  /** Restore a materialized snapshot state (full-render path). */
@@ -627,6 +828,10 @@ function assertHistoryConsistency(tab: Tab, index: number) {
627
828
 
628
829
  /** @param {Tab} tab */
629
830
  export function undo(tab: Tab) {
831
+ /* Undo is the person's, so a segment open on this tab closes first and becomes the step undo
832
+ takes back. Without this, ⌘Z mid-turn would skip the assistant's pending edits and undo the
833
+ entry before them, leaving the segment's inverses pointing at a document they no longer fit. */
834
+ closeForeignSegment(tab, null);
630
835
  const delegate = _historyDelegates.get(tab);
631
836
  if (delegate) {
632
837
  delegate.undo(tab);
@@ -678,6 +883,8 @@ export function undo(tab: Tab) {
678
883
 
679
884
  /** @param {Tab} tab */
680
885
  export function redo(tab: Tab) {
886
+ // Same as {@link undo}: an open segment is history the redo stack is about to be measured against.
887
+ closeForeignSegment(tab, null);
681
888
  const delegate = _historyDelegates.get(tab);
682
889
  if (delegate) {
683
890
  delegate.redo(tab);
@@ -740,11 +947,16 @@ export function mutateInsertNode(
740
947
  if (!parent) {
741
948
  return;
742
949
  }
743
- childArray(parent).splice(index, 0, structuredClone(nodeDef));
744
- recordPatch({ index, op: "insert", parentPath });
950
+ const children = childArray(parent);
951
+ /* Recorded at the slot the node lands in, not the one asked for: `splice` appends past the end,
952
+ and an op naming index 9 of a two-child list replays wrongly anywhere that does not clamp (the
953
+ collab mirror throws on it) and undoes by removing nothing. */
954
+ const slot = insertionSlot(index, children.length);
955
+ children.splice(slot, 0, structuredClone(nodeDef));
956
+ recordPatch({ index: slot, op: "insert", parentPath });
745
957
  recordDocOp({
746
- forward: { index, node: cloneValue(nodeDef), op: "insert-child", parentPath },
747
- inverse: { index, op: "remove-child", parentPath },
958
+ forward: { index: slot, node: cloneValue(nodeDef), op: "insert-child", parentPath },
959
+ inverse: { index: slot, op: "remove-child", parentPath },
748
960
  });
749
961
  }
750
962
 
@@ -905,50 +1117,26 @@ export function mutateWrapNode(tab: Tab, path: JxPath, wrapperTag = "div") {
905
1117
  */
906
1118
  export function mutateMoveNode(tab: Tab, fromPath: JxPath, toParentPath: JxPath, toIndex: number) {
907
1119
  const doc = tab.doc.document;
908
- const fromParentPath = parentElementPath(fromPath);
909
- const fromIdx = childIndex(fromPath);
910
- if (!fromParentPath || !isNumber(fromIdx)) {
911
- return;
912
- }
913
- const fromParent = getNodeAtPath(doc, fromParentPath);
914
- const toParent = getNodeAtPath(doc, toParentPath);
915
- if (!fromParent || !Array.isArray(fromParent.children) || !toParent) {
1120
+ const move = planMove(doc, fromPath, toParentPath, toIndex);
1121
+ if (!move) {
916
1122
  return;
917
1123
  }
918
- if (fromParent.children[fromIdx] === undefined) {
919
- return;
920
- }
921
- let adjustedIndex = toIndex;
922
- if (fromParent === toParent && fromIdx < toIndex) {
923
- adjustedIndex -= 1;
924
- }
925
- const forward: JxDocOp = {
926
- fromIndex: fromIdx,
927
- fromParentPath,
928
- op: "move-child",
929
- toIndex: adjustedIndex,
930
- toParentPath,
931
- };
932
- /* Computed BEFORE the splice, against the document the move applies to. The inverse runs against
933
- the post-move document, so `inverseOf` writes both of its parent paths in post-move
934
- coordinates: the removal shifted paths under the source parent, the insertion paths under the
935
- target. One implementation of that rule, shared with every other host that moves a node. */
936
- const inverse = inverseOf(doc, forward);
937
- const [node] = fromParent.children.splice(fromIdx, 1);
938
- childArray(toParent).splice(adjustedIndex, 0, node!);
939
- recordPatch({ fromPath, op: "move", toIndex: adjustedIndex, toParentPath });
1124
+ const { forward, inverse } = move;
1125
+ /* SAFETY: `planMove` resolved both parents as nodes, and the source's children as a static
1126
+ array holding a child at `fromIndex`. */
1127
+ const fromParent = getNodeAtPath(doc, forward.fromParentPath)!;
1128
+ const toParent = getNodeAtPath(doc, toParentPath)!;
1129
+ // SAFETY: as above, the source parent's children are a static array (`planMove` checked).
1130
+ const [node] = (fromParent.children as (JxMutableNode | string)[]).splice(forward.fromIndex, 1);
1131
+ childArray(toParent).splice(forward.toIndex, 0, node!);
1132
+ recordPatch({ fromPath, op: "move", toIndex: forward.toIndex, toParentPath });
940
1133
  recordDocOp({ forward, inverse });
941
1134
 
1135
+ /* The moved node's own post-move path is where its inverse picks it up. Writing the target path
1136
+ as given would be wrong whenever the removal renumbered it: moving `children/0` into
1137
+ `children/2` lands inside what is now `children/1`. */
942
1138
  if (pathsEqual(primarySelection(tab.session.selection), fromPath)) {
943
- let idx = toIndex;
944
- if (
945
- fromParentPath.length === toParentPath.length &&
946
- fromParentPath.every((v, i) => v === toParentPath[i]) &&
947
- fromIdx < toIndex
948
- ) {
949
- idx = toIndex - 1;
950
- }
951
- tab.session.selection = [[...toParentPath, "children", idx]];
1139
+ tab.session.selection = [[...inverse.fromParentPath, "children", inverse.fromIndex]];
952
1140
  }
953
1141
  }
954
1142
 
@@ -984,6 +1172,10 @@ export function mutateUpdateProperty(tab: Tab, path: JxPath, key: string, value?
984
1172
  node[key] = value;
985
1173
  }
986
1174
  recordDocOp(setKeyPair(path, key, before, cloneValue(node[key])));
1175
+ // Replacing the children replaces every node under them, so nothing selected there survives.
1176
+ if (key === "children") {
1177
+ tab.session.selection = pruneSelection(tab.session.selection, [...path, "children"]);
1178
+ }
987
1179
  if (key === "textContent") {
988
1180
  recordPatch({ op: "set-text", path });
989
1181
  } else if (key === "style") {
package/src/types.ts CHANGED
@@ -361,16 +361,21 @@ export interface StudioPlatform {
361
361
  */
362
362
  assetCapabilities?: AssetCapabilities;
363
363
  activate: (root?: string) => Promise<void>;
364
- openProject: () => Promise<{
364
+ /**
365
+ * Present a native project dialog and bind the chosen project to this window. Absent (the dev
366
+ * server, whose windows open a project from `?project=`), and with no `openProjectPicker` either,
367
+ * Studio offers no Open Project at all.
368
+ */
369
+ openProject?: () => Promise<{
365
370
  config: ProjectConfig;
366
371
  handle: { root: string; name: string; projectConfig: ProjectConfig };
367
372
  } | null>;
368
373
  /**
369
- * How "Open Project" picks a project. Absent: `openProject()` owns picking (native dialog /
370
- * showDirectoryPicker). `"repo-list"`: Studio shows its repository picker over `listRepos` +
371
- * `importProject` (write-access repositories) and opens the choice through the recent-projects
372
- * path — `openProject()` is never called. Cloud sets this: its sessions are URL-bound, so there
373
- * is no backend dialog to delegate to.
374
+ * How "Open Project" picks a project. Absent: `openProject()` owns picking (the native dialog).
375
+ * `"repo-list"`: Studio shows its repository picker over `listRepos` + `importProject`
376
+ * (write-access repositories) and opens the choice through the recent-projects path —
377
+ * `openProject()` is never called. Cloud sets this: its sessions are URL-bound, so there is no
378
+ * backend dialog to delegate to.
374
379
  */
375
380
  openProjectPicker?: "repo-list";
376
381
  probeRootProject: () => Promise<{
@@ -677,8 +682,8 @@ export interface StudioPlatform {
677
682
  ) => Promise<{ root: string; config: ProjectConfig; result?: ImportSiteSummary }>;
678
683
  /**
679
684
  * Open a native directory picker and return the chosen absolute path. Backs the New Project
680
- * modal's **Browse…** button on `createDestination: "path"` platforms: desktop (the system
681
- * dialog), and the dev server in a browser that has `showDirectoryPicker` (Chromium-based).
685
+ * modal's **Browse…** button on `createDestination: "path"` platforms that have a native dialog
686
+ * (desktop). The dev server has none, so its Location field is typed.
682
687
  *
683
688
  * Resolves `null` only when the user cancels; rejects with a displayable Error otherwise. The
684
689
  * modal shows that message under the Location field, and typing a path clears it. A picker that