@jxsuite/studio 5.1.0 → 5.2.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 (103) hide show
  1. package/dist/chunks/{cf-account-picker-qwkm9zrf.js → cf-account-picker-h4dab9bq.js} +10 -10
  2. package/dist/chunks/{cf-settings-71bv5zn9.js → cf-settings-adg74qsk.js} +4 -4
  3. package/dist/chunks/{components-vj72gqtq.js → components-5y78e05d.js} +4 -4
  4. package/dist/chunks/content-source-sgg1sv6n.js +46 -0
  5. package/dist/chunks/{convert-to-repeater-n64h8znc.js → convert-to-repeater-mf1cxgfn.js} +22 -22
  6. package/dist/chunks/entry-commands-w9qsxa6q.js +42 -0
  7. package/dist/chunks/{extension-sections-3hpcambp.js → extension-sections-c6zwzbre.js} +26 -26
  8. package/dist/chunks/{file-ops-8yhd83xr.js → file-ops-vrpq1jjk.js} +22 -22
  9. package/dist/chunks/{files-hnxqxtsg.js → files-msaf95y6.js} +31 -30
  10. package/dist/chunks/{format-host-779sfp2n.js → format-host-f64fnesp.js} +2 -2
  11. package/dist/chunks/{fs-events-b88z7g4h.js → fs-events-3y6y0nrm.js} +4 -4
  12. package/dist/chunks/{git-panel-xwj8fpab.js → git-panel-bv5e22j0.js} +23 -22
  13. package/dist/chunks/grid-view-1p6hsyjx.css +8406 -0
  14. package/dist/chunks/grid-view-paj67nr5.js +15322 -0
  15. package/dist/chunks/{iframe-host-syr0f9sb.js → iframe-host-dh5np8vk.js} +13 -13
  16. package/dist/chunks/{layers-ff81wthn.js → layers-1vcfdbet.js} +5 -5
  17. package/dist/chunks/layers-panel-fksbdg4q.js +40 -0
  18. package/dist/chunks/{media-picker-cqhhxhhe.js → media-picker-p49nhqhs.js} +13 -13
  19. package/dist/chunks/menu-qht48vnc.js +15 -0
  20. package/dist/chunks/{monaco-buffer-bhy3hb6m.js → monaco-buffer-pykwbm92.js} +3 -3
  21. package/dist/chunks/{notify-628t0anh.js → notify-42gk8ha1.js} +2 -2
  22. package/dist/chunks/{parse-0zx14y15.js → parse-reg9eevt.js} +2 -2
  23. package/dist/chunks/publish-panel-vz23wwmg.js +27 -0
  24. package/dist/chunks/quick-search-e9rp74m8.js +46 -0
  25. package/dist/chunks/{references-qvvxej7h.js → references-h69xp9zz.js} +2 -2
  26. package/dist/chunks/right-panel-j97t2yvy.js +45 -0
  27. package/dist/chunks/{studio-b0k5tqgp.js → studio-01jeck2y.js} +31 -27
  28. package/dist/chunks/{studio-wqz6dggm.js → studio-14d9tq4d.js} +9 -9
  29. package/dist/chunks/{studio-try13h8c.js → studio-15f3kxqe.js} +18 -18
  30. package/dist/chunks/studio-1tbjfqv4.js +146 -0
  31. package/dist/chunks/{studio-vw7mzp6z.js → studio-28vs5k2y.js} +2 -2
  32. package/dist/chunks/{studio-93zafjcm.js → studio-2mdbbc76.js} +10 -10
  33. package/dist/chunks/{studio-7r42c6ca.js → studio-3ntvq6cg.js} +5 -5
  34. package/dist/chunks/{studio-g3jevyjt.js → studio-4c041r8y.js} +2 -2
  35. package/dist/chunks/{studio-3773086z.js → studio-4r14yzyy.js} +4 -4
  36. package/dist/chunks/{studio-3rz5md06.js → studio-4xmjcrg3.js} +6 -6
  37. package/dist/chunks/{studio-syg2tadj.js → studio-5nz00bhh.js} +3 -3
  38. package/dist/chunks/{studio-1knfqgy7.js → studio-5t7ena5z.js} +8 -8
  39. package/dist/chunks/{studio-a79k90n9.js → studio-66arbpc2.js} +8 -8
  40. package/dist/chunks/{studio-b4s9eykd.js → studio-68fydsrj.js} +15 -15
  41. package/dist/chunks/{studio-mhhyamxf.js → studio-9j6sfeba.js} +6 -6
  42. package/dist/chunks/{studio-7xnmgv66.js → studio-a1pswps3.js} +37 -37
  43. package/dist/chunks/{studio-6xa21ej9.js → studio-c04nzbby.js} +2 -2
  44. package/dist/chunks/{studio-nzzpvzx9.js → studio-c07s5ckd.js} +4 -4
  45. package/dist/chunks/{studio-js8nv53n.js → studio-ctm7bhx5.js} +14 -14
  46. package/dist/chunks/{studio-p1dw8wfw.js → studio-d8z5bajd.js} +14 -10
  47. package/dist/chunks/{studio-jxp12rnj.js → studio-en82c639.js} +6 -6
  48. package/dist/chunks/{studio-1z1kcjv4.js → studio-etvx9s2g.js} +11 -11
  49. package/dist/chunks/{studio-k75n7vhp.js → studio-ez11dmdh.js} +74 -36
  50. package/dist/chunks/{studio-qhhm6hbt.js → studio-hdc2jt3z.js} +4 -4
  51. package/dist/chunks/{studio-r6nvacqs.js → studio-hhskq2mr.js} +19 -19
  52. package/dist/chunks/{studio-6ewqv390.js → studio-m1p9b6c3.js} +5 -5
  53. package/dist/chunks/{studio-qk90fgqm.js → studio-mpzwze9x.js} +7 -7
  54. package/dist/chunks/{studio-e1pgga3k.js → studio-ncn9e189.js} +7 -7
  55. package/dist/chunks/{studio-kkqkcp3d.js → studio-p6pmwkvh.js} +10 -10
  56. package/dist/chunks/{studio-aj37eer3.js → studio-qa7fzxan.js} +4 -4
  57. package/dist/chunks/{studio-sbjyc8bt.js → studio-sar8g54p.js} +8 -8
  58. package/dist/chunks/studio-t171hxhe.js +23 -0
  59. package/dist/chunks/{studio-pxbgypmg.js → studio-t3adrr56.js} +17 -14
  60. package/dist/chunks/{studio-hhvs4sdg.js → studio-vn5hske5.js} +10 -4
  61. package/dist/chunks/{studio-5wt9nvhd.js → studio-vt0tj32j.js} +29 -42
  62. package/dist/chunks/{studio-qk4bfsq2.js → studio-wjpgxywa.js} +4 -4
  63. package/dist/chunks/{studio-q3x06vmj.js → studio-wnsyffg1.js} +2 -2
  64. package/dist/chunks/{studio-gb13095a.js → studio-x10fp4tp.js} +59 -20
  65. package/dist/chunks/{studio-8scdew0e.js → studio-x72j58vw.js} +4 -4
  66. package/dist/chunks/{studio-tqgryxwg.js → studio-y4f9fdge.js} +7 -7
  67. package/dist/chunks/{studio-vfq8qpjt.js → studio-ympe0n7a.js} +3 -3
  68. package/dist/chunks/{studio-y9zedyjn.js → studio-z2smx5ce.js} +2 -2
  69. package/dist/chunks/{studio-zdn2g6dt.js → studio-zjjde71a.js} +2 -2
  70. package/dist/chunks/{workspace-t55jdjbx.js → workspace-bnw54cha.js} +18 -10
  71. package/dist/iframe-entry.js +7 -3
  72. package/dist/studio.css +1 -2
  73. package/dist/studio.js +866 -15698
  74. package/package.json +6 -6
  75. package/src/files/files.ts +38 -15
  76. package/src/grid/grid-lazy.ts +59 -0
  77. package/src/grid/grid-open.ts +20 -3
  78. package/src/grid/grid-panel.ts +43 -9
  79. package/src/media/media-open.ts +14 -2
  80. package/src/panels/pane-context.ts +5 -4
  81. package/src/panels/pane-grid.ts +100 -10
  82. package/src/panels/tab-drop.ts +220 -0
  83. package/src/panels/tab-strip.ts +235 -30
  84. package/src/services/ai-models.ts +13 -0
  85. package/src/studio.ts +10 -6
  86. package/src/surfaces/commandbar.json +26 -16
  87. package/src/surfaces/pane-grid.json +35 -3
  88. package/src/surfaces/pane-grid.ts +26 -4
  89. package/src/surfaces/tab-strip.json +16 -39
  90. package/src/surfaces/tab-strip.ts +76 -12
  91. package/src/ui/ai-credentials-form.ts +12 -1
  92. package/src/workspace/pane-derive.ts +7 -5
  93. package/src/workspace/session.ts +3 -3
  94. package/src/workspace/workspace.ts +160 -43
  95. package/styles/shell-frame.css +8 -0
  96. package/styles/shell-frame.json +4 -0
  97. package/dist/chunks/content-source-5mex4x1h.js +0 -46
  98. package/dist/chunks/entry-commands-234hjnqw.js +0 -42
  99. package/dist/chunks/layers-panel-pnnghw12.js +0 -40
  100. package/dist/chunks/menu-9p2w46gj.js +0 -15
  101. package/dist/chunks/publish-panel-5zt840sy.js +0 -27
  102. package/dist/chunks/quick-search-3748564r.js +0 -45
  103. package/dist/chunks/right-panel-eccz92qt.js +0 -45
@@ -0,0 +1,220 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Resolve a completed pragmatic drag over a tab strip or a pane's right edge into a model write.
4
+ *
5
+ * `panels/tab-strip.ts` registers every chip as a source and every strip and chip as a target,
6
+ * `panels/pane-grid.ts` registers the right-edge zone, and both hand their monitor's `onDrop` here
7
+ * with nothing decided yet — the DATA a target's `getData` produced, and nothing else. This module
8
+ * is the one place that DATA becomes a write, which is what keeps the two registration sites (a
9
+ * strip, a grid cell) from having to agree twice about what a drop means.
10
+ *
11
+ * No import of `tab-strip.ts` or `pane-grid.ts`: this module reaches the model through
12
+ * `workspace/workspace.ts` and `workspace/pane-derive.ts`, and the one opener through
13
+ * `files/files.ts` — the same three a keyboard command would use, because a drag and `⌘\` / "Open
14
+ * to the Side" are the same act by a different gesture (studio-ui-guidelines.md §8.2).
15
+ *
16
+ * @docs studio/interface/tabs
17
+ */
18
+
19
+ import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
20
+ import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
21
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
22
+ import {
23
+ activateTab,
24
+ closePane,
25
+ moveTab,
26
+ moveTabToPane,
27
+ paneById,
28
+ paneIsEmpty,
29
+ promoteTab,
30
+ receivingPane,
31
+ splitRight,
32
+ workspace,
33
+ } from "../workspace/workspace";
34
+ import { clearPaneDerivation } from "../workspace/pane-derive";
35
+ import { openFileInTab } from "../files/files";
36
+
37
+ /** A tab chip dragged off its own strip. */
38
+ export interface TabDragData extends Record<string, unknown> {
39
+ type: "tab";
40
+ tabId: string;
41
+ /**
42
+ * The pane the chip is being dragged FROM, read at drag start — a host can change which pane it
43
+ * draws, so this is never captured earlier than the gesture itself.
44
+ */
45
+ paneId?: string;
46
+ }
47
+
48
+ /** A file row dragged off the Files tree — `files.ts`'s own source shape, read structurally. */
49
+ export interface FileDragData extends Record<string, unknown> {
50
+ type: "file-tree";
51
+ entryType: string;
52
+ path: string;
53
+ }
54
+
55
+ export type TabDropSourceData = TabDragData | FileDragData;
56
+
57
+ /** A slot in a strip — a chip's edge, or the strip's own empty tail when `tabId` is absent. */
58
+ export interface TabSlotTarget extends Record<string, unknown> {
59
+ type: "tab-slot";
60
+ paneId: string;
61
+ tabId?: string;
62
+ /** The target's own slot in `paneId`'s order at `getData` time — the tail's length, otherwise. */
63
+ index: number;
64
+ }
65
+
66
+ export interface TabSlotRefusedTarget extends Record<string, unknown> {
67
+ type: "tab-slot-refused";
68
+ }
69
+
70
+ /** The right band of the last pane's stage, armed only while the grid is under {@link MAX_PANES}. */
71
+ export interface PaneEdgeTarget extends Record<string, unknown> {
72
+ type: "pane-edge";
73
+ paneId: string;
74
+ }
75
+
76
+ export interface PaneEdgeRefusedTarget extends Record<string, unknown> {
77
+ type: "pane-edge-refused";
78
+ }
79
+
80
+ export type TabDropTarget =
81
+ | TabSlotTarget
82
+ | TabSlotRefusedTarget
83
+ | PaneEdgeTarget
84
+ | PaneEdgeRefusedTarget;
85
+
86
+ /**
87
+ * Whether `data` — a drag source's own data, exactly as `getInitialData` produced it — is a source
88
+ * this module knows how to land. A directory row is refused HERE, in the data, rather than by
89
+ * omitting a `dropTargetForElements` registration: pragmatic-dnd bubbles a refusal to the next
90
+ * target OUT, and the next target out of a tab strip is the pane cell, which is not a target at all
91
+ * — so an unhandled refusal would not merely do nothing, it would do nothing silently in a way
92
+ * indistinguishable from a bug. Every caller answers `canDrop` with this, so a directory row (or
93
+ * anything else this module has no branch for) is refused at the SOURCE and never reaches a target
94
+ * closure at all.
95
+ */
96
+ export function isTabDropSource(data: Record<string, unknown>): data is TabDropSourceData {
97
+ return data.type === "tab" || (data.type === "file-tree" && data.entryType === "file");
98
+ }
99
+
100
+ /**
101
+ * Land a tab at a specific slot of `paneId`, releasing any derivation the pane is borrowing FIRST —
102
+ * the same order `receivingPane` uses, and for the same reason: inserting into a `tabOrder` while
103
+ * `pane.derived` is still set is the one-frame D2 violation its own docstring describes, observable
104
+ * to anything that reads the pane between the two writes.
105
+ */
106
+ function placeTab(tabId: string, paneId: string, index: number): void {
107
+ const pane = paneById(paneId);
108
+ if (pane?.derived) {
109
+ clearPaneDerivation(paneId);
110
+ }
111
+ const landed = moveTabToPane(tabId, paneId, index);
112
+ if (!landed) {
113
+ return;
114
+ }
115
+ // A drag is a commitment, exactly as a double-click promoting a preview tab is one.
116
+ promoteTab(tabId);
117
+ activateTab(tabId);
118
+ }
119
+
120
+ /** {@link placeTab}'s file half: land the file's tab at `index`, opening it first if it is not. */
121
+ async function placeFile(path: string, paneId: string, index: number): Promise<void> {
122
+ const existing = [...workspace.tabs.values()].find((tab) => tab.documentPath === path);
123
+ if (existing) {
124
+ placeTab(existing.id, paneId, index);
125
+ return;
126
+ }
127
+ await openFileInTab(path, { focus: true, paneId });
128
+ // Idempotent by construction: `openFileInTab` may already have landed the tab at its default
129
+ // Slot, and this re-finds it by path and moves it to the slot the drop actually asked for.
130
+ const opened = [...workspace.tabs.values()].find((tab) => tab.documentPath === path);
131
+ if (opened) {
132
+ placeTab(opened.id, paneId, index);
133
+ }
134
+ }
135
+
136
+ /** The landing index a chip-edge target names, or the tail target's own index when there is none. */
137
+ function edgeIndex(target: TabSlotTarget, edge: Edge | null): number {
138
+ if (target.tabId === undefined) {
139
+ return target.index;
140
+ }
141
+ return edge === "left" ? target.index : target.index + 1;
142
+ }
143
+
144
+ async function resolveTabSlotDrop(source: TabDropSourceData, target: TabSlotTarget): Promise<void> {
145
+ const edge = extractClosestEdge(target as unknown as Record<string | symbol, unknown>);
146
+ if (source.type === "tab") {
147
+ if (source.paneId === target.paneId) {
148
+ const pane = paneById(target.paneId);
149
+ if (!pane) {
150
+ return;
151
+ }
152
+ const startIndex = pane.tabOrder.indexOf(source.tabId);
153
+ if (startIndex === -1) {
154
+ return;
155
+ }
156
+ const to =
157
+ target.tabId === undefined
158
+ ? pane.tabOrder.length - 1
159
+ : getReorderDestinationIndex({
160
+ axis: "horizontal",
161
+ closestEdgeOfTarget: edge,
162
+ indexOfTarget: target.index,
163
+ startIndex,
164
+ });
165
+ if (to !== startIndex) {
166
+ moveTab(source.tabId, to);
167
+ }
168
+ return;
169
+ }
170
+ placeTab(source.tabId, target.paneId, edgeIndex(target, edge));
171
+ return;
172
+ }
173
+ await placeFile(source.path, target.paneId, edgeIndex(target, edge));
174
+ }
175
+
176
+ async function resolvePaneEdgeDrop(
177
+ source: TabDropSourceData,
178
+ target: PaneEdgeTarget,
179
+ ): Promise<void> {
180
+ if (source.type === "tab") {
181
+ // Ignores `target.paneId`: the split moves the tab beside ITS OWN pane, which is the edge zone's
182
+ // Pane by construction — the zone is only armed below `MAX_PANES`, so there is exactly one pane
183
+ // For a dragged tab to have come from.
184
+ const landed = splitRight(source.tabId);
185
+ if (landed) {
186
+ promoteTab(source.tabId);
187
+ }
188
+ return;
189
+ }
190
+ const pane = receivingPane(target.paneId);
191
+ await openFileInTab(source.path, { paneId: pane.id });
192
+ const landed = [...workspace.tabs.values()].some((tab) => tab.documentPath === source.path);
193
+ if (!landed && paneIsEmpty(pane)) {
194
+ // A refusal (a missing file, no editor for the format) must not leave a pane this drop minted
195
+ // Standing empty — the same rule `document.openToSide` applies to its own refusal.
196
+ closePane(pane.id);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Turn a completed drag into a write. `target` is the drop's own INNERMOST target data, or
202
+ * `undefined` for a drag cancelled off every target — both are legal and both mean nothing landed.
203
+ */
204
+ export async function resolveTabDrop(
205
+ source: { data: Record<string, unknown> },
206
+ target: { data: Record<string, unknown> } | undefined,
207
+ ): Promise<void> {
208
+ if (!target || !isTabDropSource(source.data)) {
209
+ return;
210
+ }
211
+ const sourceData = source.data;
212
+ if (target.data.type === "tab-slot") {
213
+ await resolveTabSlotDrop(sourceData, target.data as TabSlotTarget);
214
+ return;
215
+ }
216
+ if (target.data.type === "pane-edge") {
217
+ await resolvePaneEdgeDrop(sourceData, target.data as PaneEdgeTarget);
218
+ }
219
+ // `tab-slot-refused` / `pane-edge-refused` / anything else: nothing.
220
+ }
@@ -48,7 +48,6 @@ import {
48
48
  closeTab,
49
49
  focusPane,
50
50
  isPaneFocused,
51
- moveTab,
52
51
  promoteDirtyPreviewTabs,
53
52
  promoteTab,
54
53
  setTabPinned,
@@ -80,9 +79,17 @@ import type { MenuHandle, MenuRowProjection } from "../surfaces/menu";
80
79
  import { saveFile } from "../files/file-ops";
81
80
  import { collabReadOnly } from "../collab/collab-session";
82
81
  import { collabState } from "../collab/collab-state";
83
- import { rectOf } from "../utils/geometry";
82
+ import { elementAtPoint, rectOf } from "../utils/geometry";
84
83
  import { resolveRegion } from "../ui/regions";
85
84
  import { commitTabBuffers, tabBufferUnsaved } from "../services/monaco-buffer";
85
+ import {
86
+ draggable,
87
+ dropTargetForElements,
88
+ monitorForElements,
89
+ } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
90
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
91
+ import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
92
+ import { isTabDropSource, resolveTabDrop } from "./tab-drop";
86
93
  import type { EffectScope } from "@vue/reactivity";
87
94
 
88
95
  /**
@@ -119,8 +126,24 @@ const _overflowing = new Map<string, boolean>();
119
126
 
120
127
  let _overflowHandle: MenuHandle | null = null;
121
128
 
122
- /** The tab id currently being dragged, or null. */
123
- let _dragging: string | null = null;
129
+ /** What is being dragged right now, or null between drags — a tab off some strip, or a file row. */
130
+ let _drag: { kind: "tab"; tabId: string } | { kind: "file"; path: string } | null = null;
131
+
132
+ /**
133
+ * Where the pointer is hovering, in the vocabulary a strip draws: which pane, which chip slot (`-1`
134
+ * for the tail), and whether that slot IS the tail. Null off every target this module answers for —
135
+ * a `pane-edge` hover included, which is `panels/pane-grid.ts`'s own indicator to draw.
136
+ */
137
+ let _over: { paneId: string; index: number; tail: boolean } | null = null;
138
+
139
+ /** Every chip's pragmatic registration, per host, per tab id — released before a re-registration. */
140
+ const _chipDnd = new Map<HTMLElement, Map<string, () => void>>();
141
+
142
+ /** Each host's OWN registration — the strip's empty-tail target. */
143
+ const _stripDnd = new Map<HTMLElement, () => void>();
144
+
145
+ /** The one drag monitor, armed in {@link mount} and released in {@link unmount}. */
146
+ let _dropMonitor: (() => void) | null = null;
124
147
 
125
148
  /** Region id of a pane's tab strip. `ui/regions.ts` owns the grammar; this is one call site. */
126
149
  export function paneStripRegion(paneId: string): string {
@@ -153,6 +176,51 @@ function hostFor(pane: Pane): HTMLElement | null {
153
176
  export function mount(host: HTMLElement) {
154
177
  unmount();
155
178
  _primaryHost = host;
179
+ _dropMonitor = monitorForElements({
180
+ onDragStart({ source }) {
181
+ if (!isTabDropSource(source.data)) {
182
+ return;
183
+ }
184
+ _drag =
185
+ source.data.type === "tab"
186
+ ? { kind: "tab", tabId: source.data.tabId as string }
187
+ : { kind: "file", path: source.data.path as string };
188
+ // The tail target does not exist until a pane with no tabs is asked to grow one (§7).
189
+ render();
190
+ // `render()` alone does not write `dragKey` — only `syncDrag()` does — so without this the
191
+ // Source chip's `data-dragging` mark would not appear until the pointer's first move.
192
+ syncDrag();
193
+ },
194
+ onDrag({ location }) {
195
+ if (!_drag) {
196
+ return;
197
+ }
198
+ const [target] = location.current.dropTargets;
199
+ const data = target?.data;
200
+ _over =
201
+ data?.type === "tab-slot"
202
+ ? {
203
+ index: data.tabId === undefined ? -1 : (data.index as number),
204
+ paneId: data.paneId as string,
205
+ tail: data.tabId === undefined,
206
+ }
207
+ : null;
208
+ syncDrag();
209
+ },
210
+ onDrop({ location, source }) {
211
+ if (!isTabDropSource(source.data)) {
212
+ return;
213
+ }
214
+ const [target] = location.current.dropTargets;
215
+ _drag = null;
216
+ _over = null;
217
+ syncDrag();
218
+ render();
219
+ // A cancelled drag (dropped off every target) arrives with `dropTargets: []`; the resolver
220
+ // Reads `target === undefined` as "nothing landed", the same answer a refused target gives.
221
+ void resolveTabDrop(source, target);
222
+ },
223
+ });
156
224
  _scope = effectScope();
157
225
  _scope.run(() => {
158
226
  effect(() => {
@@ -195,18 +263,37 @@ export function unmount() {
195
263
  dismissTabContextMenu();
196
264
  _scope?.stop();
197
265
  _scope = null;
266
+ _dropMonitor?.();
267
+ _dropMonitor = null;
268
+ for (const host of _strips.keys()) {
269
+ releaseHostDnD(host);
270
+ }
198
271
  for (const strip of _strips.values()) {
199
272
  strip.dispose();
200
273
  }
201
274
  _strips.clear();
202
275
  _drawing.clear();
203
276
  _primaryHost = null;
204
- _dragging = null;
277
+ _drag = null;
278
+ _over = null;
205
279
  _lastActive.clear();
206
280
  _overflowing.clear();
207
281
  _hosts.clear();
208
282
  }
209
283
 
284
+ /** Take back every pragmatic registration a host holds — its chips and its own tail target. */
285
+ function releaseHostDnD(host: HTMLElement): void {
286
+ const chips = _chipDnd.get(host);
287
+ if (chips) {
288
+ for (const release of chips.values()) {
289
+ release();
290
+ }
291
+ _chipDnd.delete(host);
292
+ }
293
+ _stripDnd.get(host)?.();
294
+ _stripDnd.delete(host);
295
+ }
296
+
210
297
  function render() {
211
298
  /* One host draws ONE pane. While the shell has a single strip, both panes resolve to the same
212
299
  div — and the one whose tabs are on screen is the focused one, so it wins the tie. Without
@@ -237,6 +324,7 @@ function render() {
237
324
  _lastActive.delete(paneId);
238
325
  _overflowing.delete(paneId);
239
326
  if (!claims.has(host)) {
327
+ releaseHostDnD(host);
240
328
  _strips.get(host)?.dispose();
241
329
  _strips.delete(host);
242
330
  _drawing.delete(host);
@@ -261,6 +349,7 @@ function paint(pane: Pane, host: HTMLElement): void {
261
349
  } else {
262
350
  _strips.set(host, mountTabStripSurface(host, values, actionsFor(host)));
263
351
  }
352
+ reconcileStripDnD(host, values);
264
353
 
265
354
  /* The strip is HELD — `surfaces/tab-strip.ts` takes it as the element is created — so this is an
266
355
  imperative USE of a node this module owns rather than a re-find of one it renders
@@ -306,6 +395,9 @@ function actionsFor(host: HTMLElement): TabStripActions {
306
395
  activate: (id) => {
307
396
  activateTab(id);
308
397
  },
398
+ chipHost: (element, id) => {
399
+ adoptChip(host, element, id);
400
+ },
309
401
  closeTab: (id) => {
310
402
  void requestClose(id);
311
403
  },
@@ -315,11 +407,6 @@ function actionsFor(host: HTMLElement): TabStripActions {
315
407
  openTabContextMenu(tab, x, y);
316
408
  }
317
409
  },
318
- dragEnd: onDragEnd,
319
- dragStart: onDragStart,
320
- drop: (index, transfer) => {
321
- onDrop(host, index, transfer);
322
- },
323
410
  focusPane: () => {
324
411
  focusPane(drawnPane(host)?.id ?? PRIMARY_PANE);
325
412
  },
@@ -329,6 +416,9 @@ function actionsFor(host: HTMLElement): TabStripActions {
329
416
  promote: (id) => {
330
417
  promoteTab(id);
331
418
  },
419
+ stripHost: (element) => {
420
+ adoptStrip(host, element);
421
+ },
332
422
  togglePin: (id) => {
333
423
  const tab = workspace.tabs.get(id);
334
424
  if (tab) {
@@ -439,6 +529,26 @@ function project(pane: Pane): TabStripValues {
439
529
  return derivationValues(pane, derived);
440
530
  }
441
531
  if (pane.tabOrder.length === 0) {
532
+ /* An empty, non-derived pane WHILE A DRAG IS LIVE draws a `tabs` row with no chips instead of
533
+ going blank: `blankValues` hides the row outright, and a hidden row has no `jx-tabs` element
534
+ for `adoptStrip` to make a target of — so a tab dragged toward the empty primary beside a
535
+ derived secondary (the state `detachTab` allows) had nowhere to land. `_drag` gates it rather
536
+ than `pane.tabOrder.length === 0` alone, so the pane stays hidden the rest of the time. */
537
+ if (_drag) {
538
+ return {
539
+ active: "",
540
+ chipTitle: "",
541
+ focused: isPaneFocused(pane.id),
542
+ mode: "tabs",
543
+ preset: "",
544
+ stripLabel: stripLabel(pane),
545
+ subject: "",
546
+ tabs: [],
547
+ trailing: false,
548
+ trailingGlyph: "⌄",
549
+ trailingTitle: "Show hidden tabs",
550
+ };
551
+ }
442
552
  _lastActive.set(pane.id, null);
443
553
  _overflowing.set(pane.id, false);
444
554
  return blankValues(pane);
@@ -492,7 +602,6 @@ function chipView(
492
602
  {
493
603
  dirty: tab.doc.dirty,
494
604
  draft: pill?.draft ?? false,
495
- dragging: _dragging === id,
496
605
  index,
497
606
  key: id,
498
607
  label: labels.get(id) ?? "Untitled",
@@ -511,35 +620,131 @@ function chipView(
511
620
  ];
512
621
  }
513
622
 
514
- // ─── Drag reorder ─────────────────────────────────────────────────────────────
623
+ // ─── Drag and drop ────────────────────────────────────────────────────────────
515
624
 
516
- function onDragStart(id: string, transfer: DataTransfer | null) {
517
- _dragging = id;
518
- transfer?.setData("text/plain", id);
519
- if (transfer) {
520
- transfer.effectAllowed = "move";
625
+ /**
626
+ * Make one chip a pragmatic drag source and drop target. Called from `chipHost`, as the chip is
627
+ * CREATED (`onNodeCreated` fires once per chip; a repaint that keeps a chip alive fires it never
628
+ * again, and one that replaces it — a pane handover, §18.3 — fires it fresh).
629
+ *
630
+ * Released first, in case a chip element is ever handed a second registration for the same id: the
631
+ * SOURCE of truth for "is this id registered on this host" is the map, not whether pragmatic still
632
+ * remembers the old element, and releasing unconditionally is cheaper than asking.
633
+ */
634
+ function adoptChip(host: HTMLElement, element: HTMLElement, id: string): void {
635
+ let chips = _chipDnd.get(host);
636
+ if (!chips) {
637
+ chips = new Map();
638
+ _chipDnd.set(host, chips);
521
639
  }
522
- // The ghost is chrome state, not model state, so no effect fires for it.
523
- render();
640
+ chips.get(id)?.();
641
+
642
+ const cleanup = combine(
643
+ draggable({
644
+ element,
645
+ // A drag starting on the pin toggle or the kit's own close button is a click on THAT control,
646
+ // Not a request to move the chip — the same guard `panels/dnd.ts`'s Outline rows use.
647
+ canDrag({ input }) {
648
+ const target = elementAtPoint(input.clientX, input.clientY);
649
+ return !(target instanceof Element && target.closest('[part="pin"], [part="close"]'));
650
+ },
651
+ getInitialData() {
652
+ // Read at DRAG TIME, not captured at registration: the pane a host draws can change under
653
+ // An already-mounted chip (a handover, §18.3), and `drawnPane` always answers about now.
654
+ return { paneId: drawnPane(host)?.id, tabId: id, type: "tab" };
655
+ },
656
+ }),
657
+ dropTargetForElements({
658
+ canDrop({ source }) {
659
+ return isTabDropSource(source.data);
660
+ },
661
+ element,
662
+ getData({ element: el, input }) {
663
+ const pane = drawnPane(host);
664
+ if (!pane) {
665
+ return { type: "tab-slot-refused" };
666
+ }
667
+ // Computed HERE, not at chip creation: the target's own slot moves every time a tab is
668
+ // Reordered, and `resolveTabDrop` reads this fresh on every hover and on the drop itself.
669
+ return attachClosestEdge(
670
+ { index: pane.tabOrder.indexOf(id), paneId: pane.id, tabId: id, type: "tab-slot" },
671
+ { allowedEdges: ["left", "right"], element: el, input },
672
+ );
673
+ },
674
+ }),
675
+ );
676
+ chips.set(id, cleanup);
524
677
  }
525
678
 
526
- function onDragEnd() {
527
- _dragging = null;
528
- render();
679
+ /**
680
+ * Make a strip's OWN element a drop target — the empty tail after the last chip, and, for an empty
681
+ * pane growing one during a drag (§7), the whole of it. Innermost wins, so a chip under the pointer
682
+ * answers first; this only ever resolves a drop that landed on the strip itself.
683
+ */
684
+ function adoptStrip(host: HTMLElement, element: HTMLElement): void {
685
+ _stripDnd.get(host)?.();
686
+ const cleanup = dropTargetForElements({
687
+ canDrop({ source }) {
688
+ return isTabDropSource(source.data);
689
+ },
690
+ element,
691
+ getData() {
692
+ const pane = drawnPane(host);
693
+ return pane
694
+ ? { index: pane.tabOrder.length, paneId: pane.id, type: "tab-slot" }
695
+ : { type: "tab-slot-refused" };
696
+ },
697
+ });
698
+ _stripDnd.set(host, cleanup);
529
699
  }
530
700
 
531
701
  /**
532
- * Drop onto the chip at `index`. The model clamps the destination into the region the dragged tab's
533
- * pinned state allows, so this only has to say where the pointer was.
702
+ * Release a host's chip registrations for ids that no longer appear — a tab closed, or a pane
703
+ * handover replaced every chip with another pane's — and release the whole host when it is no
704
+ * longer drawing tabs at all. Run at the tail of every {@link paint}, which is what makes a handover
705
+ * self-correcting: the SAME call that draws the new pane's chips (through `onNodeCreated`) is the
706
+ * call that notices the old pane's ids are no longer live.
534
707
  */
535
- function onDrop(host: HTMLElement, index: number, transfer: DataTransfer | null) {
536
- const pane = drawnPane(host);
537
- const id = _dragging ?? transfer?.getData("text/plain") ?? null;
538
- _dragging = null;
539
- if (!pane || !id || !pane.tabOrder.includes(id)) {
708
+ function reconcileStripDnD(host: HTMLElement, values: TabStripValues): void {
709
+ if (values.mode !== "tabs") {
710
+ releaseHostDnD(host);
711
+ return;
712
+ }
713
+ const live = new Set(values.tabs.map((chip) => chip.key));
714
+ const chips = _chipDnd.get(host);
715
+ if (!chips) {
540
716
  return;
541
717
  }
542
- moveTab(id, index);
718
+ for (const [id, release] of chips) {
719
+ if (!live.has(id)) {
720
+ release();
721
+ chips.delete(id);
722
+ }
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Write the drag indicators for every standing strip, from {@link _over} and {@link _drag}.
728
+ *
729
+ * `dragKey` is broadcast to every host unconditionally — a tab id only matches a chip on the ONE
730
+ * host that draws its pane, so the comparison in `tab-strip.json` is what scopes it, not this
731
+ * function. `dropIndex` / `dropTail` ARE scoped here, to the host currently drawing `_over.paneId`,
732
+ * because an index is meaningless on a strip it was not measured against.
733
+ */
734
+ function syncDrag(): void {
735
+ const dragKey = _drag?.kind === "tab" ? _drag.tabId : "";
736
+ for (const [host, drawing] of _drawing) {
737
+ const surface = _strips.get(host);
738
+ if (!surface) {
739
+ continue;
740
+ }
741
+ const hovering = _over !== null && _over.paneId === drawing.paneId;
742
+ surface.drag({
743
+ dragKey,
744
+ dropIndex: hovering && _over ? _over.index : -1,
745
+ dropTail: hovering && _over ? _over.tail : false,
746
+ });
747
+ }
543
748
  }
544
749
 
545
750
  /**
@@ -75,6 +75,7 @@ let proxyConfigured = false;
75
75
  let proxyManaged = false;
76
76
  let proxyDefaultModel = "";
77
77
  let proxyCode: AiModelsResponse["code"];
78
+ let proxyModelsError = "";
78
79
 
79
80
  /**
80
81
  * One-shot capability probe shared by every credentials gate, plus the hosts to repaint when it
@@ -116,6 +117,7 @@ export function resetModelCache() {
116
117
  proxyManaged = false;
117
118
  proxyDefaultModel = "";
118
119
  proxyCode = undefined;
120
+ proxyModelsError = "";
119
121
  /* The probe's result IS the flags above. Clearing them while keeping the settled promise
120
122
  would strand every gate on a permanent "unconfigured, unmanaged" reading — ensureProxyProbe
121
123
  would no-op forever and the managed option would vanish until a full reload. */
@@ -266,6 +268,16 @@ export function proxyStateCode(): AiModelsResponse["code"] {
266
268
  return proxyCode;
267
269
  }
268
270
 
271
+ /**
272
+ * The upstream's own error message from the last fetch, when it reported one (e.g. Cloudflare's "No
273
+ * route for that URI" for a base URL that doesn't serve `/models`). Empty when the last fetch had
274
+ * no upstream error to report — a stale message from an earlier failure must not survive a fetch
275
+ * that succeeded.
276
+ */
277
+ export function proxyModelsErrorMessage(): string {
278
+ return proxyModelsError;
279
+ }
280
+
269
281
  /** The proxy's preferred model id ("" when it does not declare one). */
270
282
  export function getProxyDefaultModel(): string {
271
283
  return proxyDefaultModel;
@@ -356,6 +368,7 @@ export async function fetchAvailableModels(
356
368
  proxyManaged = data.managed === true;
357
369
  proxyDefaultModel = data.defaultModel ?? "";
358
370
  proxyCode = data.code;
371
+ proxyModelsError = data.upstreamError !== undefined ? (data.upstreamMessage ?? "") : "";
359
372
  /* Capabilities are kept, not dropped. The backend has reported `toolSupport` all along and the
360
373
  ingest mapped `{id, name}` only, so a Workers AI model that cannot call tools looked exactly
361
374
  like one that can — the agent loop ran, called nothing, and answered as if that were normal.
package/src/studio.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
 
28
28
  import {
29
29
  PRIMARY_PANE,
30
+ activePane,
30
31
  activeTab,
31
32
  closeAllTabs,
32
33
  openTab,
@@ -384,9 +385,11 @@ function setCanvasMode(tab: Tab | null, mode: string) {
384
385
  * 1. **To the side.** §8.2 has promised this since P3 and it never shipped — the chain ran
385
386
  * `openFileInTab` → `openTab` → `activePane()`, so "open the layout that wraps this page" opened
386
387
  * it ON TOP of the page it was teaching about.
387
- * 2. **Focus stays in the page.** An assistant pane that takes the keyboard means the author's next
388
- * keystroke edits the definition instead of the document they are looking at — and a following
389
- * pane would immediately have nothing to follow.
388
+ * 2. **The focus FOLLOWS.** The open is a gesture, not a read: the author asked to edit the component,
389
+ * so the pane it lands in takes the keyboard. The failure it replaces is the opposite one — an
390
+ * assistant pane that takes the keyboard means the author's next keystroke edits the definition
391
+ * instead of the document they are looking at, which is why the READS (`pane.compareWith`, the
392
+ * derivation follow, session restore) browse with `focus: false`.
390
393
  * 3. **A PREVIEW tab**, because drilling in is browsing: the second drill-in takes the same slot
391
394
  * instead of littering the side strip, and an edit promotes it (`promoteDirtyPreviewTabs`).
392
395
  * 4. **An ordinary tab, not a derivation.** "Edit definition" is a commitment to edit one component; a
@@ -396,16 +399,17 @@ function setCanvasMode(tab: Tab | null, mode: string) {
396
399
  * `openedFrom` is unchanged — §14.2's relationship, which nothing pops and nothing restores from.
397
400
  *
398
401
  * @param {string} componentPath
402
+ * @docs studio/interface/tabs
399
403
  */
400
404
  async function navigateToComponent(componentPath: string) {
401
405
  const from = activeTab.value;
402
- /* {@link receivingPane}, not `sidePane`: the pane beside this one may be a LENS, which owns no
406
+ /* {@link receivingPane}, not `paneBeside`: the pane beside this one may be a LENS, which owns no
403
407
  tab. The open then landed in a `tabOrder` `tabOfPane` hops straight past, so the read below got
404
408
  the SOURCE tab back, `opened.documentPath !== componentPath`, and the one relationship this
405
409
  function exists to record (§14.2) was skipped without a sound. */
406
- const target = receivingPane();
410
+ const target = receivingPane(activePane().id);
407
411
  const alreadyOpen = [...workspace.tabs.values()].some((t) => t.documentPath === componentPath);
408
- await openFileInTab(componentPath, { focus: false, paneId: target.id, preview: true });
412
+ await openFileInTab(componentPath, { paneId: target.id, preview: true });
409
413
  const opened = tabOfPane(target.id);
410
414
  if (!alreadyOpen && from && opened && opened.documentPath === componentPath) {
411
415
  opened.session.openedFrom = { documentPath: from.documentPath, tabId: from.id };