@jxsuite/studio 5.6.0 → 5.7.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 (173) hide show
  1. package/dist/chunks/{2020-zvax9fmb.js → 2020-cs2sb9t1.js} +3 -3
  2. package/dist/chunks/adapter-y5gdq19c.js +4 -0
  3. package/dist/chunks/{cf-account-picker-zfycs4k2.js → cf-account-picker-882a7ecm.js} +3 -3
  4. package/dist/chunks/cf-settings-20dznc3j.js +4 -0
  5. package/dist/chunks/components-q037dya7.js +4 -0
  6. package/dist/chunks/content-source-37x3vsap.js +4 -0
  7. package/dist/chunks/{convert-to-repeater-x19jr6c7.js → convert-to-repeater-grfep2w8.js} +3 -3
  8. package/dist/chunks/editor-j5sq149q.js +4 -0
  9. package/dist/chunks/entry-commands-c5h3nqck.js +4 -0
  10. package/dist/chunks/{extension-sections-y90sa0n4.js → extension-sections-dp8endhx.js} +3 -3
  11. package/dist/chunks/file-ops-937cvqk6.js +4 -0
  12. package/dist/chunks/files-tznwe1p1.js +4 -0
  13. package/dist/chunks/format-host-e2pjvd25.js +4 -0
  14. package/dist/chunks/fs-events-fv64ak3s.js +4 -0
  15. package/dist/chunks/git-panel-gb713ttc.js +4 -0
  16. package/dist/chunks/{grid-view-d9mybhn0.js → grid-view-5amhg604.js} +10 -10
  17. package/dist/chunks/iframe-host-t819mcg5.js +4 -0
  18. package/dist/chunks/{index-vnwy9d44.js → index-9ydfe04y.js} +3 -3
  19. package/dist/chunks/index-m0x5ztax.js +4 -0
  20. package/dist/chunks/{javascript-f0nrbdjb.js → javascript-d9pqgc2p.js} +2 -2
  21. package/dist/chunks/{jsonMode-nfa90dtf.js → jsonMode-800yvbem.js} +4 -4
  22. package/dist/chunks/jx-validate-zc9zp77s.js +4 -0
  23. package/dist/chunks/layers-4bsr82wz.js +4 -0
  24. package/dist/chunks/layers-panel-b6shnaax.js +4 -0
  25. package/dist/chunks/lit-html-cfn61dn7.js +4 -0
  26. package/dist/chunks/media-picker-ej0kdh1j.js +4 -0
  27. package/dist/chunks/menu-rtxf9chb.js +4 -0
  28. package/dist/chunks/{monaco-binding-antn79jf.js → monaco-binding-62dtmgbf.js} +3 -3
  29. package/dist/chunks/monaco-buffer-nn4dtx27.js +4 -0
  30. package/dist/chunks/{monaco-setup-k8s40vwg.js → monaco-setup-tj13ndtm.js} +3 -3
  31. package/dist/chunks/notify-ahs57a2z.js +4 -0
  32. package/dist/chunks/platform-v9xyfkbx.js +4 -0
  33. package/dist/chunks/publish-panel-bq36xcmj.js +4 -0
  34. package/dist/chunks/quick-search-zm00h9dh.js +4 -0
  35. package/dist/chunks/references-99ws0b0m.js +4 -0
  36. package/dist/chunks/right-panel-pq2f71q3.js +4 -0
  37. package/dist/chunks/section-registry-b2yzp9sw.js +4 -0
  38. package/dist/chunks/{studio-y17505ge.js → studio-01kbbqg3.js} +207 -207
  39. package/dist/chunks/{studio-gxna6yta.js → studio-01qza1n3.js} +4 -4
  40. package/dist/chunks/{studio-n2e133d6.js → studio-03wwfvyt.js} +4 -4
  41. package/dist/chunks/{studio-09n3fyy0.js → studio-0bbxhhce.js} +4 -4
  42. package/dist/chunks/{studio-dknr8m7b.js → studio-0k9mvpgj.js} +7 -7
  43. package/dist/chunks/{studio-s85vah66.js → studio-0scebvb9.js} +5 -5
  44. package/dist/chunks/studio-1a99w39s.js +5 -0
  45. package/dist/chunks/{studio-eegyf5vg.js → studio-1wvfxkyy.js} +4 -4
  46. package/dist/chunks/{studio-ft8pfpp0.js → studio-1x8x26k3.js} +4 -4
  47. package/dist/chunks/{studio-28pz4s1k.js → studio-222rqyyc.js} +4 -4
  48. package/dist/chunks/{studio-6exg5z4x.js → studio-4xppwncr.js} +3 -3
  49. package/dist/chunks/{studio-0c3fpdqj.js → studio-54g077x3.js} +5 -5
  50. package/dist/chunks/{studio-z47mm077.js → studio-56z7evx4.js} +6 -6
  51. package/dist/chunks/{studio-zkkdbbvd.js → studio-5e9zsamh.js} +6 -6
  52. package/dist/chunks/{studio-4yztve2t.js → studio-65fhspdd.js} +4 -4
  53. package/dist/chunks/{studio-b29h7g7s.js → studio-693nhyky.js} +4 -4
  54. package/dist/chunks/{studio-654fxcr0.js → studio-7pgy3xaf.js} +4 -4
  55. package/dist/chunks/{studio-k9pwvmz8.js → studio-888knnkc.js} +4 -4
  56. package/dist/chunks/{studio-b5rxwkj2.js → studio-9mqfamps.js} +4 -4
  57. package/dist/chunks/{studio-011em5cf.js → studio-a7xqhc3n.js} +4 -4
  58. package/dist/chunks/{studio-mf41717j.js → studio-aje966s9.js} +4 -4
  59. package/dist/chunks/studio-aqmph6k4.js +5 -0
  60. package/dist/chunks/studio-c6w3tq7t.js +5 -0
  61. package/dist/chunks/{studio-tzs6njnb.js → studio-c6zdm25z.js} +7 -7
  62. package/dist/chunks/{studio-1pxvgx1t.js → studio-c9d3pxf7.js} +4 -4
  63. package/dist/chunks/{studio-pvj970st.js → studio-ceb34dxj.js} +4 -4
  64. package/dist/chunks/studio-cvbkkfqb.js +5 -0
  65. package/dist/chunks/{studio-sadckcpp.js → studio-cxt150pj.js} +5 -5
  66. package/dist/chunks/{studio-891t1tv9.js → studio-dkn3x3hz.js} +4 -4
  67. package/dist/chunks/studio-e6re3rk1.js +5 -0
  68. package/dist/chunks/{studio-9n2j9nqk.js → studio-edbtvcd1.js} +4 -4
  69. package/dist/chunks/{studio-wr0pq67n.js → studio-eh0rac07.js} +4 -4
  70. package/dist/chunks/{studio-mm8x32gg.js → studio-evwgp1ym.js} +4 -4
  71. package/dist/chunks/{studio-9tkaykt5.js → studio-f195h2hq.js} +4 -4
  72. package/dist/chunks/{studio-0nm2skex.js → studio-fqq890ap.js} +3 -3
  73. package/dist/chunks/{studio-02y9cb4h.js → studio-frb0qbf7.js} +5 -5
  74. package/dist/chunks/{studio-je15p5v8.js → studio-gfn1kpxj.js} +5 -5
  75. package/dist/chunks/{studio-ymsjnvp9.js → studio-gh97gngt.js} +4 -4
  76. package/dist/chunks/{studio-zkvyd3ev.js → studio-gp02nqnw.js} +4 -4
  77. package/dist/chunks/{studio-5fc0728f.js → studio-hjbg8d5y.js} +7 -7
  78. package/dist/chunks/{studio-dtmdjmqb.js → studio-j6gnhvaa.js} +4 -4
  79. package/dist/chunks/{studio-d007j0z3.js → studio-mwxgyn9h.js} +6 -6
  80. package/dist/chunks/{studio-36s6qjgq.js → studio-n78gjfy8.js} +4 -4
  81. package/dist/chunks/studio-nbxqcgvf.js +5 -0
  82. package/dist/chunks/{studio-ks0ecgjx.js → studio-nt06yybj.js} +4 -4
  83. package/dist/chunks/{studio-ebt49j3c.js → studio-p7c1agz0.js} +5 -5
  84. package/dist/chunks/{studio-y164brht.js → studio-pwqfmt4p.js} +4 -4
  85. package/dist/chunks/{studio-6zyt56ek.js → studio-r3kaka4d.js} +4 -4
  86. package/dist/chunks/{studio-tj28pxa1.js → studio-semd1s8s.js} +4 -4
  87. package/dist/chunks/studio-skzanb0k.js +5 -0
  88. package/dist/chunks/{studio-z1vdxq0e.js → studio-spf2skgc.js} +4 -4
  89. package/dist/chunks/{studio-7w04zw8h.js → studio-st75kn99.js} +5 -5
  90. package/dist/chunks/{studio-dmymvk2j.js → studio-sxc0zvpt.js} +3 -3
  91. package/dist/chunks/studio-t1eedsyx.js +4 -0
  92. package/dist/chunks/{studio-153mwkyq.js → studio-tp642n5g.js} +4 -4
  93. package/dist/chunks/{studio-r7yeeqc4.js → studio-tw7rre6s.js} +4 -4
  94. package/dist/chunks/{studio-t0v47dpc.js → studio-tzegmy4w.js} +4 -4
  95. package/dist/chunks/studio-vavmwshr.js +5 -0
  96. package/dist/chunks/{studio-805fwp9k.js → studio-wma2c6g8.js} +4 -4
  97. package/dist/chunks/studio-x92tgnee.js +64 -0
  98. package/dist/chunks/studio-xk0z6msk.js +5 -0
  99. package/dist/chunks/{studio-njmg0e73.js → studio-xry7tk3z.js} +5 -5
  100. package/dist/chunks/{studio-sgzss898.js → studio-xta2vxp6.js} +7 -7
  101. package/dist/chunks/{studio-jsr14n1s.js → studio-xy3tv3az.js} +4 -4
  102. package/dist/chunks/{studio-6k1eswp9.js → studio-y7jehjrp.js} +4 -4
  103. package/dist/chunks/{studio-81twpmzj.js → studio-yr04mv2j.js} +4 -4
  104. package/dist/chunks/{studio-gj4j9e7k.js → studio-zd4kayn2.js} +4 -4
  105. package/dist/chunks/{studio-w8w9frjy.js → studio-zpnk016a.js} +12 -12
  106. package/dist/chunks/{tsMode-eq26t9gs.js → tsMode-k9s9fcag.js} +4 -4
  107. package/dist/chunks/workspace-b69mgm0e.js +4 -0
  108. package/dist/chunks/{ws-client-3gxqp06s.js → ws-client-0ja08haa.js} +3 -3
  109. package/dist/manifest.json +62 -62
  110. package/dist/studio.js +95 -95
  111. package/package.json +3 -3
  112. package/src/canvas/stage-scrollbar.ts +7 -7
  113. package/src/files/file-ops.ts +5 -8
  114. package/src/panels/jump-bar.ts +132 -158
  115. package/src/panels/pane-context.ts +8 -8
  116. package/src/panels/pane-grid.ts +18 -26
  117. package/src/panels/style-panel.ts +2 -2
  118. package/src/services/context-manager.ts +41 -70
  119. package/src/services/document-assistant.ts +12 -3
  120. package/src/services/harness/chat-reducer.ts +115 -0
  121. package/src/services/harness/turn-hooks.ts +50 -0
  122. package/src/services/tool-executor.ts +69 -288
  123. package/src/services/tool-outcomes.ts +12 -10
  124. package/src/studio.ts +2 -5
  125. package/src/surfaces/block-action-bar.json +6 -1
  126. package/src/surfaces/jump-bar.json +9 -12
  127. package/src/surfaces/jump-bar.ts +15 -16
  128. package/src/surfaces/pane-context.json +2 -2
  129. package/src/surfaces/pane-grid.json +8 -25
  130. package/src/surfaces/pane-grid.ts +7 -8
  131. package/src/surfaces/statusbar.json +71 -3
  132. package/src/surfaces/statusbar.ts +66 -139
  133. package/src/surfaces/style-panel.json +3 -3
  134. package/src/surfaces/style-panel.ts +11 -1
  135. package/dist/chunks/adapter-6zhjbpmd.js +0 -4
  136. package/dist/chunks/cf-settings-fpa485q3.js +0 -4
  137. package/dist/chunks/components-b0vh4sq8.js +0 -4
  138. package/dist/chunks/content-source-z5t0d0h9.js +0 -4
  139. package/dist/chunks/editor-7e23n4a8.js +0 -4
  140. package/dist/chunks/entry-commands-5cm6aba2.js +0 -4
  141. package/dist/chunks/file-ops-hv7gf757.js +0 -4
  142. package/dist/chunks/files-kb0epdxf.js +0 -4
  143. package/dist/chunks/format-host-eg1wy2bp.js +0 -4
  144. package/dist/chunks/fs-events-ky3vxfmh.js +0 -4
  145. package/dist/chunks/git-panel-yyg3bzp2.js +0 -4
  146. package/dist/chunks/iframe-host-9wdf0hes.js +0 -4
  147. package/dist/chunks/index-2tf0m2ne.js +0 -4
  148. package/dist/chunks/jx-validate-7w11db9v.js +0 -4
  149. package/dist/chunks/layers-7zasrpse.js +0 -4
  150. package/dist/chunks/layers-panel-9nzc4fdq.js +0 -4
  151. package/dist/chunks/lit-html-5qr7nbng.js +0 -4
  152. package/dist/chunks/media-picker-maevn9d4.js +0 -4
  153. package/dist/chunks/menu-wq6q0v3y.js +0 -4
  154. package/dist/chunks/monaco-buffer-9ry2rn5x.js +0 -4
  155. package/dist/chunks/notify-g91gjd6w.js +0 -4
  156. package/dist/chunks/platform-jdwjp2vt.js +0 -4
  157. package/dist/chunks/publish-panel-8yym447p.js +0 -4
  158. package/dist/chunks/quick-search-9bzm9kh3.js +0 -4
  159. package/dist/chunks/references-znssfqvq.js +0 -4
  160. package/dist/chunks/right-panel-czbzktm8.js +0 -4
  161. package/dist/chunks/section-registry-0rtq73qs.js +0 -4
  162. package/dist/chunks/studio-0mm38y62.js +0 -5
  163. package/dist/chunks/studio-27tc8q0h.js +0 -5
  164. package/dist/chunks/studio-7djymzt2.js +0 -5
  165. package/dist/chunks/studio-ant1zbp3.js +0 -5
  166. package/dist/chunks/studio-eb5sty2k.js +0 -5
  167. package/dist/chunks/studio-frv7vjhw.js +0 -5
  168. package/dist/chunks/studio-mb8jhtr4.js +0 -64
  169. package/dist/chunks/studio-nvykrgbj.js +0 -4
  170. package/dist/chunks/studio-nw4q9sdn.js +0 -5
  171. package/dist/chunks/studio-p6avneaq.js +0 -5
  172. package/dist/chunks/studio-vz7b43ng.js +0 -5
  173. package/dist/chunks/workspace-emt8454d.js +0 -4
@@ -5,27 +5,28 @@
5
5
  * `workspace.panes` has been a real data model since the shell redesign's chrome phase: an ordered
6
6
  * list, a focused id, a split, an unsplit. Nothing drew it. `index.html` declared ONE each of
7
7
  * `#tab-strip`, `#jump-bar`, `#pane-chrome` and `#canvas-wrap` as flat siblings in `#app`'s grid —
8
- * four surfaces that belong to a PANE, laid out as rows and columns of the APPLICATION — so the
9
- * shell could model two panes and had somewhere to put exactly one. §18.3's stage handover was the
8
+ * surfaces that belong to a PANE, laid out as rows and columns of the APPLICATION — so the shell
9
+ * could model two panes and had somewhere to put exactly one. §18.3's stage handover was the
10
10
  * workaround: one stage, taken by whichever pane had focus, releasing the loser's artboards on the
11
11
  * way past. This module is what that scaffolding was standing in for, and the handover is deleted
12
12
  * with it.
13
13
  *
14
14
  * **The document owns the frame; this module owns the decisions.** `surfaces/pane-grid.json` is the
15
- * cells, the five boxes inside each one, the splitter between two of them and every rule that lays
15
+ * cells, the four boxes inside each one, the splitter between two of them and every rule that lays
16
16
  * them out; `surfaces/pane-grid.ts` is the mount. What is left here is the flow: which panes there
17
17
  * are, what a cell's stage is furnished with, in what ORDER a departing cell is taken apart, and
18
18
  * what the grid's own tracks are. Everything a cell CONTAINS still arrives through the module that
19
19
  * owns it — `canvas/canvas-render.ts` is the render root of the stage and this document puts
20
- * nothing inside it, `panels/jump-bar.ts` and `panels/pane-context.ts` are handed the jump and
21
- * chrome boxes, `panels/tab-strip.ts` is handed the strip, and this module makes the fifth — the
22
- * right-edge drop zone — a pragmatic target itself.
20
+ * nothing inside it, `panels/pane-context.ts` is handed the chrome box, `panels/tab-strip.ts` is
21
+ * handed the strip, and this module makes the fourth — the right-edge drop zone — a pragmatic
22
+ * target itself. (The jump bar had a box here too, until it moved to the status bar: a pane's
23
+ * address is now the focused pane's breadcrumb trail along the foot of the window.)
23
24
  *
24
25
  * **Three properties survived the conversion, and each is structural rather than remembered:**
25
26
  *
26
27
  * 1. _A pane is complete before it is published._ The runtime builds a `$map` row inside its own
27
- * effect scope and inserts it afterwards, so the five `cellPart` calls below — the surface
28
- * record, the gestures, the two bars' mounts, the pane-focus listener, the drop zone's own
28
+ * effect scope and inserts it afterwards, so the `cellPart` calls below — the surface record,
29
+ * the gestures, the context bar's mount, the pane-focus listener, the drop zone's own
29
30
  * registration — all happen while the cell is still detached. There is no frame in which a cell
30
31
  * exists with no stage inside it.
31
32
  * 2. _A pane's node is never re-parented._ The repeater is keyed on the pane id and the runtime's
@@ -81,7 +82,6 @@ import { releaseCanvasHosts } from "../canvas/iframe-host";
81
82
  import { installStageGestures } from "../editor/shortcuts";
82
83
  import { scheduleCanvasRender } from "../canvas/canvas-render";
83
84
  import { paneRegion, paneStripRegion } from "../ui/regions";
84
- import { attachJumpBarHost } from "./jump-bar";
85
85
  import { attachPaneChromeHost } from "./pane-context";
86
86
  import { MAX_PANES, focusPane, workspace } from "../workspace/workspace";
87
87
  import { mountPaneGridSurface } from "../surfaces/pane-grid";
@@ -89,12 +89,11 @@ import type { EffectScope } from "@vue/reactivity";
89
89
  import type { CanvasSurface } from "../canvas/canvas-surface";
90
90
  import type { PaneCellPart, PaneGridRow, PaneGridSurface } from "../surfaces/pane-grid";
91
91
 
92
- /** One drawn pane: its root and the five surfaces inside it. */
92
+ /** One drawn pane: its root and the four surfaces inside it. */
93
93
  export interface PaneCell {
94
94
  paneId: string;
95
95
  root: HTMLElement;
96
96
  strip: HTMLElement;
97
- jump: HTMLElement;
98
97
  chrome: HTMLElement;
99
98
  stage: HTMLElement;
100
99
  dropZone: HTMLElement;
@@ -162,7 +161,6 @@ function cellState(paneId: string): CellState {
162
161
  const cell: CellState = {
163
162
  chrome: null as unknown as HTMLElement,
164
163
  dropZone: null as unknown as HTMLElement,
165
- jump: null as unknown as HTMLElement,
166
164
  paneId,
167
165
  releaseDropZone: null,
168
166
  releaseGestures: null,
@@ -283,15 +281,15 @@ function attachDropZone(paneId: string, element: HTMLElement): () => void {
283
281
  }
284
282
 
285
283
  /**
286
- * One of a cell's six boxes has been created. Fill the record in, and wire what belongs to it.
284
+ * One of a cell's five boxes has been created. Fill the record in, and wire what belongs to it.
287
285
  *
288
- * The two bars are HANDED their host rather than resolving a region, the same way
289
- * `panels/frontmatter-panel.ts` is handed the stage. They cannot resolve one the way the tab strip
286
+ * The context bar is HANDED its host rather than resolving a region, the same way
287
+ * `panels/frontmatter-panel.ts` is handed the stage. It cannot resolve one the way the tab strip
290
288
  * does: the strip's host carries `pane.<id>/tabs` and nothing inside it re-stamps that id, while
291
- * the jump bar and the context bar both stamp `pane.<id>/jump` and `pane.<id>/context` on markup
292
- * they render INSIDE their two boxes — so a region on the wrapper as well would put the same id on
293
- * two nested elements, and `resolveRegion` takes the LAST match. Sixty shots crop
294
- * `pane.primary/context`; a second, larger element carrying it is a silently widened crop.
289
+ * the context bar stamps `pane.<id>/context` on markup it renders INSIDE its box — so a region on
290
+ * the wrapper as well would put the same id on two nested elements, and `resolveRegion` takes the
291
+ * LAST match. Sixty shots crop `pane.primary/context`; a second, larger element carrying it is a
292
+ * silently widened crop.
295
293
  */
296
294
  function cellPart(paneId: string, part: PaneCellPart, element: HTMLElement): void {
297
295
  const cell = cellState(paneId);
@@ -304,11 +302,6 @@ function cellPart(paneId: string, part: PaneCellPart, element: HTMLElement): voi
304
302
  cell.strip = element;
305
303
  return;
306
304
  }
307
- if (part === "jump") {
308
- cell.jump = element;
309
- attachJumpBarHost(paneId, element);
310
- return;
311
- }
312
305
  if (part === "chrome") {
313
306
  cell.chrome = element;
314
307
  attachPaneChromeHost(paneId, element);
@@ -333,7 +326,7 @@ function cellPart(paneId: string, part: PaneCellPart, element: HTMLElement): voi
333
326
  *
334
327
  * The order is the document's own, and each step needs the one before it:
335
328
  *
336
- * - The two bars go first, each disposing a mount whose DOM is inside this cell — the runtime that
329
+ * - The context bar goes first, disposing a mount whose DOM is inside this cell — the runtime that
337
330
  * owns it is about to be unreachable.
338
331
  * - `releaseCanvasHosts` goes before `disposePaneSurface` and while `stage` still CONTAINS its
339
332
  * frames, which is the whole reason it can find them. A frame released later is noticed only by
@@ -345,7 +338,6 @@ function cellPart(paneId: string, part: PaneCellPart, element: HTMLElement): voi
345
338
  function disposeCell(cell: CellState): void {
346
339
  cell.releaseDropZone?.();
347
340
  cell.releaseDropZone = null;
348
- attachJumpBarHost(cell.paneId, null);
349
341
  attachPaneChromeHost(cell.paneId, null);
350
342
  cell.releaseGestures?.();
351
343
  cell.releaseGestures = null;
@@ -813,7 +813,7 @@ function buttonGroup(
813
813
  return {
814
814
  icon,
815
815
  row: rowKey,
816
- selected: v === value,
816
+ checked: v === value ? ("true" as const) : ("false" as const),
817
817
  text: icon === "" ? v : "",
818
818
  title: v,
819
819
  value: v,
@@ -2112,7 +2112,7 @@ function rememberButtons(view: StylePanelView): void {
2112
2112
  for (const section of view.sections) {
2113
2113
  for (const row of section.rows) {
2114
2114
  if (row.widget === "buttons") {
2115
- _buttonValues.set(row.key, row.buttons.find((b) => b.selected)?.value ?? "");
2115
+ _buttonValues.set(row.key, row.buttons.find((b) => b.checked === "true")?.value ?? "");
2116
2116
  }
2117
2117
  }
2118
2118
  }
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Context-manager.js — Token budget management for the AI assistant
3
3
  *
4
- * Estimates token usage and trims conversation history before each send to keep the
5
- * context window within safe limits (specs/ai.md §2 and §2.1).
4
+ * Estimates token usage and trims conversation history before each send to keep the context window
5
+ * within safe limits (specs/ai.md §2 and §2.1).
6
6
  *
7
- * Strategy (MVP): keep the system prompt + the most recent messages, dropping the oldest
8
- * messages when the estimated total exceeds the configured token budget. Dropped messages
9
- * are replaced with a single summary note so the model knows history was truncated.
7
+ * Strategy (MVP): keep the system prompt + the most recent messages, dropping the oldest messages
8
+ * when the estimated total exceeds the configured token budget. Dropped messages are replaced with
9
+ * a single summary note so the model knows history was truncated.
10
10
  *
11
11
  * @license MIT
12
12
  */
13
13
 
14
14
  import type { createChatState } from "@jxsuite/ai/chat-state";
15
+ import {
16
+ SEAL_RELOADED,
17
+ repairToolPairs,
18
+ toChatMessages,
19
+ toLiveMessages,
20
+ } from "@jxsuite/ai/messages";
15
21
  import { modelContextWindow } from "./ai-models";
16
22
 
17
23
  /** Shape of a single entry returned by `chatState.toMessagesArray()`. */
@@ -273,91 +279,56 @@ export function trimContext(
273
279
  // ─── Orphaned tool calls ────────────────────────────────────────────────────
274
280
 
275
281
  /**
276
- * What a synthesized result says, so a reader and the model see the same reason. Exported so a
277
- * restore can tell a seal from a reply (services/tool-outcomes.ts).
282
+ * What a seal says for a call that was not cut off, so a reader and the model see the same reason.
283
+ * Exported so a restore can name the outcome of an ordinary call nothing answered
284
+ * (services/tool-outcomes.ts); `isSealContent` tells any seal from a reply.
278
285
  */
279
- export const UNANSWERED_TOOL_RESULT = JSON.stringify({
280
- success: false,
281
- error:
282
- "This tool call was never completed — the session was reloaded or the history was trimmed.",
283
- });
286
+ export const UNANSWERED_TOOL_RESULT = JSON.stringify({ success: false, error: SEAL_RELOADED });
284
287
 
285
288
  /**
286
289
  * Repair tool-call pairing in place, and report how many messages it touched.
287
290
  *
288
291
  * `toMessagesArray()` serializes the array verbatim: an assistant message with `tool_calls` becomes
289
292
  * a `tool_calls` request, and a `tool` message becomes a reply carrying `tool_call_id`. Providers
290
- * require the two to come in pairs, and **three ordinary things break the pairing**:
293
+ * require each reply directly after the request that made the call, and **four ordinary things
294
+ * break that**:
291
295
  *
292
296
  * 1. `saveSession` persists `msgs.slice(-MAX_PERSIST_MESSAGES)` and `restoreChat` pushes the result
293
297
  * back, so a session longer than the cap can be restored starting mid-pair.
294
298
  * 2. {@link trimContext} splices from the front for the token budget, with no more regard for pairs.
295
299
  * 3. A turn that stopped while a tool was still running — which `ask_user` makes routine, because its
296
300
  * tool is _designed_ to be outstanding — leaves a request with no reply.
301
+ * 4. A transcript written before one window ran one turn can hold a reply after a later message.
297
302
  *
298
303
  * Each of those produced a 400 on the NEXT send, from a conversation the user could see was fine.
299
- * So the repair belongs on the send path, after trimming, rather than at any one of the three
300
- * sites: a request with no reply gets a synthesized failure (keeping the assistant's text, which
301
- * dropping the message would lose), and a reply with no request is dropped.
304
+ * So the repair belongs on the send path, after trimming, rather than at any one of the sites. It
305
+ * is `repairToolPairs` (`@jxsuite/ai/messages`, specs/ai.md §3.4), the same repair the turn engine
306
+ * runs at turn start, applied to the chat store: pairing is per request rather than by call id
307
+ * across the transcript, because a provider may reuse `call_0` every round; a reply away from its
308
+ * request is moved back beside it; a request with no reply is sealed with a synthesized failure
309
+ * (keeping the assistant's text, which dropping the message would lose) that says whether the call
310
+ * was cut off mid-arguments; and a reply with no request is dropped.
311
+ *
312
+ * The store is rebuilt with one `splice`, and only when something changed. Every message the repair
313
+ * kept is the store's own object, which the streaming turn and the panel hold by identity, and the
314
+ * array itself is never reassigned.
302
315
  *
303
316
  * @param {ReturnType<typeof createChatState>} chatState
304
- * @returns {{ sealed: number; dropped: number }}
317
+ * @returns {{ dropped: number; moved: number; sealed: number }}
305
318
  */
306
319
  export function pruneOrphanToolMessages(chatState: ReturnType<typeof createChatState>): {
307
- sealed: number;
308
320
  dropped: number;
321
+ moved: number;
322
+ sealed: number;
309
323
  } {
310
- const { messages } = chatState;
311
-
312
- const requested = new Set<string>();
313
- const answered = new Set<string>();
314
- for (const msg of messages) {
315
- if (msg.role === "assistant" && msg.toolCalls) {
316
- for (const call of msg.toolCalls) {
317
- requested.add(call.id);
318
- }
319
- } else if (msg.role === "tool" && msg.toolCallId) {
320
- answered.add(msg.toolCallId);
321
- }
324
+ const live = chatState.messages;
325
+ const neutral = toChatMessages(live);
326
+ const { messages, sealed, dropped, moved } = repairToolPairs(neutral);
327
+ if (sealed.length + dropped.length + moved.length === 0) {
328
+ return { dropped: 0, moved: 0, sealed: 0 };
322
329
  }
323
-
324
- // Replies with no request, and replies with no id at all — neither can be serialized into a
325
- // Well-formed pair, and a reply is the half that carries no author text worth keeping.
326
- let dropped = 0;
327
- for (let i = messages.length - 1; i >= 0; i--) {
328
- const msg = messages[i]!;
329
- if (msg.role === "tool" && (!msg.toolCallId || !requested.has(msg.toolCallId))) {
330
- messages.splice(i, 1);
331
- dropped += 1;
332
- }
333
- }
334
-
335
- /* Requests with no reply. Walk backwards so each insertion is past the indices still to visit,
336
- and seal immediately after the requesting message — `toMessagesArray` emits in array order, so
337
- that is the only position the pair is adjacent in. */
338
- let sealed = 0;
339
- for (let i = messages.length - 1; i >= 0; i--) {
340
- const msg = messages[i]!;
341
- if (msg.role !== "assistant" || !msg.toolCalls) {
342
- continue;
343
- }
344
- const missing = msg.toolCalls.filter((call) => !answered.has(call.id));
345
- if (missing.length === 0) {
346
- continue;
347
- }
348
- messages.splice(
349
- i + 1,
350
- 0,
351
- ...missing.map((call, n) => ({
352
- content: UNANSWERED_TOOL_RESULT,
353
- id: `sealed_${call.id}_${n}`,
354
- role: "tool" as const,
355
- timestamp: msg.timestamp,
356
- toolCallId: call.id,
357
- })),
358
- );
359
- sealed += missing.length;
360
- }
361
-
362
- return { dropped, sealed };
330
+ const liveOf = new Map(neutral.map((message, index) => [message, live[index]!]));
331
+ const next = messages.map((message) => liveOf.get(message) ?? toLiveMessages([message])[0]!);
332
+ live.splice(0, live.length, ...next);
333
+ return { dropped: dropped.length, moved: moved.length, sealed: sealed.length };
363
334
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { createChatState, createProxyStreamingClient, createToolRegistry } from "@jxsuite/ai";
13
+ import { createTurnLock } from "@jxsuite/ai/harness";
13
14
  import { createSessionFacts } from "@jxsuite/ai/tools";
14
15
  import type { ProjectConfig } from "@jxsuite/schema/types";
15
16
  import { getPlatform } from "../platform";
@@ -233,14 +234,17 @@ export function createDocumentAssistant() {
233
234
  let controller: AbortController | null = null;
234
235
 
235
236
  /**
236
- * Whether a turn is in flight: true from the moment a send is accepted until its loop has ended.
237
+ * One window runs one turn. The lock is what decides it: a send is accepted only when it can take
238
+ * the lock, and holds it until its loop has ended.
237
239
  *
238
240
  * Not the chat's `status`, which belongs to the token stream: it reads idle while a round's tools
239
241
  * run, while a question waits on the author and during an import. Guarding on it let a second
240
242
  * send start a second turn beside the first, and let the composer offer Send in the middle of
241
- * one. One window runs one turn, and this is the fact that says whether it is running.
243
+ * one. `turnActive` is the lock's reactive reading, for the panel's effects.
242
244
  */
245
+ const turnLock = createTurnLock();
243
246
  const turnActive = shallowRef(false);
247
+ let turnCount = 0;
244
248
 
245
249
  /** Settles when the turn in flight has ended, finally included; already settled when none is. */
246
250
  let turnEnded: Promise<void> = Promise.resolve();
@@ -287,7 +291,7 @@ export function createDocumentAssistant() {
287
291
  }
288
292
 
289
293
  async function sendMessage(text: string) {
290
- if (!text.trim() || turnActive.value || chatState.status === "streaming") {
294
+ if (!text.trim() || turnLock.active !== null || chatState.status === "streaming") {
291
295
  return;
292
296
  }
293
297
 
@@ -320,6 +324,10 @@ export function createDocumentAssistant() {
320
324
  resolved inside the first request, on the armed signal. */
321
325
  controller = new AbortController();
322
326
  const { signal } = controller;
327
+ /* Taken here rather than at the guard so nothing above can leave it held. Everything since the
328
+ guard is synchronous, so no other send can have taken it in between: it is free. */
329
+ turnCount += 1;
330
+ const releaseTurn = turnLock.acquire(`send:${turnCount}`)!;
323
331
  turnActive.value = true;
324
332
  let settleTurnEnded!: () => void;
325
333
  turnEnded = new Promise<void>((settle) => {
@@ -363,6 +371,7 @@ export function createDocumentAssistant() {
363
371
  chatState.setError(error instanceof Error ? error.message : String(error));
364
372
  } finally {
365
373
  controller = null;
374
+ releaseTurn();
366
375
  turnActive.value = false;
367
376
  // Persist again once the stream settled so the completed reply (or the state
368
377
  // After an error/abort cleanup) survives a reload without another send.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Chat-reducer.ts — a turn's events, applied to Studio's chat store.
3
+ *
4
+ * The chat store is Studio's transcript of record: the panel renders it and the session store saves
5
+ * it. The turn engine (`@jxsuite/ai/harness`) reports what a turn does as events, and this is the
6
+ * one place they become chat-store calls, in the order the agent loop always made them (specs/ai.md
7
+ * §3.8): the recorded agent traces hold the two to the same sequence, call for call.
8
+ *
9
+ * @license MIT
10
+ */
11
+
12
+ import type { createChatState } from "@jxsuite/ai/chat-state";
13
+ import type { HarnessEvent, TurnOutcome } from "@jxsuite/ai/harness";
14
+ import type { ImportProgressEvent } from "../../types";
15
+ import { recordImportProgress } from "../import-run";
16
+
17
+ type ChatStore = ReturnType<typeof createChatState>;
18
+
19
+ /** How a turn's end reads in the transcript: the cap message, an error row, or nothing more. */
20
+ function applyOutcome(chat: ChatStore, outcome: TurnOutcome): void {
21
+ switch (outcome.kind) {
22
+ case "cap_partial": {
23
+ // A partial success is said as the assistant, not as a failure (specs/ai.md §3.2).
24
+ chat.beginAssistantTurn();
25
+ chat.appendDelta(outcome.cap?.text ?? "");
26
+ chat.finishStream("length");
27
+ break;
28
+ }
29
+ case "cap_failed":
30
+ case "empty":
31
+ case "error": {
32
+ /* `setError` alone, never after `finishStream`: it removes the round's partial message, and
33
+ only while that message is still the streaming one. */
34
+ chat.setError(outcome.error?.message ?? "");
35
+ break;
36
+ }
37
+ default: {
38
+ break;
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Apply one turn event to the chat store.
45
+ *
46
+ * Round one answers into the placeholder `sendMessage` already pushed, so only a later round opens
47
+ * a message. A round that failed, or a turn that drew nothing, is never finished: its error row
48
+ * removes the partial. `turn_start`, `tool_start` and `write` change nothing here; the ledger
49
+ * records writes itself. Nor does `transcript_repaired`: the send path ran the same repair on the
50
+ * chat store before the turn began (`pruneOrphanToolMessages`), so the store is already the history
51
+ * the engine repaired.
52
+ *
53
+ * @param {ChatStore} chat
54
+ * @param {HarnessEvent} event
55
+ */
56
+ export function applyHarnessEvent(chat: ChatStore, event: HarnessEvent): void {
57
+ switch (event.type) {
58
+ case "round_start": {
59
+ if (event.round > 1) {
60
+ chat.beginAssistantTurn();
61
+ }
62
+ break;
63
+ }
64
+ case "text": {
65
+ chat.appendDelta(event.text);
66
+ break;
67
+ }
68
+ case "reasoning": {
69
+ /* Kept on the turn, not shown as answer text: the next round has to replay it
70
+ (specs/ai.md §2.2). */
71
+ chat.appendReasoning(event.text);
72
+ break;
73
+ }
74
+ case "tool_call_start": {
75
+ chat.appendToolCallStart(event.callId, event.name);
76
+ break;
77
+ }
78
+ case "tool_call_delta": {
79
+ chat.appendToolCallDelta(event.callId, event.args);
80
+ break;
81
+ }
82
+ case "tool_call_end": {
83
+ chat.appendToolCallEnd(event.callId);
84
+ break;
85
+ }
86
+ case "usage": {
87
+ /* The provider's own count replaces the four-characters-per-token estimate; the prompt it
88
+ covered is recorded with it, because the next send rebuilds the prompt. */
89
+ chat.recordUsage(event.frame, { systemTokens: event.systemTokens });
90
+ break;
91
+ }
92
+ case "round_end": {
93
+ if (!event.dropped) {
94
+ chat.finishStream(event.stopReason);
95
+ }
96
+ break;
97
+ }
98
+ case "tool_progress": {
99
+ recordImportProgress(event.callId, event.progress as unknown as ImportProgressEvent);
100
+ break;
101
+ }
102
+ case "tool_result": {
103
+ chat.appendToolResult(event.callId, event.result);
104
+ chat.pushToolResultMessage(event.callId, JSON.stringify(event.result));
105
+ break;
106
+ }
107
+ case "turn_end": {
108
+ applyOutcome(chat, event.outcome);
109
+ break;
110
+ }
111
+ default: {
112
+ break;
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Turn-hooks.ts — what Studio does between the turn engine's steps.
3
+ *
4
+ * @license MIT
5
+ */
6
+
7
+ import type { TurnHooks } from "@jxsuite/ai/harness";
8
+ import { ensureProxyProbe, resetModelCache } from "../ai-models";
9
+
10
+ /** What Studio's hooks act on, injected so the hooks hold no state of their own. */
11
+ export interface StudioTurnHookDeps {
12
+ /** Whether the conversation the turn answers has left the transcript (another chat was opened). */
13
+ superseded: () => boolean;
14
+ /** Called once a call settles into a transcript that replaced the turn's own. */
15
+ onSuperseded: () => void;
16
+ /** Re-anchor the undo batch on the document the tools now edit (specs/ai.md §3.3). */
17
+ reanchor: () => void;
18
+ }
19
+
20
+ /**
21
+ * Studio's turn hooks.
22
+ *
23
+ * - **After each call**, a turn whose conversation has been replaced stops there: its reply belongs
24
+ * to a request that is no longer on screen, and writing it would land a stray `tool` message in
25
+ * the other conversation. Otherwise the undo batch follows the document the tools moved to, which
26
+ * a tool, project adoption or the author clicking another tab can each change.
27
+ * - **On a stream error**, a lapsed hosted grant (`cf_reconnect_required`) drops the model reading
28
+ * and re-probes (specs/ai.md §2.1): the probe settles once at boot, so without this every gate
29
+ * keeps offering an assistant that cannot answer, and the failed send is the only evidence.
30
+ *
31
+ * @param {StudioTurnHookDeps} deps
32
+ * @returns {TurnHooks}
33
+ */
34
+ export function studioTurnHooks(deps: StudioTurnHookDeps): TurnHooks {
35
+ return {
36
+ afterTool() {
37
+ if (deps.superseded()) {
38
+ deps.onSuperseded();
39
+ return;
40
+ }
41
+ deps.reanchor();
42
+ },
43
+ onStreamError(error) {
44
+ if (error.code === "cf_reconnect_required") {
45
+ resetModelCache();
46
+ ensureProxyProbe();
47
+ }
48
+ },
49
+ };
50
+ }