@jxsuite/studio 5.5.0 → 5.6.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 (221) hide show
  1. package/LICENSE +1 -1
  2. package/dist/chunks/{2020-g8mwpbkq.js → 2020-zvax9fmb.js} +3 -3
  3. package/dist/chunks/adapter-6zhjbpmd.js +4 -0
  4. package/dist/chunks/cf-account-picker-zfycs4k2.js +4 -0
  5. package/dist/chunks/cf-settings-fpa485q3.js +4 -0
  6. package/dist/chunks/components-b0vh4sq8.js +4 -0
  7. package/dist/chunks/content-source-z5t0d0h9.js +4 -0
  8. package/dist/chunks/convert-to-repeater-x19jr6c7.js +4 -0
  9. package/dist/chunks/editor-7e23n4a8.js +4 -0
  10. package/dist/chunks/entry-commands-5cm6aba2.js +4 -0
  11. package/dist/chunks/extension-sections-y90sa0n4.js +4 -0
  12. package/dist/chunks/file-ops-hv7gf757.js +4 -0
  13. package/dist/chunks/files-kb0epdxf.js +4 -0
  14. package/dist/chunks/format-host-eg1wy2bp.js +4 -0
  15. package/dist/chunks/fs-events-ky3vxfmh.js +4 -0
  16. package/dist/chunks/git-panel-yyg3bzp2.js +4 -0
  17. package/dist/chunks/grid-view-5mqv1cbd.css +1 -0
  18. package/dist/chunks/grid-view-d9mybhn0.js +51 -0
  19. package/dist/chunks/iframe-host-9wdf0hes.js +4 -0
  20. package/dist/chunks/index-2tf0m2ne.js +4 -0
  21. package/dist/chunks/{index-arg188jq.js → index-vnwy9d44.js} +3 -3
  22. package/dist/chunks/{javascript-zcdmye2k.js → javascript-f0nrbdjb.js} +2 -2
  23. package/dist/chunks/{jsonMode-6x5e1nbf.js → jsonMode-nfa90dtf.js} +5 -5
  24. package/dist/chunks/jx-validate-7w11db9v.js +4 -0
  25. package/dist/chunks/layers-7zasrpse.js +4 -0
  26. package/dist/chunks/layers-panel-9nzc4fdq.js +4 -0
  27. package/dist/chunks/lit-html-5qr7nbng.js +4 -0
  28. package/dist/chunks/media-picker-maevn9d4.js +4 -0
  29. package/dist/chunks/menu-wq6q0v3y.js +4 -0
  30. package/dist/chunks/{monaco-binding-4az2kh5p.js → monaco-binding-antn79jf.js} +3 -3
  31. package/dist/chunks/monaco-buffer-9ry2rn5x.js +4 -0
  32. package/dist/chunks/{monaco-setup-d7mwkcx3.js → monaco-setup-k8s40vwg.js} +3 -3
  33. package/dist/chunks/notify-g91gjd6w.js +4 -0
  34. package/dist/chunks/platform-jdwjp2vt.js +4 -0
  35. package/dist/chunks/publish-panel-8yym447p.js +4 -0
  36. package/dist/chunks/quick-search-9bzm9kh3.js +4 -0
  37. package/dist/chunks/references-znssfqvq.js +4 -0
  38. package/dist/chunks/right-panel-czbzktm8.js +4 -0
  39. package/dist/chunks/section-registry-0rtq73qs.js +4 -0
  40. package/dist/chunks/studio-011em5cf.js +5 -0
  41. package/dist/chunks/studio-02y9cb4h.js +6 -0
  42. package/dist/chunks/{studio-7gqyhjns.js → studio-09n3fyy0.js} +4 -4
  43. package/dist/chunks/{studio-xcng8nx7.js → studio-0c3fpdqj.js} +5 -5
  44. package/dist/chunks/studio-0mm38y62.js +5 -0
  45. package/dist/chunks/studio-0nm2skex.js +4 -0
  46. package/dist/chunks/{studio-73czgk6a.js → studio-153mwkyq.js} +4 -4
  47. package/dist/chunks/{studio-f8jyp45j.js → studio-1pxvgx1t.js} +4 -4
  48. package/dist/chunks/studio-27tc8q0h.js +5 -0
  49. package/dist/chunks/studio-28pz4s1k.js +5 -0
  50. package/dist/chunks/{studio-x5esgzct.js → studio-36s6qjgq.js} +4 -4
  51. package/dist/chunks/studio-4yztve2t.js +5 -0
  52. package/dist/chunks/{studio-x9sagq2d.js → studio-5fc0728f.js} +7 -7
  53. package/dist/chunks/{studio-7jazdy6y.js → studio-654fxcr0.js} +4 -4
  54. package/dist/chunks/studio-6exg5z4x.js +4 -0
  55. package/dist/chunks/studio-6k1eswp9.js +5 -0
  56. package/dist/chunks/{studio-5wfrgee9.js → studio-6zyt56ek.js} +4 -4
  57. package/dist/chunks/studio-7djymzt2.js +5 -0
  58. package/dist/chunks/{studio-b4epb26c.js → studio-7w04zw8h.js} +7 -7
  59. package/dist/chunks/studio-805fwp9k.js +5 -0
  60. package/dist/chunks/{studio-k1eaqyzm.js → studio-81twpmzj.js} +4 -4
  61. package/dist/chunks/studio-891t1tv9.js +5 -0
  62. package/dist/chunks/{studio-5n1km79v.js → studio-9n2j9nqk.js} +4 -4
  63. package/dist/chunks/{studio-he2fhrgp.js → studio-9tkaykt5.js} +4 -4
  64. package/dist/chunks/studio-ant1zbp3.js +5 -0
  65. package/dist/chunks/studio-b29h7g7s.js +5 -0
  66. package/dist/chunks/studio-b5rxwkj2.js +5 -0
  67. package/dist/chunks/studio-d007j0z3.js +7 -0
  68. package/dist/chunks/studio-dknr8m7b.js +14 -0
  69. package/dist/chunks/{studio-eq27rrr0.js → studio-dmymvk2j.js} +3 -3
  70. package/dist/chunks/studio-dtmdjmqb.js +5 -0
  71. package/dist/chunks/studio-eb5sty2k.js +5 -0
  72. package/dist/chunks/{studio-hc9q4j24.js → studio-ebt49j3c.js} +5 -5
  73. package/dist/chunks/studio-eegyf5vg.js +5 -0
  74. package/dist/chunks/studio-frv7vjhw.js +5 -0
  75. package/dist/chunks/studio-ft8pfpp0.js +5 -0
  76. package/dist/chunks/{studio-mhhc9btt.js → studio-gj4j9e7k.js} +4 -4
  77. package/dist/chunks/{studio-hf4qdjc8.js → studio-gxna6yta.js} +4 -4
  78. package/dist/chunks/studio-je15p5v8.js +6 -0
  79. package/dist/chunks/{studio-21enxdr8.js → studio-jsr14n1s.js} +4 -4
  80. package/dist/chunks/studio-k9pwvmz8.js +5 -0
  81. package/dist/chunks/{studio-p1eggxcf.js → studio-ks0ecgjx.js} +3 -3
  82. package/dist/chunks/studio-mb8jhtr4.js +64 -0
  83. package/dist/chunks/studio-mf41717j.js +5 -0
  84. package/dist/chunks/{studio-625ezz59.js → studio-mm8x32gg.js} +4 -4
  85. package/dist/chunks/{studio-pkkw32j0.js → studio-n2e133d6.js} +4 -4
  86. package/dist/chunks/{studio-c4pnt1c6.js → studio-njmg0e73.js} +5 -5
  87. package/dist/chunks/studio-nvykrgbj.js +4 -0
  88. package/dist/chunks/studio-nw4q9sdn.js +5 -0
  89. package/dist/chunks/studio-p6avneaq.js +5 -0
  90. package/dist/chunks/studio-pvj970st.js +5 -0
  91. package/dist/chunks/studio-r7yeeqc4.js +5 -0
  92. package/dist/chunks/studio-s85vah66.js +6 -0
  93. package/dist/chunks/studio-sadckcpp.js +7 -0
  94. package/dist/chunks/studio-sgzss898.js +9 -0
  95. package/dist/chunks/{studio-7by9a93k.js → studio-t0v47dpc.js} +4 -4
  96. package/dist/chunks/{studio-jnb285we.js → studio-tj28pxa1.js} +4 -4
  97. package/dist/chunks/{studio-6j2jn55a.js → studio-tzs6njnb.js} +8 -8
  98. package/dist/chunks/studio-vz7b43ng.js +5 -0
  99. package/dist/chunks/studio-w8w9frjy.js +16 -0
  100. package/dist/chunks/{studio-xc8ebsts.js → studio-wr0pq67n.js} +4 -4
  101. package/dist/chunks/{studio-8r4y2x0z.js → studio-y164brht.js} +4 -4
  102. package/dist/chunks/studio-y17505ge.js +928 -0
  103. package/dist/chunks/studio-ymsjnvp9.js +5 -0
  104. package/dist/chunks/studio-z1vdxq0e.js +5 -0
  105. package/dist/chunks/studio-z47mm077.js +10 -0
  106. package/dist/chunks/studio-zkkdbbvd.js +9 -0
  107. package/dist/chunks/{studio-awqghqxv.js → studio-zkvyd3ev.js} +4 -4
  108. package/dist/chunks/{tsMode-rtwt7w1a.js → tsMode-eq26t9gs.js} +5 -5
  109. package/dist/chunks/workspace-emt8454d.js +4 -0
  110. package/dist/chunks/ws-client-3gxqp06s.js +4 -0
  111. package/dist/iframe-entry.js +14 -14
  112. package/dist/manifest.json +62 -59
  113. package/dist/studio.css +1 -1
  114. package/dist/studio.js +115 -117
  115. package/package.json +5 -5
  116. package/src/account/action-flow.ts +294 -0
  117. package/src/account/action-required.ts +68 -0
  118. package/src/account-status.ts +29 -1
  119. package/src/browse/library-model.ts +3 -1
  120. package/src/browse/library-preview.ts +6 -2
  121. package/src/canvas/asset-refs.ts +3 -1
  122. package/src/canvas/asset-resolve.ts +79 -1
  123. package/src/canvas/iframe-entry.ts +38 -1
  124. package/src/canvas/iframe-host.ts +85 -0
  125. package/src/canvas/iframe-protocol.ts +13 -0
  126. package/src/files/asset-versions.ts +294 -0
  127. package/src/files/files.ts +61 -9
  128. package/src/files/fs-events.ts +279 -28
  129. package/src/files/media-meta.ts +2 -1
  130. package/src/files/media-paths.ts +5 -2
  131. package/src/files/media-upload.ts +5 -1
  132. package/src/new-project/add-repo-modal.ts +12 -1
  133. package/src/new-project/new-project-modal.ts +12 -1
  134. package/src/panels/git-panel.ts +25 -2
  135. package/src/platform-errors.ts +61 -1
  136. package/src/platforms/cloud.ts +389 -50
  137. package/src/platforms/devserver.ts +17 -1
  138. package/src/publish/pages-service.ts +17 -7
  139. package/src/services/ai-models.ts +30 -6
  140. package/src/services/ai-system-prompt.ts +81 -51
  141. package/src/settings/preferences-accounts.ts +42 -3
  142. package/src/settings/preferences-dialog.ts +8 -1
  143. package/src/studio.ts +18 -1
  144. package/src/surfaces/dialog.ts +5 -0
  145. package/src/surfaces/welcome.json +86 -1
  146. package/src/surfaces/welcome.ts +61 -2
  147. package/src/types.ts +84 -2
  148. package/src/ui/ai-managed-connect.ts +41 -4
  149. package/src/ui/media-picker.ts +2 -1
  150. package/dist/chunks/adapter-bsbs78x9.js +0 -4
  151. package/dist/chunks/cf-account-picker-gnm7xeqb.js +0 -4
  152. package/dist/chunks/cf-settings-9y6jyscp.js +0 -4
  153. package/dist/chunks/components-s51h3b7c.js +0 -4
  154. package/dist/chunks/content-source-ty3be0qg.js +0 -4
  155. package/dist/chunks/convert-to-repeater-c3e7q61z.js +0 -4
  156. package/dist/chunks/editor-t002b41m.js +0 -4
  157. package/dist/chunks/entry-commands-sb72e9gy.js +0 -4
  158. package/dist/chunks/extension-sections-ktn2ntnj.js +0 -4
  159. package/dist/chunks/file-ops-78zeytkm.js +0 -4
  160. package/dist/chunks/files-gatzrz8v.js +0 -4
  161. package/dist/chunks/format-host-r59b1651.js +0 -4
  162. package/dist/chunks/fs-events-f8h91ndt.js +0 -4
  163. package/dist/chunks/git-panel-y12zf7nj.js +0 -4
  164. package/dist/chunks/grid-view-rfd8b2pg.js +0 -51
  165. package/dist/chunks/grid-view-tafenfm2.css +0 -1
  166. package/dist/chunks/iframe-host-vsrsj20g.js +0 -4
  167. package/dist/chunks/index-5aghyrfh.js +0 -4
  168. package/dist/chunks/jx-validate-653krkrf.js +0 -4
  169. package/dist/chunks/layers-32p8gtae.js +0 -4
  170. package/dist/chunks/layers-panel-rs58m105.js +0 -4
  171. package/dist/chunks/lit-html-54bmkwz3.js +0 -4
  172. package/dist/chunks/media-picker-5bb04wct.js +0 -4
  173. package/dist/chunks/menu-cbha7w2e.js +0 -4
  174. package/dist/chunks/monaco-buffer-9vbbyfr1.js +0 -4
  175. package/dist/chunks/notify-dc1nz5ar.js +0 -4
  176. package/dist/chunks/platform-2m49s1zf.js +0 -4
  177. package/dist/chunks/publish-panel-nx3ycmma.js +0 -4
  178. package/dist/chunks/quick-search-yzrzh9x3.js +0 -4
  179. package/dist/chunks/references-vx7mbj9f.js +0 -4
  180. package/dist/chunks/right-panel-7jzpbxja.js +0 -4
  181. package/dist/chunks/section-registry-gjryse41.js +0 -4
  182. package/dist/chunks/studio-3znfhcxg.js +0 -5
  183. package/dist/chunks/studio-58qam27v.js +0 -4
  184. package/dist/chunks/studio-6j59fwaw.js +0 -5
  185. package/dist/chunks/studio-7z69g1gx.js +0 -5
  186. package/dist/chunks/studio-93j3v6zx.js +0 -5
  187. package/dist/chunks/studio-9gf6v8zy.js +0 -5
  188. package/dist/chunks/studio-9rhmettf.js +0 -4
  189. package/dist/chunks/studio-ac5aq677.js +0 -10
  190. package/dist/chunks/studio-as65vbns.js +0 -5
  191. package/dist/chunks/studio-bkv0sga4.js +0 -6
  192. package/dist/chunks/studio-bttmqef4.js +0 -5
  193. package/dist/chunks/studio-c3y9d41k.js +0 -5
  194. package/dist/chunks/studio-ccegp7gz.js +0 -9
  195. package/dist/chunks/studio-chxabn98.js +0 -6
  196. package/dist/chunks/studio-cktcdsxz.js +0 -16
  197. package/dist/chunks/studio-cnspfke5.js +0 -5
  198. package/dist/chunks/studio-de3p1ktm.js +0 -5
  199. package/dist/chunks/studio-fsbcn530.js +0 -928
  200. package/dist/chunks/studio-gftga7a3.js +0 -64
  201. package/dist/chunks/studio-hf1rpcq0.js +0 -4
  202. package/dist/chunks/studio-hz02hq70.js +0 -5
  203. package/dist/chunks/studio-jsv3s3s1.js +0 -5
  204. package/dist/chunks/studio-ks7cksh0.js +0 -5
  205. package/dist/chunks/studio-nptx0dbs.js +0 -5
  206. package/dist/chunks/studio-pg0z7k98.js +0 -5
  207. package/dist/chunks/studio-pve0b2f2.js +0 -5
  208. package/dist/chunks/studio-q7zmjv3b.js +0 -6
  209. package/dist/chunks/studio-rpmvw7kj.js +0 -5
  210. package/dist/chunks/studio-rwhss52s.js +0 -5
  211. package/dist/chunks/studio-sbg3g3h0.js +0 -5
  212. package/dist/chunks/studio-sq1bn60g.js +0 -14
  213. package/dist/chunks/studio-vwz7rma2.js +0 -5
  214. package/dist/chunks/studio-wa55e8gf.js +0 -5
  215. package/dist/chunks/studio-x0mzdha2.js +0 -5
  216. package/dist/chunks/studio-xrgpakvc.js +0 -9
  217. package/dist/chunks/studio-xt3djk1w.js +0 -7
  218. package/dist/chunks/studio-yh3nggx1.js +0 -7
  219. package/dist/chunks/studio-zmecp6hq.js +0 -5
  220. package/dist/chunks/workspace-g6dvaete.js +0 -4
  221. package/dist/chunks/ws-client-shf41j5p.js +0 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -61,12 +61,12 @@
61
61
  "dependencies": {
62
62
  "@atlaskit/pragmatic-drag-and-drop": "^4.0.0",
63
63
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^3.0.0",
64
- "@jxsuite/ai": "^0.39.0",
65
- "@jxsuite/collab": "^0.10.0",
64
+ "@jxsuite/ai": "^0.39.1",
65
+ "@jxsuite/collab": "^0.11.0",
66
66
  "@jxsuite/create": "^1.3.12",
67
67
  "@jxsuite/formulas": "^0.0.19",
68
68
  "@jxsuite/markup": "^0.4.11",
69
- "@jxsuite/protocol": "^2.5.0",
69
+ "@jxsuite/protocol": "^2.6.0",
70
70
  "@jxsuite/runtime": "^4.0.2",
71
71
  "@jxsuite/schema": "^2.3.0",
72
72
  "@jxsuite/site": "^2.0.2",
@@ -76,7 +76,7 @@
76
76
  "ajv-formats": "^3.0.1",
77
77
  "lit-html": "^3.3.3",
78
78
  "monaco-editor": "^0.57.0",
79
- "tabulator-tables": "^6.5.3",
79
+ "tabulator-tables": "^6.6.0",
80
80
  "yaml": "^2.9.1"
81
81
  },
82
82
  "devDependencies": {
@@ -0,0 +1,294 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Action-flow.ts — what Studio does when the backend refuses something and offers what to do.
4
+ *
5
+ * A backend answers an action it will not allow yet with `action-required` (desktop.md §10.4): its
6
+ * own heading, its own sentence, the actions it offers and what to do once one is done. Studio does
7
+ * not know why — a role, a policy, a plan, a quota — and does not need to. This module shows the
8
+ * refusal in one dialog, runs the chosen action through `platform.performAction`, and follows the
9
+ * backend's `retry` hint. `surfaces/dialog.ts` draws each phase; every word about the refusal
10
+ * itself is the backend's, and the few this module supplies ("Not now", "Waiting…") are about the
11
+ * dialog.
12
+ *
13
+ * **One dialog at a time.** A refused project open is a burst — `activate`, `project-info`, a
14
+ * directory listing — and every one of them reports. Callers that ask while a dialog is up join
15
+ * it.
16
+ *
17
+ * **Declining is remembered for the page.** The reports that arrive by themselves
18
+ * ({@link wireActionReports}) stop offering once the user has said "Not now", because a session
19
+ * whose every request is refused would otherwise reopen the dialog on each one. An action the user
20
+ * takes on purpose — a commit, a New Project — still asks, because that is a new question.
21
+ *
22
+ * @docs studio/interface/welcome-screen
23
+ */
24
+
25
+ import { hydrateAccountStatus, takeToastNotices } from "../account-status";
26
+ import { getPlatform, hasPlatform } from "../platform";
27
+ import { platformErrorInfo } from "../platform-errors";
28
+ import { notify } from "../services/notify";
29
+ import { resetModelCache } from "../services/ai-models";
30
+ import { openDialogSurface } from "../surfaces/dialog";
31
+ import { layerHost } from "../ui/layers";
32
+ import { onActionRequired } from "./action-required";
33
+ import type { ActionRequiredReport } from "./action-required";
34
+ import type { ActionOutcome, OfferedAction, RetryHint } from "../types";
35
+
36
+ /** A refusal to offer: what the backend offered, in its own words. */
37
+ export interface ActionPrompt {
38
+ actions: OfferedAction[];
39
+ heading?: string | undefined;
40
+ detail?: string | undefined;
41
+ retry?: RetryHint | undefined;
42
+ }
43
+
44
+ /** Whether this platform can perform an offered action at all. */
45
+ export function canPerformActions(): boolean {
46
+ return hasPlatform() && typeof getPlatform().performAction === "function";
47
+ }
48
+
49
+ /** The action a surface leads with — the one marked primary, else the first — and one other. */
50
+ export function leadingActions(
51
+ actions: readonly OfferedAction[],
52
+ ): { lead: OfferedAction; other?: OfferedAction } | null {
53
+ const lead = actions.find((action) => action.primary) ?? actions[0];
54
+ if (!lead) {
55
+ return null;
56
+ }
57
+ const other = actions.find((action) => action !== lead);
58
+ return other ? { lead, other } : { lead };
59
+ }
60
+
61
+ /** The dialog currently up, joined by every caller that asks while it is. */
62
+ let running: Promise<boolean> | null = null;
63
+
64
+ /** Set once the user declines; silences the reports that arrive on their own until reload. */
65
+ let declined = false;
66
+
67
+ /**
68
+ * Offer a refusal's actions, and resolve whether one of them is now done.
69
+ *
70
+ * Resolves false at once on a platform that cannot perform actions, or for a refusal that offers
71
+ * nothing, and when the user declines or closes the dialog. Never rejects: an action that failed is
72
+ * drawn in the dialog, which stays up so the user can try again or give up.
73
+ */
74
+ export function promptAction(prompt: ActionPrompt): Promise<boolean> {
75
+ const leading = leadingActions(prompt.actions);
76
+ if (!canPerformActions() || !leading) {
77
+ return Promise.resolve(false);
78
+ }
79
+ running ??= offer(prompt, leading).finally(() => {
80
+ running = null;
81
+ });
82
+ return running;
83
+ }
84
+
85
+ /**
86
+ * A caught error, offered when it is an `action-required` refusal with something to offer. Resolves
87
+ * whether the caller should now try again: false for any other error (nothing was offered) and for
88
+ * an offer declined.
89
+ */
90
+ export async function actIfRequired(error: unknown): Promise<boolean> {
91
+ const info = platformErrorInfo(error);
92
+ if (info.code !== "action-required" || !info.actions?.length) {
93
+ return false;
94
+ }
95
+ return promptAction({
96
+ actions: info.actions,
97
+ detail: error instanceof Error ? error.message : undefined,
98
+ heading: info.heading,
99
+ retry: info.retry,
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Everything a done action may have changed, re-read: the account rows and Start-pane notices read
105
+ * the status cache, and the assistant's gate reads the models probe. Then whatever the platform now
106
+ * wants announced is announced — in its words, not Studio's.
107
+ */
108
+ async function settleDone(): Promise<void> {
109
+ resetModelCache();
110
+ await hydrateAccountStatus();
111
+ showToastNotices();
112
+ }
113
+
114
+ /** Run one dialog to its end. */
115
+ function offer(
116
+ prompt: ActionPrompt,
117
+ leading: { lead: OfferedAction; other?: OfferedAction },
118
+ ): Promise<boolean> {
119
+ const message = prompt.detail ?? "This needs one more step first.";
120
+ return new Promise<boolean>((resolve) => {
121
+ let phase: "offer" | "waiting" | "done" = "offer";
122
+ const finish = (done: boolean) => {
123
+ if (phase === "done") {
124
+ return;
125
+ }
126
+ phase = "done";
127
+ handle.close();
128
+ resolve(done);
129
+ };
130
+ const offerAgain = (sentence: string) => {
131
+ phase = "offer";
132
+ handle.update({
133
+ cancelLabel: "Not now",
134
+ confirmLabel: leading.lead.label,
135
+ message: sentence,
136
+ secondaryLabel: leading.other?.label ?? "",
137
+ });
138
+ };
139
+
140
+ const begin = (action: OfferedAction) => {
141
+ if (phase !== "offer") {
142
+ return;
143
+ }
144
+ phase = "waiting";
145
+ /* Called straight from the click, with nothing awaited first: `performAction` opens its
146
+ window synchronously, and only code still inside the click may. */
147
+ let flow: Promise<ActionOutcome | null>;
148
+ try {
149
+ flow = getPlatform().performAction!(action);
150
+ } catch (error) {
151
+ offerAgain(error instanceof Error ? error.message : String(error));
152
+ return;
153
+ }
154
+ handle.update({
155
+ cancelLabel: "Cancel",
156
+ confirmLabel: "Waiting…",
157
+ message: "Finish in the window that opened. This closes by itself once it is done.",
158
+ secondaryLabel: "",
159
+ });
160
+ void flow.then(
161
+ async (outcome) => {
162
+ /* A done action is re-read even when the dialog is already gone — the user may have
163
+ dismissed the waiting dialog and finished in the window anyway, and the account must
164
+ not go on saying otherwise. */
165
+ if (outcome?.status === "done") {
166
+ await settleDone();
167
+ finish(true);
168
+ return;
169
+ }
170
+ if (phase !== "waiting" || !outcome || outcome.status === "redirect") {
171
+ // Dismissed meanwhile, or the page is navigating to the action: nothing to draw.
172
+ return;
173
+ }
174
+ // Closed without saying: the account may have changed anyway, so re-read it, and offer again.
175
+ void hydrateAccountStatus();
176
+ offerAgain(message);
177
+ },
178
+ (error: unknown) => {
179
+ if (phase === "waiting") {
180
+ offerAgain(error instanceof Error ? error.message : String(error));
181
+ }
182
+ },
183
+ );
184
+ };
185
+
186
+ const { lead, other } = leading;
187
+ const handle = openDialogSurface({
188
+ cancelLabel: "Not now",
189
+ confirmLabel: lead.label,
190
+ headline: prompt.heading ?? "One more step",
191
+ layer: layerHost("dialog"),
192
+ message,
193
+ onCancel: () => {
194
+ declined = true;
195
+ finish(false);
196
+ },
197
+ onClosed: () => {
198
+ finish(false);
199
+ },
200
+ onConfirm: () => {
201
+ begin(lead);
202
+ },
203
+ region: "account/action",
204
+ ...(other
205
+ ? {
206
+ onSecondary: () => {
207
+ begin(other);
208
+ },
209
+ secondaryLabel: other.label,
210
+ }
211
+ : {}),
212
+ });
213
+ });
214
+ }
215
+
216
+ /** Follow a done refusal's retry hint: reload a session it broke, or ask for the action again. */
217
+ function afterDone(retry: RetryHint | undefined): void {
218
+ if (retry === "reload") {
219
+ location.reload();
220
+ } else if (retry === "repeat") {
221
+ notify.info("Done. Run that again now.", { key: "account.action.retry", source: "Account" });
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Offer the actions of every refusal the platform adapter reports by itself — the refusals no
227
+ * caller asked to handle. Returns the unsubscribe. Called once, at boot.
228
+ */
229
+ export function wireActionReports(): () => void {
230
+ return onActionRequired((report: ActionRequiredReport) => {
231
+ if (declined || running) {
232
+ return;
233
+ }
234
+ void promptAction(report).then((done) => {
235
+ if (done) {
236
+ afterDone(report.retry);
237
+ }
238
+ });
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Run an action offered outside a refusal — on a Start-pane notice or an account row. Resolves
244
+ * whether it is done; either way the account status is re-read, because an action is how it
245
+ * changes. Synchronous up to `performAction`, which opens its window from inside the click.
246
+ */
247
+ export async function runOfferedAction(action: OfferedAction): Promise<boolean> {
248
+ if (!canPerformActions()) {
249
+ return false;
250
+ }
251
+ const pending = getPlatform().performAction!(action);
252
+ let outcome: ActionOutcome | null;
253
+ try {
254
+ outcome = await pending;
255
+ } catch (error) {
256
+ // A toast, not a Problems entry: it answers the click, and nothing is left standing to fix.
257
+ notify.error(error instanceof Error ? error.message : String(error), {
258
+ key: "account.action",
259
+ source: "Account",
260
+ tier: "toast",
261
+ });
262
+ return false;
263
+ }
264
+ if (outcome?.status === "done") {
265
+ await settleDone();
266
+ return true;
267
+ }
268
+ if (outcome?.status !== "redirect") {
269
+ await hydrateAccountStatus();
270
+ }
271
+ return false;
272
+ }
273
+
274
+ /**
275
+ * Show the platform's one-time notices — how an action that left the page ended, or anything else
276
+ * it wants said once. Called at boot after the account status is read, and after every done
277
+ * action.
278
+ */
279
+ export function showToastNotices(): void {
280
+ for (const notice of takeToastNotices()) {
281
+ const options = { key: `account.notice.${notice.id}`, source: "Account" };
282
+ if (notice.level === "warning") {
283
+ notify.warn(notice.message, options);
284
+ } else {
285
+ notify.info(notice.message, options);
286
+ }
287
+ }
288
+ }
289
+
290
+ /** Forget the dialog and the decline — tests only. */
291
+ export function resetActionFlow(): void {
292
+ running = null;
293
+ declined = false;
294
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Action-required.ts — the channel a platform adapter raises an `action-required` refusal on.
3
+ *
4
+ * A backend refuses the session's own requests with `action-required` (HTTP 403, desktop.md §10.4),
5
+ * and those requests are made deep inside an adapter by callers that have no business knowing about
6
+ * access rules: a file read, a directory listing, the boot-time `activate`. Each of them still
7
+ * throws its ordinary error, and this is the side channel that says, once, "and here is what the
8
+ * user can do about it" — to the one listener that can offer it (`action-flow.ts`).
9
+ *
10
+ * No DOM and no imports beyond types, so an adapter can raise on it from anywhere without pulling
11
+ * the dialog layer into its module graph.
12
+ *
13
+ * **A report raised before anyone listens is kept.** The boot-time `activate` runs as the studio
14
+ * module evaluates, and its refusal can land before the flow has subscribed; dropping it would be
15
+ * exactly the silent failure this channel exists to prevent. Only the latest one is kept — they all
16
+ * say the same thing.
17
+ */
18
+
19
+ import type { OfferedAction, RetryHint } from "@jxsuite/protocol";
20
+
21
+ /** One refusal, in the backend's words. */
22
+ export interface ActionRequiredReport {
23
+ /** What the backend offers. A report is raised only when it offers something. */
24
+ actions: OfferedAction[];
25
+ /** The refusal's heading. */
26
+ heading?: string;
27
+ /** The refusal's `detail`. */
28
+ detail?: string;
29
+ /** What to do once an action is done. */
30
+ retry?: RetryHint;
31
+ }
32
+
33
+ type Listener = (report: ActionRequiredReport) => void;
34
+
35
+ const listeners = new Set<Listener>();
36
+
37
+ /** The latest report raised while nobody was listening; delivered to the first subscriber. */
38
+ let pending: ActionRequiredReport | null = null;
39
+
40
+ /** Raise a refusal. Every listener hears it; with none yet, the first one to subscribe will. */
41
+ export function reportActionRequired(report: ActionRequiredReport): void {
42
+ if (listeners.size === 0) {
43
+ pending = report;
44
+ return;
45
+ }
46
+ for (const listener of listeners) {
47
+ listener(report);
48
+ }
49
+ }
50
+
51
+ /** Hear every refusal from now on, and the one raised before this call, if any. */
52
+ export function onActionRequired(listener: Listener): () => void {
53
+ listeners.add(listener);
54
+ const missed = pending;
55
+ pending = null;
56
+ if (missed) {
57
+ listener(missed);
58
+ }
59
+ return () => {
60
+ listeners.delete(listener);
61
+ };
62
+ }
63
+
64
+ /** Drop every listener and any kept report — tests only. */
65
+ export function resetActionReports(): void {
66
+ listeners.clear();
67
+ pending = null;
68
+ }
@@ -5,7 +5,7 @@
5
5
  * "install the GitHub App" when the user has no repository access yet.
6
6
  */
7
7
  import { getPlatform, hasPlatform } from "./platform";
8
- import type { AccountStatus } from "./types";
8
+ import type { AccountEntry, AccountNotice, AccountStatus } from "./types";
9
9
 
10
10
  let cache: AccountStatus | null = null;
11
11
 
@@ -62,7 +62,35 @@ export function getRepoAccessLinks(): RepoAccessLinks | null {
62
62
  return { manage, ...(cache.appInstallUrl ? { installUrl: cache.appInstallUrl } : {}) };
63
63
  }
64
64
 
65
+ /** The rows the platform adds to Preferences › Accounts (desktop.md §10.4); none when absent. */
66
+ export function getAccountEntries(): AccountEntry[] {
67
+ return cache?.entries ?? [];
68
+ }
69
+
70
+ /** The platform's notices to draw on the Start pane: every one that is not a one-time toast. */
71
+ export function getBannerNotices(): AccountNotice[] {
72
+ return (cache?.notices ?? []).filter((notice) => notice.display !== "toast");
73
+ }
74
+
75
+ /** Toast notices already shown, by id, so a repeated hydrate does not announce one twice. */
76
+ const shownToasts = new Set<string>();
77
+
78
+ /**
79
+ * The platform's one-time notices not yet shown, marked shown as they are taken. A platform that
80
+ * keeps sending one after it was shown — before it hears it was seen — does not repeat itself.
81
+ */
82
+ export function takeToastNotices(): AccountNotice[] {
83
+ const fresh = (cache?.notices ?? []).filter(
84
+ (notice) => notice.display === "toast" && !shownToasts.has(notice.id),
85
+ );
86
+ for (const notice of fresh) {
87
+ shownToasts.add(notice.id);
88
+ }
89
+ return fresh;
90
+ }
91
+
65
92
  /** Reset seam for tests. */
66
93
  export function resetAccountStatus(): void {
67
94
  cache = null;
95
+ shownToasts.clear();
68
96
  }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { MEDIA_EXTENSIONS, extensionOf } from "../files/media-upload";
22
22
  import { projectState } from "../store";
23
+ import { notedListing } from "../files/asset-versions";
23
24
  import { errorMessage } from "@jxsuite/schema/parse";
24
25
  import { resolveI18n, servedLocaleOfPath } from "@jxsuite/schema/locale";
25
26
  import type { ResolvedI18n } from "@jxsuite/schema/locale";
@@ -174,7 +175,8 @@ async function walk(
174
175
  ): Promise<void> {
175
176
  let entries: DirEntry[];
176
177
  try {
177
- entries = await platform.listDirectory(dir);
178
+ // Noted: the Library walks every media directory, so it is where most versions are learned.
179
+ entries = await notedListing(() => platform.listDirectory(dir));
178
180
  } catch (error) {
179
181
  failures.push({ dir, error: errorMessage(error) });
180
182
  return;
@@ -26,6 +26,7 @@ import {
26
26
  setSkipServerFunctions,
27
27
  } from "@jxsuite/runtime";
28
28
  import { assetContextFor, hostAssetDeclarations, resolveAssetRef } from "../canvas/asset-refs";
29
+ import { assetVersionsSnapshot } from "../files/asset-versions";
29
30
  import { componentRegistry } from "../files/components";
30
31
  import { getPlatform } from "../platform";
31
32
  import { loadFormats, formatForPath } from "../format/format-host";
@@ -121,7 +122,7 @@ export async function renderDocPreview(filePath: string): Promise<HTMLElement |
121
122
  try {
122
123
  const content = await getPlatform().readFile(filePath);
123
124
  setSkipServerFunctions(true);
124
- const assets = assetContextFor(filePath, hostAssetDeclarations());
125
+ const context = assetContextFor(filePath, hostAssetDeclarations());
125
126
  await loadFormats();
126
127
  let document_: JxDocument;
127
128
  if (formatForPath(filePath)) {
@@ -134,7 +135,10 @@ export async function renderDocPreview(filePath: string): Promise<HTMLElement |
134
135
  /* This render lands in the PARENT document, so an `<img src="/hero.jpg">` inside it resolves
135
136
  against `index.html` — which is the shell, not the site. Same hook the canvas installs, same
136
137
  context, restored afterwards because the next preview is a different document and a stale
137
- `documentDir` would resolve its relative refs against the wrong entry. */
138
+ `documentDir` would resolve its relative refs against the wrong entry. The version map is
139
+ read as the render starts, after the reads above: the Library is the many-images screen,
140
+ and a versioned src is what lets its cards repaint from the browser cache. */
141
+ const assets = context && { ...context, versions: assetVersionsSnapshot() };
138
142
  setCanvasAssetResolver((value) => resolveAssetRef(value, assets));
139
143
  try {
140
144
  const element = renderNode(document_, scope);
@@ -18,6 +18,7 @@ import { contentMountFor, resolveAssetRef } from "./asset-resolve";
18
18
  import { documentBase, loopbackAssetSrc } from "./canvas-origin";
19
19
  import { getPlatform, hasPlatform } from "../platform";
20
20
  import { projectState } from "../store";
21
+ import { assetVersionsSnapshot } from "../files/asset-versions";
21
22
  import { activeTab } from "../workspace/workspace";
22
23
  import type { AssetContext } from "./asset-resolve";
23
24
  import type { ContentSectionEntry } from "../types";
@@ -94,7 +95,8 @@ export function previewAssetSrc(value: string): string {
94
95
  return value;
95
96
  }
96
97
  const ctx = assetContextFor(activeTab.value?.documentPath, hostAssetDeclarations());
97
- return loopbackAssetSrc(resolveAssetRef(value, ctx) ?? value);
98
+ const versioned = ctx && { ...ctx, versions: assetVersionsSnapshot() };
99
+ return loopbackAssetSrc(resolveAssetRef(value, versioned) ?? value);
98
100
  }
99
101
 
100
102
  /**
@@ -98,6 +98,81 @@ export interface AssetContext {
98
98
  * editing servers actually use, and why the two differ.
99
99
  */
100
100
  lanes: readonly AssetLane[];
101
+ /**
102
+ * Content versions by normalized project path, for repo-space URLs (`?v=<version>`, see
103
+ * {@link withVersion}). Only paths the backend vouches for appear; a path absent here resolves
104
+ * unversioned, exactly as before versions existed.
105
+ */
106
+ versions?: Readonly<Record<string, string>> | undefined;
107
+ }
108
+
109
+ /**
110
+ * `url` with `v=<version>` in its query — appended to an existing query, inserted before any
111
+ * `#fragment`, and replacing every `v` parameter the URL already had. The host reads ONE `v`, and
112
+ * it decodes the key to find it (`URLSearchParams.get("v")` returns the FIRST match), so when
113
+ * Studio appends its own, an authored `v` — `?v=2`, `?v`, or a percent-encoded `?%76=…` — is
114
+ * dropped by its DECODED name rather than left to shadow it.
115
+ *
116
+ * With no version the URL comes back EXACTLY as given, authored `v` and all: an unversioned
117
+ * reference keeps working precisely as it did before versions existed. That is safe because a host
118
+ * may answer a `v` immutably only when it is a version the host issued, with exactly the bytes it
119
+ * names (specs/studio.md §3.4), so an authored cache-buster like `?v=2` gets the ordinary
120
+ * revalidating response. A version that is not a non-empty string is no version.
121
+ *
122
+ * The version is opaque and equal versions mean identical bytes, so a host that recognizes it may
123
+ * answer the URL with an immutable cache lifetime (specs/studio.md §3.4).
124
+ *
125
+ * @param {string} url - A URL, absolute or relative
126
+ * @param {string | undefined} version - The file's content version, if any
127
+ * @returns {string} The versioned URL
128
+ */
129
+ export function withVersion(url: string, version: string | undefined): string {
130
+ if (typeof version !== "string" || version === "") {
131
+ return url;
132
+ }
133
+ const hashAt = url.indexOf("#");
134
+ const fragment = hashAt === -1 ? "" : url.slice(hashAt);
135
+ const beforeHash = hashAt === -1 ? url : url.slice(0, hashAt);
136
+ const queryAt = beforeHash.indexOf("?");
137
+ const base = queryAt === -1 ? beforeHash : beforeHash.slice(0, queryAt);
138
+ const parts = queryAt === -1 ? [] : beforeHash.slice(queryAt + 1).split("&");
139
+ const kept = parts.filter((part) => part !== "" && !namesV(part));
140
+ kept.push(`v=${encodeURIComponent(version)}`);
141
+ return `${base}?${kept.join("&")}${fragment}`;
142
+ }
143
+
144
+ /** Whether one `key[=value]` query part's key decodes to `v`, the way `URLSearchParams` reads it. */
145
+ function namesV(part: string): boolean {
146
+ const eq = part.indexOf("=");
147
+ const key = eq === -1 ? part : part.slice(0, eq);
148
+ if (key === "v") {
149
+ return true;
150
+ }
151
+ if (!key.includes("%")) {
152
+ return false;
153
+ }
154
+ try {
155
+ return decodeURIComponent(key) === "v";
156
+ } catch {
157
+ // A malformed escape decodes to itself under `URLSearchParams`, which is never a bare `v`.
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The version `versions` holds for `path`, or undefined. Own keys only: the map is a plain object
164
+ * (and a structured clone of one inside the canvas iframe), so a path named `constructor` or
165
+ * `__proto__` would otherwise read a member of `Object.prototype` as its version.
166
+ */
167
+ function ownVersion(
168
+ versions: Readonly<Record<string, string>> | undefined,
169
+ path: string,
170
+ ): string | undefined {
171
+ if (!versions || !Object.hasOwn(versions, path)) {
172
+ return undefined;
173
+ }
174
+ const version: unknown = versions[path];
175
+ return typeof version === "string" ? version : undefined;
101
176
  }
102
177
 
103
178
  /**
@@ -221,7 +296,10 @@ export function resolveAssetRef(value: string, ctx: AssetContext | null): string
221
296
  return null;
222
297
  }
223
298
  const { suffix } = splitRefSuffix(value);
224
- return `${ctx.fileBaseUrl}${encodeProjectPath(path)}${suffix}`;
299
+ return withVersion(
300
+ `${ctx.fileBaseUrl}${encodeProjectPath(path)}${suffix}`,
301
+ ownVersion(ctx.versions, path),
302
+ );
225
303
  }
226
304
  const [mount] = ctx.mounts;
227
305
  return mount ? mountedRefFor(value, ctx.documentDir, mount) : null;
@@ -53,8 +53,11 @@ import {
53
53
  reapplyStyle,
54
54
  redefineElement,
55
55
  resetDocumentStyles,
56
+ setCanvasAssetResolver,
56
57
  setResolveToken,
57
58
  } from "@jxsuite/runtime";
59
+ import { resolveAssetRef } from "./asset-resolve";
60
+ import type { AssetContext } from "./asset-resolve";
58
61
  import { KIT_LOADERS } from "@jxsuite/ui/loaders";
59
62
  import type { IframeChannel } from "./iframe-channel";
60
63
  import type {
@@ -121,6 +124,25 @@ export function isExternalFileDrag(e: DragEvent): boolean {
121
124
  */
122
125
  const FILE_DRAG_SRC: DragSrcKind = { type: "block" };
123
126
 
127
+ /**
128
+ * The project's media versions, as the host last posted them (`assetVersions`). Module-level: one
129
+ * frame per realm, and the map describes the PROJECT, not any one render. Folded into every
130
+ * render's asset context by {@link versionedAssets}; empty until the host sends one, which resolves
131
+ * every reference unversioned — the behaviour from before versions existed.
132
+ */
133
+ let assetVersions: Readonly<Record<string, string>> = {};
134
+ /**
135
+ * The asset context of the newest render accepted, WITHOUT versions — kept so a later
136
+ * `assetVersions` can rebuild the resolver around the new map. Null when that render resolves
137
+ * nothing.
138
+ */
139
+ let liveAssets: AssetContext | null = null;
140
+
141
+ /** A render's asset context with the current versions folded in, or null for no resolution. */
142
+ function versionedAssets(assets: AssetContext | null): AssetContext | null {
143
+ return assets ? { ...assets, versions: assetVersions } : null;
144
+ }
145
+
124
146
  /**
125
147
  * Describe the node under a file drag for the parent: its path, rect, and resolved tag. The tag
126
148
  * comes from the SHADOW DOC (the document's own `tagName`) when the path resolves there, because
@@ -240,6 +262,9 @@ export function startCanvasIframe(opts: {
240
262
  container: HTMLElement;
241
263
  }): () => void {
242
264
  const { channel, container } = opts;
265
+ // A frame starts knowing no versions; the host posts the map before the first render.
266
+ assetVersions = {};
267
+ liveAssets = null;
243
268
  let handle: RenderHandle | null = null;
244
269
  // Disposer for the live render's dataScope re-post effect (see the render handler); stopped
245
270
  // Alongside the handle so a superseded render's refs can't keep posting.
@@ -869,6 +894,17 @@ export function startCanvasIframe(opts: {
869
894
  stopAutoScroll();
870
895
  return;
871
896
  }
897
+ if (msg.kind === "assetVersions") {
898
+ /* Replaced wholesale, like the keymap. The resolver the last render installed closed over the
899
+ old map, and a patch draws through that resolver — so it is rebuilt around the new one
900
+ here, not left for the next render. */
901
+ assetVersions = msg.versions;
902
+ const assets = versionedAssets(liveAssets);
903
+ if (assets) {
904
+ setCanvasAssetResolver((value) => resolveAssetRef(value, assets));
905
+ }
906
+ return;
907
+ }
872
908
  if (msg.kind === "keymap") {
873
909
  // Replaced wholesale, never merged: the host sends the whole live table, so a chord the
874
910
  // Author unbound disappears here rather than lingering as a key the canvas still swallows.
@@ -946,6 +982,7 @@ export function startCanvasIframe(opts: {
946
982
  stopEditing();
947
983
  }
948
984
  latestGen = msg.gen;
985
+ liveAssets = msg.assets ?? null;
949
986
  // Adopt the document's caret vocabulary BEFORE rendering: which tags hold a caret depends on
950
987
  // The format class, and a `.md` page and a native `.json` component do not agree. Absent means
951
988
  // A document with no format, where the studio's own element metadata answers on its own.
@@ -966,7 +1003,7 @@ export function startCanvasIframe(opts: {
966
1003
  element per render. */
967
1004
  resetDocumentStyles(container.ownerDocument);
968
1005
  handle = await renderResolvedDocument({
969
- assets: msg.assets ?? null,
1006
+ assets: versionedAssets(msg.assets ?? null),
970
1007
  container,
971
1008
  doc: msg.doc as JxDocument,
972
1009
  docBase: msg.docBase,