@jxsuite/studio 5.5.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 (244) hide show
  1. package/LICENSE +1 -1
  2. package/dist/chunks/{2020-g8mwpbkq.js → 2020-cs2sb9t1.js} +3 -3
  3. package/dist/chunks/adapter-y5gdq19c.js +4 -0
  4. package/dist/chunks/cf-account-picker-882a7ecm.js +4 -0
  5. package/dist/chunks/cf-settings-20dznc3j.js +4 -0
  6. package/dist/chunks/components-q037dya7.js +4 -0
  7. package/dist/chunks/content-source-37x3vsap.js +4 -0
  8. package/dist/chunks/convert-to-repeater-grfep2w8.js +4 -0
  9. package/dist/chunks/editor-j5sq149q.js +4 -0
  10. package/dist/chunks/entry-commands-c5h3nqck.js +4 -0
  11. package/dist/chunks/extension-sections-dp8endhx.js +4 -0
  12. package/dist/chunks/file-ops-937cvqk6.js +4 -0
  13. package/dist/chunks/files-tznwe1p1.js +4 -0
  14. package/dist/chunks/format-host-e2pjvd25.js +4 -0
  15. package/dist/chunks/fs-events-fv64ak3s.js +4 -0
  16. package/dist/chunks/git-panel-gb713ttc.js +4 -0
  17. package/dist/chunks/grid-view-5amhg604.js +51 -0
  18. package/dist/chunks/grid-view-5mqv1cbd.css +1 -0
  19. package/dist/chunks/iframe-host-t819mcg5.js +4 -0
  20. package/dist/chunks/{index-arg188jq.js → index-9ydfe04y.js} +3 -3
  21. package/dist/chunks/index-m0x5ztax.js +4 -0
  22. package/dist/chunks/{javascript-zcdmye2k.js → javascript-d9pqgc2p.js} +2 -2
  23. package/dist/chunks/{jsonMode-6x5e1nbf.js → jsonMode-800yvbem.js} +5 -5
  24. package/dist/chunks/jx-validate-zc9zp77s.js +4 -0
  25. package/dist/chunks/layers-4bsr82wz.js +4 -0
  26. package/dist/chunks/layers-panel-b6shnaax.js +4 -0
  27. package/dist/chunks/lit-html-cfn61dn7.js +4 -0
  28. package/dist/chunks/media-picker-ej0kdh1j.js +4 -0
  29. package/dist/chunks/menu-rtxf9chb.js +4 -0
  30. package/dist/chunks/{monaco-binding-4az2kh5p.js → monaco-binding-62dtmgbf.js} +3 -3
  31. package/dist/chunks/monaco-buffer-nn4dtx27.js +4 -0
  32. package/dist/chunks/{monaco-setup-d7mwkcx3.js → monaco-setup-tj13ndtm.js} +3 -3
  33. package/dist/chunks/notify-ahs57a2z.js +4 -0
  34. package/dist/chunks/platform-v9xyfkbx.js +4 -0
  35. package/dist/chunks/publish-panel-bq36xcmj.js +4 -0
  36. package/dist/chunks/quick-search-zm00h9dh.js +4 -0
  37. package/dist/chunks/references-99ws0b0m.js +4 -0
  38. package/dist/chunks/right-panel-pq2f71q3.js +4 -0
  39. package/dist/chunks/section-registry-b2yzp9sw.js +4 -0
  40. package/dist/chunks/studio-01kbbqg3.js +928 -0
  41. package/dist/chunks/{studio-hf4qdjc8.js → studio-01qza1n3.js} +4 -4
  42. package/dist/chunks/{studio-pkkw32j0.js → studio-03wwfvyt.js} +4 -4
  43. package/dist/chunks/{studio-7gqyhjns.js → studio-0bbxhhce.js} +4 -4
  44. package/dist/chunks/studio-0k9mvpgj.js +14 -0
  45. package/dist/chunks/studio-0scebvb9.js +6 -0
  46. package/dist/chunks/studio-1a99w39s.js +5 -0
  47. package/dist/chunks/studio-1wvfxkyy.js +5 -0
  48. package/dist/chunks/studio-1x8x26k3.js +5 -0
  49. package/dist/chunks/studio-222rqyyc.js +5 -0
  50. package/dist/chunks/studio-4xppwncr.js +4 -0
  51. package/dist/chunks/{studio-xcng8nx7.js → studio-54g077x3.js} +5 -5
  52. package/dist/chunks/studio-56z7evx4.js +10 -0
  53. package/dist/chunks/studio-5e9zsamh.js +9 -0
  54. package/dist/chunks/studio-65fhspdd.js +5 -0
  55. package/dist/chunks/studio-693nhyky.js +5 -0
  56. package/dist/chunks/{studio-7jazdy6y.js → studio-7pgy3xaf.js} +4 -4
  57. package/dist/chunks/studio-888knnkc.js +5 -0
  58. package/dist/chunks/studio-9mqfamps.js +5 -0
  59. package/dist/chunks/studio-a7xqhc3n.js +5 -0
  60. package/dist/chunks/studio-aje966s9.js +5 -0
  61. package/dist/chunks/studio-aqmph6k4.js +5 -0
  62. package/dist/chunks/studio-c6w3tq7t.js +5 -0
  63. package/dist/chunks/{studio-6j2jn55a.js → studio-c6zdm25z.js} +8 -8
  64. package/dist/chunks/{studio-f8jyp45j.js → studio-c9d3pxf7.js} +4 -4
  65. package/dist/chunks/studio-ceb34dxj.js +5 -0
  66. package/dist/chunks/studio-cvbkkfqb.js +5 -0
  67. package/dist/chunks/studio-cxt150pj.js +7 -0
  68. package/dist/chunks/studio-dkn3x3hz.js +5 -0
  69. package/dist/chunks/studio-e6re3rk1.js +5 -0
  70. package/dist/chunks/{studio-5n1km79v.js → studio-edbtvcd1.js} +4 -4
  71. package/dist/chunks/{studio-xc8ebsts.js → studio-eh0rac07.js} +4 -4
  72. package/dist/chunks/{studio-625ezz59.js → studio-evwgp1ym.js} +4 -4
  73. package/dist/chunks/{studio-he2fhrgp.js → studio-f195h2hq.js} +4 -4
  74. package/dist/chunks/studio-fqq890ap.js +4 -0
  75. package/dist/chunks/studio-frb0qbf7.js +6 -0
  76. package/dist/chunks/studio-gfn1kpxj.js +6 -0
  77. package/dist/chunks/studio-gh97gngt.js +5 -0
  78. package/dist/chunks/{studio-awqghqxv.js → studio-gp02nqnw.js} +4 -4
  79. package/dist/chunks/{studio-x9sagq2d.js → studio-hjbg8d5y.js} +7 -7
  80. package/dist/chunks/studio-j6gnhvaa.js +5 -0
  81. package/dist/chunks/studio-mwxgyn9h.js +7 -0
  82. package/dist/chunks/{studio-x5esgzct.js → studio-n78gjfy8.js} +4 -4
  83. package/dist/chunks/studio-nbxqcgvf.js +5 -0
  84. package/dist/chunks/{studio-p1eggxcf.js → studio-nt06yybj.js} +4 -4
  85. package/dist/chunks/{studio-hc9q4j24.js → studio-p7c1agz0.js} +5 -5
  86. package/dist/chunks/{studio-8r4y2x0z.js → studio-pwqfmt4p.js} +4 -4
  87. package/dist/chunks/{studio-5wfrgee9.js → studio-r3kaka4d.js} +4 -4
  88. package/dist/chunks/studio-semd1s8s.js +5 -0
  89. package/dist/chunks/studio-skzanb0k.js +5 -0
  90. package/dist/chunks/studio-spf2skgc.js +5 -0
  91. package/dist/chunks/{studio-b4epb26c.js → studio-st75kn99.js} +7 -7
  92. package/dist/chunks/{studio-eq27rrr0.js → studio-sxc0zvpt.js} +3 -3
  93. package/dist/chunks/studio-t1eedsyx.js +4 -0
  94. package/dist/chunks/{studio-73czgk6a.js → studio-tp642n5g.js} +4 -4
  95. package/dist/chunks/studio-tw7rre6s.js +5 -0
  96. package/dist/chunks/{studio-7by9a93k.js → studio-tzegmy4w.js} +4 -4
  97. package/dist/chunks/studio-vavmwshr.js +5 -0
  98. package/dist/chunks/studio-wma2c6g8.js +5 -0
  99. package/dist/chunks/studio-x92tgnee.js +64 -0
  100. package/dist/chunks/studio-xk0z6msk.js +5 -0
  101. package/dist/chunks/{studio-c4pnt1c6.js → studio-xry7tk3z.js} +5 -5
  102. package/dist/chunks/studio-xta2vxp6.js +9 -0
  103. package/dist/chunks/{studio-21enxdr8.js → studio-xy3tv3az.js} +4 -4
  104. package/dist/chunks/studio-y7jehjrp.js +5 -0
  105. package/dist/chunks/{studio-k1eaqyzm.js → studio-yr04mv2j.js} +4 -4
  106. package/dist/chunks/{studio-mhhc9btt.js → studio-zd4kayn2.js} +4 -4
  107. package/dist/chunks/studio-zpnk016a.js +16 -0
  108. package/dist/chunks/{tsMode-rtwt7w1a.js → tsMode-k9s9fcag.js} +5 -5
  109. package/dist/chunks/workspace-b69mgm0e.js +4 -0
  110. package/dist/chunks/ws-client-0ja08haa.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 +121 -123
  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/canvas/stage-scrollbar.ts +7 -7
  127. package/src/files/asset-versions.ts +294 -0
  128. package/src/files/file-ops.ts +5 -8
  129. package/src/files/files.ts +61 -9
  130. package/src/files/fs-events.ts +279 -28
  131. package/src/files/media-meta.ts +2 -1
  132. package/src/files/media-paths.ts +5 -2
  133. package/src/files/media-upload.ts +5 -1
  134. package/src/new-project/add-repo-modal.ts +12 -1
  135. package/src/new-project/new-project-modal.ts +12 -1
  136. package/src/panels/git-panel.ts +25 -2
  137. package/src/panels/jump-bar.ts +132 -158
  138. package/src/panels/pane-context.ts +8 -8
  139. package/src/panels/pane-grid.ts +18 -26
  140. package/src/panels/style-panel.ts +2 -2
  141. package/src/platform-errors.ts +61 -1
  142. package/src/platforms/cloud.ts +389 -50
  143. package/src/platforms/devserver.ts +17 -1
  144. package/src/publish/pages-service.ts +17 -7
  145. package/src/services/ai-models.ts +30 -6
  146. package/src/services/ai-system-prompt.ts +81 -51
  147. package/src/services/context-manager.ts +41 -70
  148. package/src/services/document-assistant.ts +12 -3
  149. package/src/services/harness/chat-reducer.ts +115 -0
  150. package/src/services/harness/turn-hooks.ts +50 -0
  151. package/src/services/tool-executor.ts +69 -288
  152. package/src/services/tool-outcomes.ts +12 -10
  153. package/src/settings/preferences-accounts.ts +42 -3
  154. package/src/settings/preferences-dialog.ts +8 -1
  155. package/src/studio.ts +20 -6
  156. package/src/surfaces/block-action-bar.json +6 -1
  157. package/src/surfaces/dialog.ts +5 -0
  158. package/src/surfaces/jump-bar.json +9 -12
  159. package/src/surfaces/jump-bar.ts +15 -16
  160. package/src/surfaces/pane-context.json +2 -2
  161. package/src/surfaces/pane-grid.json +8 -25
  162. package/src/surfaces/pane-grid.ts +7 -8
  163. package/src/surfaces/statusbar.json +71 -3
  164. package/src/surfaces/statusbar.ts +66 -139
  165. package/src/surfaces/style-panel.json +3 -3
  166. package/src/surfaces/style-panel.ts +11 -1
  167. package/src/surfaces/welcome.json +86 -1
  168. package/src/surfaces/welcome.ts +61 -2
  169. package/src/types.ts +84 -2
  170. package/src/ui/ai-managed-connect.ts +41 -4
  171. package/src/ui/media-picker.ts +2 -1
  172. package/dist/chunks/adapter-bsbs78x9.js +0 -4
  173. package/dist/chunks/cf-account-picker-gnm7xeqb.js +0 -4
  174. package/dist/chunks/cf-settings-9y6jyscp.js +0 -4
  175. package/dist/chunks/components-s51h3b7c.js +0 -4
  176. package/dist/chunks/content-source-ty3be0qg.js +0 -4
  177. package/dist/chunks/convert-to-repeater-c3e7q61z.js +0 -4
  178. package/dist/chunks/editor-t002b41m.js +0 -4
  179. package/dist/chunks/entry-commands-sb72e9gy.js +0 -4
  180. package/dist/chunks/extension-sections-ktn2ntnj.js +0 -4
  181. package/dist/chunks/file-ops-78zeytkm.js +0 -4
  182. package/dist/chunks/files-gatzrz8v.js +0 -4
  183. package/dist/chunks/format-host-r59b1651.js +0 -4
  184. package/dist/chunks/fs-events-f8h91ndt.js +0 -4
  185. package/dist/chunks/git-panel-y12zf7nj.js +0 -4
  186. package/dist/chunks/grid-view-rfd8b2pg.js +0 -51
  187. package/dist/chunks/grid-view-tafenfm2.css +0 -1
  188. package/dist/chunks/iframe-host-vsrsj20g.js +0 -4
  189. package/dist/chunks/index-5aghyrfh.js +0 -4
  190. package/dist/chunks/jx-validate-653krkrf.js +0 -4
  191. package/dist/chunks/layers-32p8gtae.js +0 -4
  192. package/dist/chunks/layers-panel-rs58m105.js +0 -4
  193. package/dist/chunks/lit-html-54bmkwz3.js +0 -4
  194. package/dist/chunks/media-picker-5bb04wct.js +0 -4
  195. package/dist/chunks/menu-cbha7w2e.js +0 -4
  196. package/dist/chunks/monaco-buffer-9vbbyfr1.js +0 -4
  197. package/dist/chunks/notify-dc1nz5ar.js +0 -4
  198. package/dist/chunks/platform-2m49s1zf.js +0 -4
  199. package/dist/chunks/publish-panel-nx3ycmma.js +0 -4
  200. package/dist/chunks/quick-search-yzrzh9x3.js +0 -4
  201. package/dist/chunks/references-vx7mbj9f.js +0 -4
  202. package/dist/chunks/right-panel-7jzpbxja.js +0 -4
  203. package/dist/chunks/section-registry-gjryse41.js +0 -4
  204. package/dist/chunks/studio-3znfhcxg.js +0 -5
  205. package/dist/chunks/studio-58qam27v.js +0 -4
  206. package/dist/chunks/studio-6j59fwaw.js +0 -5
  207. package/dist/chunks/studio-7z69g1gx.js +0 -5
  208. package/dist/chunks/studio-93j3v6zx.js +0 -5
  209. package/dist/chunks/studio-9gf6v8zy.js +0 -5
  210. package/dist/chunks/studio-9rhmettf.js +0 -4
  211. package/dist/chunks/studio-ac5aq677.js +0 -10
  212. package/dist/chunks/studio-as65vbns.js +0 -5
  213. package/dist/chunks/studio-bkv0sga4.js +0 -6
  214. package/dist/chunks/studio-bttmqef4.js +0 -5
  215. package/dist/chunks/studio-c3y9d41k.js +0 -5
  216. package/dist/chunks/studio-ccegp7gz.js +0 -9
  217. package/dist/chunks/studio-chxabn98.js +0 -6
  218. package/dist/chunks/studio-cktcdsxz.js +0 -16
  219. package/dist/chunks/studio-cnspfke5.js +0 -5
  220. package/dist/chunks/studio-de3p1ktm.js +0 -5
  221. package/dist/chunks/studio-fsbcn530.js +0 -928
  222. package/dist/chunks/studio-gftga7a3.js +0 -64
  223. package/dist/chunks/studio-hf1rpcq0.js +0 -4
  224. package/dist/chunks/studio-hz02hq70.js +0 -5
  225. package/dist/chunks/studio-jnb285we.js +0 -5
  226. package/dist/chunks/studio-jsv3s3s1.js +0 -5
  227. package/dist/chunks/studio-ks7cksh0.js +0 -5
  228. package/dist/chunks/studio-nptx0dbs.js +0 -5
  229. package/dist/chunks/studio-pg0z7k98.js +0 -5
  230. package/dist/chunks/studio-pve0b2f2.js +0 -5
  231. package/dist/chunks/studio-q7zmjv3b.js +0 -6
  232. package/dist/chunks/studio-rpmvw7kj.js +0 -5
  233. package/dist/chunks/studio-rwhss52s.js +0 -5
  234. package/dist/chunks/studio-sbg3g3h0.js +0 -5
  235. package/dist/chunks/studio-sq1bn60g.js +0 -14
  236. package/dist/chunks/studio-vwz7rma2.js +0 -5
  237. package/dist/chunks/studio-wa55e8gf.js +0 -5
  238. package/dist/chunks/studio-x0mzdha2.js +0 -5
  239. package/dist/chunks/studio-xrgpakvc.js +0 -9
  240. package/dist/chunks/studio-xt3djk1w.js +0 -7
  241. package/dist/chunks/studio-yh3nggx1.js +0 -7
  242. package/dist/chunks/studio-zmecp6hq.js +0 -5
  243. package/dist/chunks/workspace-g6dvaete.js +0 -4
  244. package/dist/chunks/ws-client-shf41j5p.js +0 -4
@@ -13,12 +13,17 @@
13
13
 
14
14
  import { negotiateCollab } from "@jxsuite/collab/negotiate";
15
15
  import type { CollabNegotiation } from "@jxsuite/collab/negotiate";
16
+ // Static, and free to be: `reconnect.ts` imports nothing, so no Yjs reaches the initial bundle.
17
+ import { createReconnectScheduler } from "@jxsuite/collab/reconnect";
16
18
  import type { WsCollabConnection } from "@jxsuite/collab/client";
17
19
  import type { ProjectConfig } from "@jxsuite/schema/types";
18
20
  import { componentMetaFrom } from "@jxsuite/schema/component-meta";
19
21
  import { streamImport } from "../services/import-client";
20
22
  import type {
23
+ AccountEntry,
24
+ AccountNotice,
21
25
  AccountStatus,
26
+ ActionOutcome,
22
27
  CfAccountSummary,
23
28
  CfConnection,
24
29
  CfConnectOutcome,
@@ -28,12 +33,14 @@ import type {
28
33
  ExtensionCatalogEntry,
29
34
  ExtensionsInfo,
30
35
  FsEvent,
36
+ FsResyncReason,
31
37
  GitBranchesResult,
32
38
  GitLogEntry,
33
39
  GitStatusResult,
34
40
  ImportProgressEvent,
35
41
  ImportReadyEvent,
36
42
  ImportSiteOptions,
43
+ OfferedAction,
37
44
  PackageInfo,
38
45
  ProjectListEntry,
39
46
  SiteBuildResult,
@@ -46,6 +53,8 @@ import type {
46
53
  } from "../types";
47
54
  import { problemDetail, problemSlug } from "@jxsuite/protocol";
48
55
  import type { ReadFilesRequest, ReadFilesResult, UploadResult } from "@jxsuite/protocol";
56
+ import { reportActionRequired } from "../account/action-required";
57
+ import { offeredActions, retryHint } from "../platform-errors";
49
58
  import { createReadBatcher } from "./read-batcher";
50
59
  import type { BatchAnswer } from "./read-batcher";
51
60
 
@@ -115,13 +124,95 @@ async function isReadOnlyRefusal(res: Response): Promise<boolean> {
115
124
  return code === "read_only" || code === "read-only";
116
125
  }
117
126
 
127
+ /** A failure body's machine-readable members, each one optional and none of them trusted. */
128
+ interface StructuredBody extends ErrorBody {
129
+ type?: unknown;
130
+ installUrl?: unknown;
131
+ actions?: unknown;
132
+ heading?: unknown;
133
+ retry?: unknown;
134
+ }
135
+
136
+ /** The refusal's heading, when the body carries a usable one. */
137
+ function headingOf(body: StructuredBody | null): string | undefined {
138
+ return typeof body?.heading === "string" && body.heading ? body.heading : undefined;
139
+ }
140
+
141
+ /**
142
+ * The error a failed response becomes: the backend's sentence as the message, and every member a
143
+ * surface can act on carried alongside it for `platform-errors.ts` to recover — the install link a
144
+ * missing App installation sends, and the actions, heading and retry hint an `action-required`
145
+ * refusal sends (desktop.md §10.4).
146
+ *
147
+ * This used to be the message alone, everywhere but `createProject`, which had grown its own copy
148
+ * of this function to keep the install link. So a refusal reaching any other member arrived as flat
149
+ * text: the members that say "this is fixable, and here is how" were read and dropped.
150
+ */
151
+ async function platformError(res: Response, fallback: string): Promise<Error> {
152
+ let body: StructuredBody | null;
153
+ try {
154
+ body = (await res.json()) as StructuredBody | null;
155
+ } catch {
156
+ body = null;
157
+ }
158
+ const code = problemSlug(body?.type) ?? body?.code;
159
+ const actions = offeredActions(body?.actions);
160
+ const heading = headingOf(body);
161
+ const retry = retryHint(body?.retry);
162
+ return Object.assign(new Error(problemDetail(body) ?? fallback), {
163
+ status: res.status,
164
+ ...(code ? { code } : {}),
165
+ ...(typeof body?.installUrl === "string" ? { installUrl: body.installUrl } : {}),
166
+ ...(actions.length > 0 ? { actions } : {}),
167
+ ...(heading === undefined ? {} : { heading }),
168
+ ...(retry === undefined ? {} : { retry }),
169
+ });
170
+ }
171
+
118
172
  async function okJson<T>(res: Response, fallback: string): Promise<T> {
119
173
  if (!res.ok) {
120
- throw new Error(await errorMessage(res, fallback));
174
+ throw await platformError(res, fallback);
121
175
  }
122
176
  return (await res.json()) as T;
123
177
  }
124
178
 
179
+ /** Whether a failure body is an `action-required` refusal, by problem type or legacy code. */
180
+ function isActionRequiredBody(body: StructuredBody | null): boolean {
181
+ const code = problemSlug(body?.type) ?? body?.code;
182
+ return code === "action-required" || code === "action_required";
183
+ }
184
+
185
+ /**
186
+ * Raise an `action-required` refusal on the channel, read from a CLONE so the caller still gets the
187
+ * body it is about to parse for its own error. Only a refusal that offers something is raised: with
188
+ * no actions there is nothing to offer, and the caller's own error says the rest. Every other 403 —
189
+ * a read-only collaborator, a missing installation — is the caller's alone.
190
+ */
191
+ async function reportRefusal(res: Response): Promise<void> {
192
+ let body: StructuredBody | null;
193
+ try {
194
+ body = (await res.json()) as StructuredBody | null;
195
+ } catch {
196
+ return;
197
+ }
198
+ if (!isActionRequiredBody(body)) {
199
+ return;
200
+ }
201
+ const actions = offeredActions(body?.actions);
202
+ if (actions.length === 0) {
203
+ return;
204
+ }
205
+ const detail = problemDetail(body);
206
+ const heading = headingOf(body);
207
+ const retry = retryHint(body?.retry);
208
+ reportActionRequired({
209
+ actions,
210
+ ...(detail === null ? {} : { detail }),
211
+ ...(heading === undefined ? {} : { heading }),
212
+ ...(retry === undefined ? {} : { retry }),
213
+ });
214
+ }
215
+
125
216
  /** Editor URL for a project session (mirrors the shell's route). */
126
217
  export function editUrl(project: CloudProject): string {
127
218
  return `/edit/${project.owner}/${project.repo}@${encodeURIComponent(project.branch)}`;
@@ -222,17 +313,20 @@ let cfConnectFlow: CfConnectFlow | null = null;
222
313
  * Drive one hosted OAuth connect to a {@link CfConnectOutcome}. See `cfConnect` for the semantics;
223
314
  * this lives at module scope so the single-flight handle can too.
224
315
  */
225
- async function runCfConnect(handle: CfConnectFlow): Promise<CfConnectOutcome | null> {
316
+ async function runCfConnect(
317
+ handle: CfConnectFlow,
318
+ connectUrl: string,
319
+ ): Promise<CfConnectOutcome | null> {
226
320
  /* The baseline is read BEFORE the popup opens, and it is what keeps the poll honest: a row that
227
321
  was already healthy proves nothing about THIS flow, so the poll must not settle on one. Reading
228
322
  it after the popup opened would race the callback and could capture the new row as "old". */
229
323
  const baseline = await fetchCfConnection().catch(() => null);
230
324
  const healthyBaseline = Boolean(baseline?.connected && !baseline.needsReconnect);
231
- const popup = window.open("/api/v1/cf/connect", "cf-connect", "width=980,height=780");
325
+ const popup = window.open(connectUrl, "cf-connect", "width=980,height=780");
232
326
  if (!popup) {
233
327
  /* Popup blocked: the whole page is now navigating to the broker. Not a failure — the caller
234
328
  must render nothing at all rather than an error it will never get to show. */
235
- location.assign("/api/v1/cf/connect");
329
+ location.assign(connectUrl);
236
330
  return { status: "redirect" };
237
331
  }
238
332
  handle.popup = popup;
@@ -319,6 +413,152 @@ async function runCfConnect(handle: CfConnectFlow): Promise<CfConnectOutcome | n
319
413
  });
320
414
  }
321
415
 
416
+ // ─── Backend-directed access (desktop.md §10.4) ──────────────────────────────
417
+
418
+ /** `GET /api/v1/me`, as far as this adapter reads it. */
419
+ interface MeWire {
420
+ installations?: { id: number; account: string | null; manageUrl?: string }[];
421
+ appInstallUrl?: string;
422
+ entries?: unknown;
423
+ notices?: unknown;
424
+ }
425
+
426
+ /** A wire value as a non-empty string, or undefined. */
427
+ function wireText(value: unknown): string | undefined {
428
+ return typeof value === "string" && value ? value : undefined;
429
+ }
430
+
431
+ /**
432
+ * The account rows a `/me` body holds, each one checked. A row needs an id, a label and a detail to
433
+ * be drawn at all; a malformed one is dropped rather than drawn half-empty.
434
+ */
435
+ function accountEntries(value: unknown): AccountEntry[] {
436
+ if (!Array.isArray(value)) {
437
+ return [];
438
+ }
439
+ return value.flatMap((raw: Record<string, unknown> | null): AccountEntry[] => {
440
+ const id = wireText(raw?.["id"]);
441
+ const label = wireText(raw?.["label"]);
442
+ const detail = wireText(raw?.["detail"]);
443
+ if (!id || !label || !detail) {
444
+ return [];
445
+ }
446
+ const actions = offeredActions(raw?.["actions"]);
447
+ return [
448
+ {
449
+ detail,
450
+ id,
451
+ label,
452
+ ...(typeof raw?.["connected"] === "boolean" ? { connected: raw["connected"] } : {}),
453
+ ...(actions.length > 0 ? { actions } : {}),
454
+ },
455
+ ];
456
+ });
457
+ }
458
+
459
+ /** The notices a `/me` body holds, each one checked, on the same terms as the rows. */
460
+ function accountNotices(value: unknown): AccountNotice[] {
461
+ if (!Array.isArray(value)) {
462
+ return [];
463
+ }
464
+ return value.flatMap((raw: Record<string, unknown> | null): AccountNotice[] => {
465
+ const id = wireText(raw?.["id"]);
466
+ const title = wireText(raw?.["title"]);
467
+ const message = wireText(raw?.["message"]);
468
+ if (!id || !title || !message) {
469
+ return [];
470
+ }
471
+ const actions = offeredActions(raw?.["actions"]);
472
+ const level = raw?.["level"];
473
+ const display = raw?.["display"];
474
+ return [
475
+ {
476
+ id,
477
+ message,
478
+ title,
479
+ ...(level === "info" || level === "warning" ? { level } : {}),
480
+ ...(display === "banner" || display === "toast" ? { display } : {}),
481
+ ...(actions.length > 0 ? { actions } : {}),
482
+ },
483
+ ];
484
+ });
485
+ }
486
+
487
+ interface ActionFlow {
488
+ popup: Window | null;
489
+ promise: Promise<ActionOutcome | null>;
490
+ }
491
+
492
+ /**
493
+ * The action window currently open, or null — module-level for `cfConnectFlow`'s reason: the
494
+ * window's target name is global to the browsing context, and two flows would share one window.
495
+ */
496
+ let actionFlow: ActionFlow | null = null;
497
+
498
+ /** How long an open action window is waited on before the flow gives up on hearing back. */
499
+ const ACTION_DEADLINE_MS = 15 * 60_000;
500
+
501
+ /**
502
+ * How long a closed window is given for its relay to land. A page that relays and then closes
503
+ * itself can be seen closed a moment before its message is delivered, and reading that as "closed
504
+ * without saying" would put an offer back in front of someone who just completed it.
505
+ */
506
+ const CLOSE_GRACE_MS = 300;
507
+
508
+ /**
509
+ * Wait on an action window the caller has ALREADY opened (opening it is the caller's, because only
510
+ * code still inside the click may). Settles on the window's own relay — `{source: "jx-action",
511
+ * status}`, same-origin, which is the platform's to send from wherever the action ends — or
512
+ * `unknown` when the window closes, or the deadline passes, without one.
513
+ */
514
+ function awaitAction(popup: Window): Promise<ActionOutcome> {
515
+ const deadline = Date.now() + ACTION_DEADLINE_MS;
516
+ return new Promise<ActionOutcome>((resolve, reject) => {
517
+ let timer = 0;
518
+ const cleanup = () => {
519
+ window.removeEventListener("message", onMessage);
520
+ window.clearTimeout(timer);
521
+ if (!popup.closed) {
522
+ popup.close();
523
+ }
524
+ };
525
+ const settle = (outcome: ActionOutcome) => {
526
+ cleanup();
527
+ resolve(outcome);
528
+ };
529
+ const onMessage = (event: MessageEvent) => {
530
+ if (event.origin !== location.origin) {
531
+ return;
532
+ }
533
+ const data = event.data as { source?: string; status?: string; reason?: string | null };
534
+ if (!data || data.source !== "jx-action") {
535
+ return;
536
+ }
537
+ if (data.status === "done" || data.status === "canceled") {
538
+ settle({ status: data.status });
539
+ } else if (data.status === "error") {
540
+ cleanup();
541
+ reject(new Error(data.reason ?? "The action could not be completed"));
542
+ }
543
+ };
544
+ window.addEventListener("message", onMessage);
545
+ const watch = () => {
546
+ if (Date.now() > deadline) {
547
+ settle({ status: "unknown" });
548
+ return;
549
+ }
550
+ if (popup.closed) {
551
+ timer = window.setTimeout(() => {
552
+ settle({ status: "unknown" });
553
+ }, CLOSE_GRACE_MS);
554
+ return;
555
+ }
556
+ timer = window.setTimeout(watch, 1000);
557
+ };
558
+ timer = window.setTimeout(watch, 1000);
559
+ });
560
+ }
561
+
322
562
  /** Parse an "owner/repo@branch" root key; null when malformed. */
323
563
  export function parseRootKey(root: string): CloudProject | null {
324
564
  const match = /^([^/@]+)\/([^/@]+)@(.+)$/.exec(root);
@@ -387,13 +627,35 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
387
627
  /** Lazy subprotocol negotiation from the gateway's capability probe (null = not asked yet). */
388
628
  let collabNegotiation: Promise<CollabNegotiation> | null = null;
389
629
 
390
- function api(path: string, init?: RequestInit): Promise<Response> {
630
+ /**
631
+ * Every session request, and the one place an `action-required` refusal is noticed.
632
+ *
633
+ * A refusal is reported on the channel and then returned like any other failure, so the caller
634
+ * still throws its own error with its own wording. The channel is what tells the user what they
635
+ * can do; the caller's error is what tells it the request failed. Neither has to know about the
636
+ * other, which is what lets a directory listing deep in the file tree stay ignorant of access
637
+ * rules.
638
+ */
639
+ async function api(path: string, init?: RequestInit): Promise<Response> {
391
640
  if (!project) {
392
- return Promise.reject(new Error("No project is open in this session"));
641
+ throw new Error("No project is open in this session");
393
642
  }
394
- return fetch(`${base}${path}`, { credentials: "include", ...init });
643
+ const res = await fetch(`${base}${path}`, { credentials: "include", ...init });
644
+ if (res.status === 403) {
645
+ await reportRefusal(res.clone());
646
+ }
647
+ return res;
395
648
  }
396
649
 
650
+ /**
651
+ * The bound project as the hint the platform's unscoped routes read (`owner/repo`), or null in
652
+ * the hub. The AI and Cloudflare routes are not under the session base, so this is the only way
653
+ * they learn which project a call is for — which is what a backend whose access rules depend on
654
+ * the repository needs to know. A hint, not a credential: the platform verifies it against the
655
+ * user's own access before it counts.
656
+ */
657
+ const projectHint = project ? `${project.owner}/${project.repo}` : null;
658
+
397
659
  function postJson(path: string, body: unknown): Promise<Response> {
398
660
  return api(path, {
399
661
  method: "POST",
@@ -520,11 +782,17 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
520
782
  (owner, name, visibility) rather than a folder. */
521
783
  createDestination: "repo",
522
784
 
785
+ /* A refusal throws rather than resolving as though the session were live. This used to ignore
786
+ the response entirely, so a project the backend would not open "activated" fine and the first
787
+ visible failure was a generic "Failed to load project" two calls later. */
523
788
  async activate() {
524
789
  if (!project) {
525
790
  return;
526
791
  }
527
- await postJson("/activate", {});
792
+ const res = await postJson("/activate", {});
793
+ if (!res.ok) {
794
+ throw await platformError(res, "Failed to open the project session");
795
+ }
528
796
  },
529
797
 
530
798
  /* The shell binds the session to one repo+branch before Studio boots, so
@@ -692,11 +960,25 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
692
960
  },
693
961
 
694
962
  /**
695
- * Live session events over the gateway WebSocket. Reconnects with a small backoff; the DO
696
- * pushes {kind:"fs"} batches for file mutations (including those from other tabs) and
697
- * {kind:"git"} notices this handler ignores.
963
+ * Live session events over the gateway WebSocket. The DO pushes {kind:"fs"} batches for file
964
+ * mutations (including those from other tabs) and {kind:"git"} notices.
965
+ *
966
+ * The socket reconnects on jittered backoff, cut short by the browser's wake signals
967
+ * (`@jxsuite/collab/reconnect`): every Studio on a gateway loses it at the same deploy, and a
968
+ * laptop that wakes onto a network should not sit out a 30-second timer first.
969
+ *
970
+ * Two things the event stream cannot say on its own are reported through `onResync`. A socket
971
+ * that came back missed every batch sent while it was gone, and the DO does not replay them —
972
+ * so every open after the first is a `"reconnect"`. The first open is an `"open"`: the
973
+ * session's listings carry content versions, the boot's first listings race the handshake, and
974
+ * a change broadcast before this socket joined (a first connect that fails during a gateway
975
+ * deploy can stretch that to a whole backoff) reached nobody. And a commit lands with a
976
+ * `committed` notice and no per-file events, so it is a `"commit"`.
698
977
  */
699
- subscribeFileEvents(handler: (events: FsEvent[]) => void) {
978
+ subscribeFileEvents(
979
+ handler: (events: FsEvent[]) => void,
980
+ options?: { onResync?: (reason: FsResyncReason) => void },
981
+ ) {
700
982
  // No project means an empty base, so the bare `/events` URL hits no gateway route.
701
983
  // That fails in a reconnect loop; mirror `collab`'s guard and degrade to no-op on the hub.
702
984
  if (!project || typeof WebSocket === "undefined" || typeof location === "undefined") {
@@ -704,11 +986,12 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
704
986
  }
705
987
  let socket: WebSocket | null = null;
706
988
  let closed = false;
707
- let retryMs = 1000;
989
+ let everOpened = false;
708
990
  const connect = () => {
709
991
  const scheme = location.protocol === "https:" ? "wss" : "ws";
710
- socket = new WebSocket(`${scheme}://${location.host}${base}/events`);
711
- socket.addEventListener("message", (ev: MessageEvent) => {
992
+ const ws = new WebSocket(`${scheme}://${location.host}${base}/events`);
993
+ socket = ws;
994
+ ws.addEventListener("message", (ev: MessageEvent) => {
712
995
  let payload: SessionEventWire;
713
996
  try {
714
997
  payload = JSON.parse(ev.data as string) as SessionEventWire;
@@ -717,21 +1000,26 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
717
1000
  }
718
1001
  if (payload.kind === "fs" && payload.events?.length) {
719
1002
  handler(payload.events);
1003
+ } else if (payload.kind === "git" && payload.event === "committed") {
1004
+ options?.onResync?.("commit");
720
1005
  }
721
1006
  });
722
- socket.addEventListener("open", () => {
723
- retryMs = 1000;
1007
+ ws.addEventListener("open", () => {
1008
+ scheduler.opened();
1009
+ options?.onResync?.(everOpened ? "reconnect" : "open");
1010
+ everOpened = true;
724
1011
  });
725
- socket.addEventListener("close", () => {
1012
+ ws.addEventListener("close", () => {
726
1013
  if (!closed) {
727
- setTimeout(connect, retryMs);
728
- retryMs = Math.min(retryMs * 2, 30_000);
1014
+ scheduler.scheduleRetry();
729
1015
  }
730
1016
  });
731
1017
  };
1018
+ const scheduler = createReconnectScheduler(connect);
732
1019
  connect();
733
1020
  return () => {
734
1021
  closed = true;
1022
+ scheduler.dispose();
735
1023
  socket?.close();
736
1024
  };
737
1025
  },
@@ -1190,22 +1478,9 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1190
1478
  }),
1191
1479
  });
1192
1480
  if (!res.ok) {
1193
- // Preserve the structured 403 (needs_installation_access + installUrl) so the New
1194
- // Project modal can render an install link instead of flattened text.
1195
- const body = (await res.json().catch(() => null)) as
1196
- | (ErrorBody & { installUrl?: string; type?: string })
1197
- | null;
1198
- /*
1199
- * `code` survives as the machine-readable discriminator the modal branches on, and a
1200
- * problem document supplies it from its `type` — `problemSlug` derives the same string the
1201
- * old `code` field carried, so the modal's branch is unchanged either way.
1202
- */
1203
- throw Object.assign(new Error(problemDetail(body) ?? "Failed to create project"), {
1204
- ...((problemSlug(body?.type) ?? body?.code)
1205
- ? { code: problemSlug(body?.type) ?? body?.code }
1206
- : {}),
1207
- ...(body?.installUrl ? { installUrl: body.installUrl } : {}),
1208
- });
1481
+ /* Structured, so the New Project modal can act on what failed: an install link for a
1482
+ missing App installation, the offered actions of an `action-required` refusal. */
1483
+ throw await platformError(res, "Failed to create project");
1209
1484
  }
1210
1485
  const created = (await res.json()) as { owner: string; name: string; defaultBranch: string };
1211
1486
  return {
@@ -1305,18 +1580,19 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1305
1580
  },
1306
1581
 
1307
1582
  /**
1308
- * GitHub-App installation coverage from /me — powers the welcome install prompt and the repo
1309
- * picker's "grant access to more repositories" links (`manageUrl` per installation).
1583
+ * The account status from /me: GitHub-App installation coverage — which powers the welcome
1584
+ * install prompt and the repo picker's "grant access to more repositories" links (`manageUrl`
1585
+ * per installation) — and whatever account rows and notices the platform sends (desktop.md
1586
+ * §10.4), each checked before it is drawn.
1310
1587
  */
1311
1588
  async getAccountStatus() {
1312
1589
  const res = await fetch("/api/v1/me", { credentials: "include" });
1313
1590
  if (!res.ok) {
1314
1591
  return null;
1315
1592
  }
1316
- const me = (await res.json()) as {
1317
- installations?: { id: number; account: string | null; manageUrl?: string }[];
1318
- appInstallUrl?: string;
1319
- };
1593
+ const me = (await res.json()) as MeWire;
1594
+ const entries = accountEntries(me.entries);
1595
+ const notices = accountNotices(me.notices);
1320
1596
  return {
1321
1597
  installations: (me.installations ?? []).map((entry) => {
1322
1598
  const installation: AccountStatus["installations"][number] = {
@@ -1329,9 +1605,49 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1329
1605
  return installation;
1330
1606
  }),
1331
1607
  ...(me.appInstallUrl ? { appInstallUrl: me.appInstallUrl } : {}),
1608
+ ...(entries.length > 0 ? { entries } : {}),
1609
+ ...(notices.length > 0 ? { notices } : {}),
1332
1610
  };
1333
1611
  },
1334
1612
 
1613
+ /**
1614
+ * An offered action, in a window of its own. Synchronous up to `window.open`, which is the
1615
+ * whole contract (types.ts): this runs inside a click, and a window opened after an `await` is
1616
+ * one the browser blocks. So the target is the action's own `href`, never something that has to
1617
+ * be fetched first; an action with no `href` is not one this platform performs.
1618
+ *
1619
+ * A blocked window navigates the page itself, and whatever the platform has to say about how
1620
+ * that ended arrives as a `toast` notice once the page is back. Concurrent calls join the
1621
+ * running flow, for `cfConnect`'s reason.
1622
+ */
1623
+ performAction(action: OfferedAction) {
1624
+ if (typeof window === "undefined" || typeof location === "undefined" || !action.href) {
1625
+ return Promise.resolve(null);
1626
+ }
1627
+ const running = actionFlow;
1628
+ if (running) {
1629
+ try {
1630
+ running.popup?.focus();
1631
+ } catch {
1632
+ // A cross-origin window may refuse focus; joining the flow is what mattered.
1633
+ }
1634
+ return running.promise;
1635
+ }
1636
+ const popup = window.open(action.href, "jx-action", "width=980,height=820");
1637
+ if (!popup) {
1638
+ location.assign(action.href);
1639
+ return Promise.resolve({ status: "redirect" } as const);
1640
+ }
1641
+ const handle: ActionFlow = { popup, promise: Promise.resolve(null) };
1642
+ actionFlow = handle;
1643
+ handle.promise = awaitAction(popup).finally(() => {
1644
+ if (actionFlow === handle) {
1645
+ actionFlow = null;
1646
+ }
1647
+ });
1648
+ return handle.promise;
1649
+ },
1650
+
1335
1651
  /** Open a PR from this session's branch (ProjectSession /git/pr). */
1336
1652
  async createPullRequest(opts: { title: string; body?: string; head?: string; base?: string }) {
1337
1653
  return okJson<{ url: string; number: number }>(
@@ -1378,7 +1694,12 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1378
1694
  }
1379
1695
  const handle: CfConnectFlow = { popup: null, promise: Promise.resolve(null) };
1380
1696
  cfConnectFlow = handle;
1381
- handle.promise = runCfConnect(handle).finally(() => {
1697
+ /* The project rides along so access rules keyed on the repository can apply to the connect,
1698
+ which is not under the session base and would otherwise not know which project asked. */
1699
+ const connectUrl = projectHint
1700
+ ? `/api/v1/cf/connect?project=${encodeURIComponent(projectHint)}`
1701
+ : "/api/v1/cf/connect";
1702
+ handle.promise = runCfConnect(handle, connectUrl).finally(() => {
1382
1703
  if (cfConnectFlow === handle) {
1383
1704
  cfConnectFlow = null;
1384
1705
  }
@@ -1423,16 +1744,30 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1423
1744
 
1424
1745
  /** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */
1425
1746
  async cfApi(apiPath: string, init?: { method?: string; body?: unknown }) {
1747
+ /* The project rides in a HEADER, never the query: the platform forwards this path's query
1748
+ string to Cloudflare verbatim. */
1749
+ const headers: Record<string, string> = {
1750
+ ...(projectHint ? { "X-Jx-Project": projectHint } : {}),
1751
+ ...(init?.body === undefined ? {} : { "Content-Type": "application/json" }),
1752
+ };
1426
1753
  const res = await fetch(`/api/v1/cf/proxy${apiPath}`, {
1427
1754
  method: init?.method ?? "GET",
1428
1755
  credentials: "include",
1429
- ...(init?.body === undefined
1430
- ? {}
1431
- : {
1432
- headers: { "Content-Type": "application/json" },
1433
- body: JSON.stringify(init.body),
1434
- }),
1756
+ ...(Object.keys(headers).length === 0 ? {} : { headers }),
1757
+ ...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }),
1435
1758
  });
1759
+ /* An `action-required` refusal is the platform's answer, not Cloudflare's, so it is not
1760
+ dressed as one: it keeps its own sentence and the actions the publish flow offers. Read
1761
+ from a clone, because every other 403 is Cloudflare's own and is parsed below. */
1762
+ if (res.status === 403) {
1763
+ const refusal = (await res
1764
+ .clone()
1765
+ .json()
1766
+ .catch(() => null)) as StructuredBody | null;
1767
+ if (isActionRequiredBody(refusal)) {
1768
+ throw await platformError(res, "Publishing was refused");
1769
+ }
1770
+ }
1436
1771
  const envelope = (await res.json()) as {
1437
1772
  success?: boolean;
1438
1773
  result?: unknown;
@@ -1449,8 +1784,12 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
1449
1784
 
1450
1785
  // ─── AI (platform Workers AI proxy, StreamEvent SSE) ───────────────────
1451
1786
 
1787
+ /* With a project bound, the chat route is told which one — `siblingRoute` keeps the query, so
1788
+ the `/models` probe carries it too. */
1452
1789
  aiChatUrl() {
1453
- return "/api/v1/ai/chat";
1790
+ return projectHint
1791
+ ? `/api/v1/ai/chat?project=${encodeURIComponent(projectHint)}`
1792
+ : "/api/v1/ai/chat";
1454
1793
  },
1455
1794
  };
1456
1795
 
@@ -28,6 +28,7 @@ import type {
28
28
  ExtensionCatalogEntry,
29
29
  ExtensionsInfo,
30
30
  FsEvent,
31
+ FsResyncReason,
31
32
  ImportProgressEvent,
32
33
  ImportReadyEvent,
33
34
  ImportSiteOptions,
@@ -495,12 +496,27 @@ export function createDevServerPlatform() {
495
496
  * Subscribe to filesystem change events over the dev server's SSE stream. Listens for the named
496
497
  * "fs" event (the preview iframe's default `onmessage` ignores it), strips paths to
497
498
  * project-relative, and drops events for sibling projects outside the active root.
499
+ *
500
+ * `EventSource` reconnects by itself, and the dev server does not replay what it sent while the
501
+ * stream was down — a restarted `bun run dev` is the usual cause. So every `open` after the
502
+ * first reports a `"reconnect"` resync: one re-list of the cached tree is cheap next to an
503
+ * author editing a file the sidebar says is not there.
498
504
  */
499
- subscribeFileEvents(handler: (events: FsEvent[]) => void) {
505
+ subscribeFileEvents(
506
+ handler: (events: FsEvent[]) => void,
507
+ options?: { onResync?: (reason: FsResyncReason) => void },
508
+ ) {
500
509
  if (typeof EventSource === "undefined") {
501
510
  return () => {};
502
511
  }
503
512
  const es = new EventSource("/__reload");
513
+ let opens = 0;
514
+ es.addEventListener("open", () => {
515
+ opens += 1;
516
+ if (opens > 1) {
517
+ options?.onResync?.("reconnect");
518
+ }
519
+ });
504
520
  es.addEventListener("fs", (ev: MessageEvent) => {
505
521
  let payload: { events?: FsEvent[] };
506
522
  try {