@jbrowse/app-core 5.0.0-beta.1 → 5.0.0-beta.10

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 (75) hide show
  1. package/esm/AppSession/AppSessionMixin.d.ts +42 -316
  2. package/esm/Assemblies/AssembliesMixin.d.ts +172 -103
  3. package/esm/Assemblies/AssembliesMixin.js +13 -8
  4. package/esm/HistoryManagement/index.d.ts +3 -11
  5. package/esm/HistoryManagement/index.js +24 -28
  6. package/esm/JBrowseConfig/RootConfiguration.d.ts +80 -124
  7. package/esm/JBrowseConfig/RootConfiguration.js +5 -11
  8. package/esm/JBrowseConfig/defaultShareUrl.d.ts +1 -0
  9. package/esm/JBrowseConfig/defaultShareUrl.js +5 -0
  10. package/esm/JBrowseConfig/index.d.ts +89 -83
  11. package/esm/JBrowseConfig/index.js +6 -4
  12. package/esm/JBrowseModel/index.d.ts +93 -96
  13. package/esm/JBrowseModel/index.js +43 -43
  14. package/esm/JbApi/index.d.ts +2 -0
  15. package/esm/JbApi/index.js +1 -0
  16. package/esm/JbApi/jbApi.d.ts +2767 -0
  17. package/esm/JbApi/jbApi.js +1383 -0
  18. package/esm/RootMenu/index.d.ts +2 -11
  19. package/esm/RootMenu/index.js +0 -11
  20. package/esm/SessionSpec/index.d.ts +1 -1
  21. package/esm/SessionSpec/index.js +1 -1
  22. package/esm/SessionSpec/lgvUrlInit.js +5 -2
  23. package/esm/SessionSpec/loadSessionSpec.d.ts +56 -2
  24. package/esm/SessionSpec/loadSessionSpec.js +285 -123
  25. package/esm/SessionSpec/parseSessionSpecUrl.d.ts +2 -8
  26. package/esm/SessionSpec/parseSessionSpecUrl.js +2 -17
  27. package/esm/SessionSpec/types.d.ts +16 -12
  28. package/esm/WorkspaceLayout/WorkspaceContainer.js +4 -2
  29. package/esm/WorkspaceLayout/WorkspaceTab.js +2 -6
  30. package/esm/WorkspaceLayout/model.d.ts +24 -4
  31. package/esm/WorkspaceLayout/model.js +76 -17
  32. package/esm/WorkspaceLayout/spec.d.ts +40 -10
  33. package/esm/WorkspaceLayout/spec.js +81 -8
  34. package/esm/index.d.ts +4 -2
  35. package/esm/index.js +4 -1
  36. package/esm/menus.d.ts +15 -29
  37. package/esm/menus.js +76 -92
  38. package/esm/queryParams.d.ts +1 -1
  39. package/esm/queryParams.js +10 -9
  40. package/esm/ui/App/App.js +18 -11
  41. package/esm/ui/App/AppToolbar.js +23 -8
  42. package/esm/ui/App/ClassicViewsContainer.d.ts +2 -2
  43. package/esm/ui/App/DialogQueue.d.ts +2 -2
  44. package/esm/ui/App/DialogQueue.js +3 -3
  45. package/esm/ui/App/DrawerHeader.js +2 -3
  46. package/esm/ui/App/ThemeModeButton.d.ts +12 -0
  47. package/esm/ui/App/ThemeModeButton.js +34 -0
  48. package/esm/ui/App/ViewButtons.js +2 -2
  49. package/esm/ui/App/ViewContainer.js +1 -17
  50. package/esm/ui/App/ViewContainerTitle.js +15 -12
  51. package/esm/ui/App/ViewLauncher.js +1 -1
  52. package/esm/ui/App/ViewMenu.js +2 -2
  53. package/esm/ui/App/ViewStack.js +7 -1
  54. package/esm/ui/App/ViewsContainer.d.ts +2 -4
  55. package/esm/ui/App/ViewsContainer.js +3 -9
  56. package/esm/ui/App/index.d.ts +1 -1
  57. package/esm/ui/App/index.js +1 -1
  58. package/esm/ui/App/lazyParts.d.ts +32 -0
  59. package/esm/ui/App/lazyParts.js +8 -0
  60. package/esm/ui/App/preloadAppShell.d.ts +14 -0
  61. package/esm/ui/App/preloadAppShell.js +41 -0
  62. package/esm/ui/App/types.d.ts +12 -7
  63. package/esm/ui/App/useViewVisibility.d.ts +9 -8
  64. package/esm/ui/App/useViewVisibility.js +11 -10
  65. package/esm/ui/App/viewTitle.d.ts +10 -3
  66. package/esm/ui/App/viewTitle.js +13 -5
  67. package/esm/ui/index.d.ts +1 -1
  68. package/esm/ui/index.js +1 -1
  69. package/package.json +7 -7
  70. package/esm/ui/App/AppReadyMarker.d.ts +0 -34
  71. package/esm/ui/App/AppReadyMarker.js +0 -56
  72. package/esm/ui/App/DrawerControls.d.ts +0 -1
  73. package/esm/ui/App/DrawerControls.js +0 -1
  74. package/esm/ui/App/copyView.d.ts +0 -1
  75. package/esm/ui/App/copyView.js +0 -29
@@ -0,0 +1,1383 @@
1
+ import { getReExportRegistry, setReExportRegistry, } from '@jbrowse/core/ReExports/registry';
2
+ import { getConf, getConfigurationSchemaDefinition, getConfigurationSchemaMetadata, isSlotDefinitionEntry, readConfObject, shorthandTargets, } from '@jbrowse/core/configuration';
3
+ import { releaseAdapterSession, retainAdapterSession, } from '@jbrowse/core/data_adapters/adapterSessionRefcount';
4
+ import { adapterByteLimit } from '@jbrowse/core/rpc/byteBudget';
5
+ import { isElectron, isSessionWithAddSessionTrack, objectHash, parseLocString, sum, } from '@jbrowse/core/util';
6
+ import { scaleBandHeights } from '@jbrowse/core/util/bandHeight';
7
+ import { openTracks, openViews, viewAndNested, } from '@jbrowse/core/util/openViews';
8
+ import { allSessionTracks, getConfAssemblyNamesOrNone, getConfRpcSessionId, guessTrackConfForLocation, isSameAssemblyName, viewCanDisplayTrack, viewDisplayNames, } from '@jbrowse/core/util/tracks';
9
+ import { unknownKeysMessage } from '@jbrowse/core/util/withLaunchInput';
10
+ import * as mst from '@jbrowse/mobx-state-tree';
11
+ import { applySnapshot, getSnapshot, getType, isStateTreeNode, } from '@jbrowse/mobx-state-tree';
12
+ import * as mobx from 'mobx';
13
+ // relative, not '@jbrowse/app-core': a package self-import would make this
14
+ // module depend on the barrel that exports it
15
+ import { launchSpecView, launchableSpecView, loadSessionSpec, viewTypeProblem, } from '../SessionSpec/index.js';
16
+ // Matches BaseTrackModel's DEFAULT_EXPORT_BYTE_LIMIT: both bound a fetch
17
+ // someone asked for by name rather than one a view scrolled into, so they
18
+ // should refuse at the same size.
19
+ const DEFAULT_AGENT_BYTE_LIMIT = 5_000_000;
20
+ /**
21
+ * What jb.getFeatures may pull before it refuses. An explicit request wins so
22
+ * an agent can mean it; otherwise the adapter's own declared limit, so this
23
+ * does not disagree with the size the track's display already refuses to
24
+ * render; otherwise the default.
25
+ */
26
+ export function agentByteLimit(declaredFetchSizeLimit, requested) {
27
+ return (requested ??
28
+ adapterByteLimit(declaredFetchSizeLimit, DEFAULT_AGENT_BYTE_LIMIT));
29
+ }
30
+ function viewSelf(view) {
31
+ return view;
32
+ }
33
+ // The census helpers over the views' own `ownViews`/`ownTracks`, so this module
34
+ // carries no copy of the view nesting: a synteny stack's rows and per-band
35
+ // containers, a breakpoint split view's panels, and any depth of nesting all
36
+ // arrive through each view declaring what it holds.
37
+ function viewTracks(view) {
38
+ return view.ownTracks;
39
+ }
40
+ function allTracks(session) {
41
+ return openTracks(session);
42
+ }
43
+ // The drawn display's on-screen state. `displayPhase` is the tree's own
44
+ // single-sourced vocabulary (render-core/displayPhase.ts) and the same one
45
+ // AppReadyMarker reads; `tooLarge` and `renderError` replace the display
46
+ // subtree rather than raising a snackbar, so a settle reporting only
47
+ // notifications calls them ready.
48
+ function displayState(track) {
49
+ const display = track.activeDisplay;
50
+ if (!display) {
51
+ return {};
52
+ }
53
+ try {
54
+ const phase = display.displayPhase;
55
+ return {
56
+ display: display.type,
57
+ // "make it all fit in the window" is a normal thing to be asked, and
58
+ // without this the only way to answer it is measuring DOM rectangles
59
+ ...(typeof display.height === 'number' ? { height: display.height } : {}),
60
+ ...(phase === undefined || phase === 'ready' ? {} : { phase }),
61
+ ...(phase === 'tooLarge' && display.regionTooLargeReason
62
+ ? { reason: display.regionTooLargeReason }
63
+ : {}),
64
+ ...(display.error ? { error: String(display.error) } : {}),
65
+ ...(display.renderError
66
+ ? { renderError: String(display.renderError) }
67
+ : {}),
68
+ ...(display.notices?.length ? { notices: [...display.notices] } : {}),
69
+ };
70
+ }
71
+ catch (e) {
72
+ return { display: display.type, error: String(e) };
73
+ }
74
+ }
75
+ /**
76
+ * The drawer and any modal, for the same reason `offscreen` is reported: both
77
+ * are on top of the views in a screenshot that otherwise looks right.
78
+ *
79
+ * The drawer takes a column off every view rather than covering them, so a
80
+ * figure framed with the track selector open is narrower than the one asked
81
+ * for — which every filmed take worked around by telling the agent in its
82
+ * system prompt to close it. A modal covers the app outright.
83
+ */
84
+ function sessionChrome(session) {
85
+ const s = session;
86
+ const drawer = s.drawerVisible && s.visibleWidget
87
+ ? {
88
+ drawer: {
89
+ widget: s.visibleWidget.type,
90
+ ...(typeof s.drawerWidth === 'number'
91
+ ? { width: s.drawerWidth }
92
+ : {}),
93
+ note: 'the drawer takes this width off every view; session.hideAllWidgets() closes it',
94
+ },
95
+ }
96
+ : {};
97
+ const dialog = s.DialogComponent
98
+ ? {
99
+ dialog: {
100
+ note: 'a modal dialog is open over the app, so a screenshot is of the dialog',
101
+ },
102
+ }
103
+ : {};
104
+ return { ...drawer, ...dialog };
105
+ }
106
+ export function sessionOf(pluginManager) {
107
+ return pluginManager?.rootModel?.session;
108
+ }
109
+ // A view whose init failed (its assembly was never found) paints the error in
110
+ // place of a genome and raises no toast, and stays uninitialized: without
111
+ // this, a spec with "volvix" for "volvox" settled false with nothing said.
112
+ /**
113
+ * Why a view is not finished, or nothing if it is.
114
+ *
115
+ * The three terms are AppReadyMarker's, and they have to be: the marker decides
116
+ * whether the settle ends, this decides what it says when it doesn't, and a
117
+ * term in one and not the other is a settle that answers `false` with an empty
118
+ * `notReady`, or one that names a view the marker was content with. `launching`
119
+ * is the one that was missing — `initialized` goes true the moment a linear
120
+ * view's regions land, while the same apply pass still has the spec's tracks to
121
+ * attach, so a spec load timing out there reported no reason at all.
122
+ *
123
+ * `showImportForm` excuses `initializing` on the same terms the marker gives
124
+ * it: a comparative view reads uninitialized for as long as its form is up, and
125
+ * the form is waiting on a person.
126
+ */
127
+ function viewState(v) {
128
+ if (v.error !== undefined) {
129
+ return { error: String(v.error) };
130
+ }
131
+ if (v.initialized === false && v.showImportForm !== true) {
132
+ return { phase: 'initializing' };
133
+ }
134
+ return v.pendingLaunch === undefined
135
+ ? {}
136
+ : {
137
+ phase: 'launching',
138
+ reason: 'the view is still applying what it was launched with (navigation, tracks)',
139
+ };
140
+ }
141
+ function viewSummary(view) {
142
+ const v = viewSelf(view);
143
+ return {
144
+ id: v.id,
145
+ type: v.type,
146
+ ...viewState(v),
147
+ ...(v.displayName ? { displayName: v.displayName } : {}),
148
+ ...(v.assemblyNames?.length ? { assemblyNames: v.assemblyNames } : {}),
149
+ ...(v.coarseVisibleLocStrings
150
+ ? { visibleRegion: v.coarseVisibleLocStrings }
151
+ : {}),
152
+ // the stack's own height, beside each track's below it: "does this all fit
153
+ // on screen" is then arithmetic rather than three rounds of screenshotting
154
+ ...(typeof v.height === 'number' ? { height: v.height } : {}),
155
+ ...(viewTracks(view).length
156
+ ? {
157
+ tracks: viewTracks(view).map(t => ({
158
+ trackId: t.configuration.trackId,
159
+ trackType: t.type,
160
+ ...displayState(t),
161
+ })),
162
+ }
163
+ : {}),
164
+ ...(view.ownViews.length
165
+ ? { views: view.ownViews.map(sub => viewSummary(sub)) }
166
+ : {}),
167
+ };
168
+ }
169
+ function sessionSummary(session) {
170
+ return {
171
+ name: session.name,
172
+ assemblyNames: session.assemblyNames,
173
+ ...sessionChrome(session),
174
+ views: session.views.map(v => viewSummary(v)),
175
+ };
176
+ }
177
+ // MST nodes serialize as their snapshot (toJSON), so the live-model walk below
178
+ // can stringify whatever it lands on; the replacer guards what a snapshot
179
+ // cannot contain but a getter's return can.
180
+ export function safeJson(value) {
181
+ // the ANCESTOR chain, not every object seen: MST snapshots are cached and
182
+ // structurally shared, so the same frozen object legitimately appears at two
183
+ // paths, and a visited-set reports the second one as a cycle that is not
184
+ // there. `this` is the holder JSON.stringify is currently walking, which is
185
+ // what lets the chain unwind on the way back up — so this must stay a
186
+ // `function`, not an arrow.
187
+ const chain = [];
188
+ // typed by hand: JSON.stringify's declared return is string, but a bare
189
+ // Symbol/undefined at the top level really does yield undefined at runtime
190
+ const json = JSON.stringify(isStateTreeNode(value) ? getSnapshot(value) : value, function (_key, v) {
191
+ while (chain.length > 0 && chain.at(-1) !== this) {
192
+ chain.pop();
193
+ }
194
+ if (typeof v === 'function') {
195
+ return '[function]';
196
+ }
197
+ if (typeof v === 'bigint') {
198
+ return `${v}`;
199
+ }
200
+ if (v !== null && typeof v === 'object') {
201
+ if (chain.includes(v)) {
202
+ return '[circular]';
203
+ }
204
+ chain.push(v);
205
+ }
206
+ return v;
207
+ });
208
+ return json === undefined ? '"[unserializable]"' : json;
209
+ }
210
+ function describeBrief(value) {
211
+ if (Array.isArray(value)) {
212
+ return `array(${value.length})`;
213
+ }
214
+ if (value !== null && typeof value === 'object') {
215
+ const json = safeJson(value);
216
+ return json.length > 120 ? `object(~${json.length} bytes)` : json;
217
+ }
218
+ const json = safeJson(value);
219
+ return json.length > 120 ? `${json.slice(0, 117)}...` : json;
220
+ }
221
+ // The names a live node answers beyond its snapshot: MST defines views as own
222
+ // getter properties on the instance, which is exactly the high-value surface
223
+ // (visibleLocStrings, assemblyNames, totalBp, ...) a snapshot filters out.
224
+ function memberNames(node) {
225
+ const getters = [];
226
+ const methods = [];
227
+ for (const [key, desc] of Object.entries(Object.getOwnPropertyDescriptors(node))) {
228
+ if (!key.startsWith('$') && key !== 'toJSON') {
229
+ if (desc.get) {
230
+ getters.push(key);
231
+ }
232
+ else if (typeof desc.value === 'function') {
233
+ methods.push(key);
234
+ }
235
+ }
236
+ }
237
+ return { getters: getters.sort(), methods: methods.sort() };
238
+ }
239
+ // The node itself. An agent writing `run_javascript` holds it already —
240
+ // jb.view(), session.views[0], track.activeDisplay — so there is nothing to
241
+ // learn here. A dot-path string used to be the only way to ask, which had
242
+ // agents reconstructing a path to a node in hand, in a grammar ("views.0")
243
+ // that is not JavaScript.
244
+ function inspectTarget(session, target) {
245
+ if (typeof target === 'string') {
246
+ throw new Error(`jb.inspect takes the node itself, not the name "${target}" — jb.inspect(jb.view()), jb.inspect(jb.trackModel('someTrackId')), jb.inspect(session.views[0].tracks[0]). Bare jb.inspect() is the session.`);
247
+ }
248
+ return target ?? session;
249
+ }
250
+ function inspectSession(session, args) {
251
+ const maxBytes = typeof args.maxBytes === 'number' ? args.maxBytes : 20_000;
252
+ const node = inspectTarget(session, args.target);
253
+ const members = typeof node === 'object' && !Array.isArray(node)
254
+ ? memberNames(node)
255
+ : { getters: [], methods: [] };
256
+ const json = safeJson(node);
257
+ const base = {
258
+ // the name the docs tool files a type under: docs topic "model:<modelType>"
259
+ ...(isStateTreeNode(node) ? { modelType: getType(node).name } : {}),
260
+ bytes: json.length,
261
+ ...(members.getters.length ? { getters: members.getters } : {}),
262
+ ...(members.methods.length ? { actions: members.methods } : {}),
263
+ };
264
+ if (json.length <= maxBytes) {
265
+ return { ...base, value: JSON.parse(json) };
266
+ }
267
+ const plain = JSON.parse(json);
268
+ return Array.isArray(plain)
269
+ ? {
270
+ ...base,
271
+ note: `too large to return whole — ${plain.length} items; inspect one of them or raise maxBytes`,
272
+ items: plain.slice(0, 20).map(item => describeBrief(item)),
273
+ }
274
+ : {
275
+ ...base,
276
+ note: 'too large to return whole — inspect a child of it or raise maxBytes',
277
+ keys: Object.fromEntries(Object.entries(plain).map(([k, v]) => [
278
+ k,
279
+ describeBrief(v),
280
+ ])),
281
+ };
282
+ }
283
+ const delay = (ms) => new Promise(resolve => {
284
+ setTimeout(resolve, ms);
285
+ });
286
+ // The readiness contract is AppReadyMarker's, not this file's: it publishes
287
+ // data-app-phase from the session (ready = no view resolving an assembly, no
288
+ // display fetching), and says so in its own comment.
289
+ //
290
+ // The hold is why the number is a second: `ready` has to survive past the
291
+ // ~600ms FetchVisibleRegions debounce, or one sample taken right after a
292
+ // navigation reads the pre-navigation frame as finished. Same reasoning and
293
+ // same constant as `waitForAppSettled` in products/jbrowse-capture/src/waits.ts
294
+ // (APP_SETTLED_HOLD_MS) — reached independently there, and measured.
295
+ const READY_HOLD_MS = 1000;
296
+ // An error toast stays up until a human dismisses it, and an info toast leaves
297
+ // on its own — so "the last five messages" repeated one stale error in every
298
+ // settle result for the rest of a run and named no level for the rest. Each
299
+ // toast is delivered to the caller once, by identity: the mobx array keeps the
300
+ // object it was pushed as, and removeSnackbarMessage relies on that too.
301
+ const deliveredNotifications = new WeakMap();
302
+ export function undeliveredNotifications(session) {
303
+ const entries = 'snackbarMessages' in session
304
+ ? session.snackbarMessages
305
+ : [];
306
+ const known = deliveredNotifications.get(session);
307
+ const delivered = known ?? new WeakSet();
308
+ if (!known) {
309
+ deliveredNotifications.set(session, delivered);
310
+ }
311
+ const fresh = entries.filter(m => !delivered.has(m));
312
+ for (const m of fresh) {
313
+ delivered.add(m);
314
+ }
315
+ return fresh.map(m => ({
316
+ level: m.level ?? 'info',
317
+ message: m.message,
318
+ }));
319
+ }
320
+ export async function waitReady(timeoutMs, session,
321
+ // scoped rather than document-wide because @jbrowse/react-app2 embeds this
322
+ // app in a host page: two mounted apps publish two ready markers, and a
323
+ // document-wide query answers for whichever is first in document order
324
+ root = document) {
325
+ const deadline = Date.now() + timeoutMs;
326
+ let readySince;
327
+ let outcome;
328
+ while (outcome === undefined) {
329
+ const ready = root.querySelector('[data-app-phase="ready"]') !== null &&
330
+ root.querySelector('[data-testid="loading-overlay"]') === null &&
331
+ root.querySelector('[data-display-animating="true"]') === null;
332
+ readySince = ready ? (readySince ?? Date.now()) : undefined;
333
+ if (readySince !== undefined && Date.now() - readySince >= READY_HOLD_MS) {
334
+ outcome = { settled: true };
335
+ }
336
+ else if (Date.now() >= deadline) {
337
+ outcome = {
338
+ settled: false,
339
+ note: 'rendering had not settled by the timeout; tracks may still be loading',
340
+ };
341
+ }
342
+ else {
343
+ await delay(200);
344
+ }
345
+ }
346
+ const messages = session ? undeliveredNotifications(session) : [];
347
+ // A display that refuses to draw — over the fetch-size gate, or errored —
348
+ // replaces its own subtree instead of raising a snackbar, so notifications
349
+ // alone report a clean settle over a browser with a blank track in it.
350
+ const notReady = session
351
+ ? [
352
+ ...openViews(session)
353
+ .map(v => ({
354
+ viewId: v.id,
355
+ type: v.type,
356
+ ...viewState(viewSelf(v)),
357
+ }))
358
+ .filter(v => 'phase' in v || 'error' in v),
359
+ ...allTracks(session)
360
+ .map(t => ({ trackId: t.configuration.trackId, ...displayState(t) }))
361
+ .filter(t => 'phase' in t || 'error' in t || 'notices' in t),
362
+ ]
363
+ : [];
364
+ const offscreen = session ? offscreenViews(session, root) : undefined;
365
+ return {
366
+ ...outcome,
367
+ ...(messages.length ? { notifications: messages } : {}),
368
+ ...(notReady.length ? { notReady } : {}),
369
+ ...(offscreen ? { offscreen } : {}),
370
+ ...(session ? sessionChrome(session) : {}),
371
+ };
372
+ }
373
+ // A session taller than the window scrolls, and a screenshot of the viewport
374
+ // then shows a plausible browser with the bottom view cut off or the top one
375
+ // scrolled away: every filmed take spent turns shrinking tracks after a
376
+ // picture, and one found its genome view at y = -267. Reported from the DOM
377
+ // the views are laid out in, so the agent knows before it looks.
378
+ function offscreenViews(session, root) {
379
+ const win = root.ownerDocument?.defaultView ?? window;
380
+ const windowHeight = win.innerHeight;
381
+ const measured = overflow(root);
382
+ const containers = new Map([
383
+ ...root.querySelectorAll('[data-testid^="view-container-"]'),
384
+ ].map(el => [el.dataset.testid.slice('view-container-'.length), el]));
385
+ const views = openViews(session).flatMap(view => {
386
+ const rect = containers.get(view.id)?.getBoundingClientRect();
387
+ return rect && (rect.top < 0 || rect.bottom > windowHeight)
388
+ ? [
389
+ {
390
+ viewId: view.id,
391
+ top: Math.round(rect.top),
392
+ bottom: Math.round(rect.bottom),
393
+ },
394
+ ]
395
+ : [];
396
+ });
397
+ return measured.pageHeight > measured.windowHeight || views.length
398
+ ? {
399
+ pageHeight: measured.pageHeight,
400
+ windowHeight: measured.windowHeight,
401
+ scrollY: Math.round(win.scrollY),
402
+ views,
403
+ note: 'the session is taller than the window; a viewport screenshot cuts these views off — await jb.fitToWindow() to shrink what is open until it fits, or screenshot with fullPage: true. A view with a negative top is scrolled out above the viewport (scrollY says by how much), not missing',
404
+ }
405
+ : undefined;
406
+ }
407
+ // Neither app scrolls the document: a column inside the app scrolls, so the
408
+ // document's scrollHeight equals the window's height with a view running 700
409
+ // px past it. The overflow is the scrolling ancestor's, when one exists.
410
+ function scrollerOf(container) {
411
+ const win = container.ownerDocument.defaultView ?? window;
412
+ for (let el = container.parentElement; el; el = el.parentElement) {
413
+ const { overflowY } = win.getComputedStyle(el);
414
+ if ((overflowY === 'auto' || overflowY === 'scroll') &&
415
+ el.scrollHeight > el.clientHeight) {
416
+ return el;
417
+ }
418
+ }
419
+ return undefined;
420
+ }
421
+ // The view ids each scrolling box holds, `undefined` keying the document. A
422
+ // workspace scrolls every panel on its own, so one panel fitting says nothing
423
+ // about the one beside it, and a view in a tab nobody is showing has no
424
+ // container and nothing to fit.
425
+ function scrollPorts(root) {
426
+ const ports = new Map();
427
+ for (const el of root.querySelectorAll('[data-testid^="view-container-"]')) {
428
+ const scroller = scrollerOf(el);
429
+ const ids = ports.get(scroller) ?? new Set();
430
+ ids.add(el.dataset.testid.slice('view-container-'.length));
431
+ ports.set(scroller, ids);
432
+ }
433
+ return ports;
434
+ }
435
+ // The trailing overscroll ViewStack renders below the last view, which exists so
436
+ // a newly-added view can scroll to the middle of the port. It is room, not
437
+ // content, and it is up to 300px of a scrollHeight, so a shrink that charges it
438
+ // to the views leaves them that much short of the window — the E. coli take's
439
+ // dotplot was cut to 312px of the 600 that fit.
440
+ function overscrollHeight(scroller) {
441
+ const spacer = scroller.querySelector('[data-testid="view-stack-overscroll"]');
442
+ return spacer ? Math.round(spacer.getBoundingClientRect().height) : 0;
443
+ }
444
+ function overflowOf(root, scroller) {
445
+ if (scroller) {
446
+ return {
447
+ pageHeight: scroller.scrollHeight - overscrollHeight(scroller),
448
+ windowHeight: scroller.clientHeight,
449
+ };
450
+ }
451
+ const win = root.ownerDocument?.defaultView ?? window;
452
+ return {
453
+ pageHeight: win.document.documentElement.scrollHeight,
454
+ windowHeight: win.innerHeight,
455
+ };
456
+ }
457
+ function excessOf(measured) {
458
+ return measured.pageHeight - measured.windowHeight;
459
+ }
460
+ // The port that overflows most, which is what a single answer reports
461
+ function overflow(root) {
462
+ return [...scrollPorts(root).keys()]
463
+ .map(scroller => overflowOf(root, scroller))
464
+ .reduce((worst, m) => (excessOf(m) > excessOf(worst) ? m : worst), overflowOf(root, undefined));
465
+ }
466
+ // What a shrink can leave a display, a synteny band or a dotplot at: enough to
467
+ // still read a track's name and see that it drew.
468
+ const MIN_HEIGHT_PX = 40;
469
+ // Everything on screen whose height is its own state: each shown track's
470
+ // display, each synteny band, and a view whose height is a declared property
471
+ // rather than the sum of its tracks (a dotplot). A linear view's `height` is a
472
+ // getter over its tracks, so shrinking it is shrinking them.
473
+ function shrinkables(views) {
474
+ return views.flatMap(viewAndNested).flatMap(view => {
475
+ const v = view;
476
+ const declared = isStateTreeNode(view) &&
477
+ getType(view).properties
478
+ ?.height !== undefined;
479
+ const own = declared && typeof v.height === 'number' && v.setHeight
480
+ ? [{ what: `view ${v.id}`, height: v.height, setHeight: v.setHeight }]
481
+ : [];
482
+ const bands = (v.levels ?? []).flatMap((level, i) => typeof level.height === 'number' && level.setHeight
483
+ ? [
484
+ {
485
+ what: `view ${v.id} band ${i}`,
486
+ height: level.height,
487
+ setHeight: level.setHeight,
488
+ },
489
+ ]
490
+ : []);
491
+ const displays = viewTracks(view).flatMap(track => {
492
+ // resizeHeight where the display has it: in grow mode it is the call
493
+ // that leaves grow, where setHeight writes a slot nothing reads
494
+ const d = track.activeDisplay;
495
+ const { setHeight, resizeHeight } = d ?? {};
496
+ return d && typeof d.height === 'number' && (resizeHeight || setHeight)
497
+ ? [
498
+ {
499
+ what: track.configuration.trackId,
500
+ height: d.height,
501
+ setHeight: (px) => {
502
+ if (resizeHeight) {
503
+ resizeHeight(px - d.height);
504
+ }
505
+ else {
506
+ setHeight?.(px);
507
+ }
508
+ },
509
+ },
510
+ ]
511
+ : [];
512
+ });
513
+ return [...own, ...bands, ...displays];
514
+ });
515
+ }
516
+ /**
517
+ * Shrink what is open until the session fits the window, and say what moved.
518
+ *
519
+ * Every filmed take spent two to five screenshot rounds on this by hand: the
520
+ * settle reported `offscreen`, the agent guessed which heights to cut and by
521
+ * how much, and looked again. The overflow is arithmetic the settle already
522
+ * does; this spends it across every display, band and dotplot in proportion to
523
+ * the headroom each has above the floor, so tall tracks give up the most and
524
+ * nothing goes below readable.
525
+ */
526
+ async function fitToWindow(session, settleMs, root = document) {
527
+ const before = overflow(root);
528
+ const excess = excessOf(before);
529
+ if (excess <= 0) {
530
+ return { fits: true, ...before };
531
+ }
532
+ // each port spends only its own overflow, on its own views; a host that
533
+ // renders no view containers is one port, the document
534
+ const ports = scrollPorts(root);
535
+ const groups = ports.size
536
+ ? [...ports].map(([scroller, ids]) => ({
537
+ scroller,
538
+ views: session.views.filter(v => ids.has(v.id)),
539
+ }))
540
+ : [{ scroller: undefined, views: session.views }];
541
+ const cuts = groups.map(({ scroller, views }) => {
542
+ const portExcess = excessOf(overflowOf(root, scroller));
543
+ if (portExcess <= 0) {
544
+ return { scroller, atFloor: false, shrunk: [] };
545
+ }
546
+ const items = shrinkables(views);
547
+ const heights = items.map(i => i.height);
548
+ const next = scaleBandHeights(heights, -portExcess, MIN_HEIGHT_PX);
549
+ const available = sum(heights) - sum(next);
550
+ return {
551
+ scroller,
552
+ atFloor: available < portExcess,
553
+ shrunk: items.flatMap((item, i) => {
554
+ const to = next[i];
555
+ if (to >= item.height) {
556
+ return [];
557
+ }
558
+ item.setHeight(to);
559
+ return [{ what: item.what, from: item.height, to }];
560
+ }),
561
+ };
562
+ });
563
+ const settle = await waitReady(settleMs, session, root);
564
+ // each panel measured again on its own box, since one that now fits is no
565
+ // longer a scroller for `overflow` to find
566
+ const remaining = cuts.map(c => ({
567
+ atFloor: c.atFloor,
568
+ left: excessOf(overflowOf(root, c.scroller)),
569
+ }));
570
+ const left = Math.max(excessOf(overflowOf(root, undefined)), ...remaining.map(r => r.left));
571
+ return {
572
+ fits: left <= 0,
573
+ overflowBefore: excess,
574
+ overflowAfter: Math.max(0, left),
575
+ shrunk: cuts.flatMap(c => c.shrunk),
576
+ ...(left > 0
577
+ ? {
578
+ note: remaining.some(r => r.left > 0 && r.atFloor)
579
+ ? `everything shrinkable is at its ${MIN_HEIGHT_PX} px floor — hide a track or a view, or screenshot with fullPage: true`
580
+ : 'still taller than the window after the settle — call jb.fitToWindow() again',
581
+ }
582
+ : {}),
583
+ ...settle,
584
+ };
585
+ }
586
+ // The catalog is the largest result of nearly every agent run — the 129 volvox
587
+ // rows were 20 KB — so a row carries what picks a track out of the list and
588
+ // nothing more. The adapter type is `jb.inspect`'s or `jb.describeSlots`' to
589
+ // answer for the one track an agent went on to use.
590
+ //
591
+ // Dropping assemblyNames wherever the session has a single assembly saved
592
+ // nothing and made the row shape vary by session: a config declares far more
593
+ // assemblies than it opens, and volvox — every eval run's config — declares 10.
594
+ function trackEntry(conf) {
595
+ return {
596
+ trackId: conf.trackId,
597
+ name: readConfObject(conf, 'name'),
598
+ type: conf.type,
599
+ // getConfAssemblyNamesOrNone, not the slot: an assembly's sequence track
600
+ // has no assemblyNames slot and answers through its parent assembly
601
+ assemblyNames: getConfAssemblyNamesOrNone(conf),
602
+ };
603
+ }
604
+ // Each assembly's own sequence track, which no track list holds: the config
605
+ // keeps it under the assembly as `sequence`, so `jb.getFeatures` on its
606
+ // trackId reads bases while `listTracks` never named it.
607
+ function sequenceTracks(session) {
608
+ return session.assemblyManager.assemblyList.map(asm => asm.sequence);
609
+ }
610
+ function listTracks(session, searchArg, limitArg) {
611
+ const search = searchArg?.toLowerCase() ?? '';
612
+ const limit = limitArg ?? 100;
613
+ // allSessionTracks, not session.tracks: connection-supplied tracks (hubs,
614
+ // registries) are absent from the session lists but fully showable — a
615
+ // hand-rolled union here hid them from agents entirely
616
+ const matches = [...allSessionTracks(session), ...sequenceTracks(session)]
617
+ .map(c => trackEntry(c))
618
+ .filter(t => !search ||
619
+ t.trackId.toLowerCase().includes(search) ||
620
+ t.name.toLowerCase().includes(search));
621
+ return {
622
+ total: matches.length,
623
+ ...(matches.length > limit
624
+ ? { note: `showing ${limit} of ${matches.length}; refine with search` }
625
+ : {}),
626
+ tracks: matches.slice(0, limit),
627
+ };
628
+ }
629
+ function pickView(session, args, capability, wants) {
630
+ const viewId = typeof args.viewId === 'string' ? args.viewId : undefined;
631
+ const candidates = viewScope(session, viewId);
632
+ const able = candidates.filter(v => typeof viewSelf(v)[capability] === 'function');
633
+ if (!able.length) {
634
+ throw new Error(`No ${inScope(viewId)} supports this (${candidates.map(v => v.type).join(', ') || 'none open'}) — jb.loadSessionSpec can open one.`);
635
+ }
636
+ // A view on another assembly would show the track and render nothing, with a
637
+ // successful-looking result — the same silent mismatch visibleRegionsOf
638
+ // guards against for reads
639
+ const onAssembly = wants?.assembly
640
+ ? able.filter(v => viewSelf(v).assemblyNames?.some(name => isSameAssemblyName(name, wants.assembly, session.assemblyManager)))
641
+ : able;
642
+ if (!onAssembly.length) {
643
+ throw new Error(`No open view is on assembly "${wants.assembly}" (open views: ${able.map(v => `${v.id} on ${viewSelf(v).assemblyNames?.join(', ')}`).join('; ')}) — open one, or pass show: false`);
644
+ }
645
+ const canDisplay = wants?.track && wants.pluginManager
646
+ ? onAssembly.filter(v => viewCanDisplayTrack(wants.pluginManager, viewDisplayNames(wants.pluginManager, v.type), wants.track))
647
+ : onAssembly;
648
+ if (!canDisplay.length) {
649
+ throw new Error(`No open view can display a ${wants.track.type} (open views: ${onAssembly.map(v => v.type).join(', ')})`);
650
+ }
651
+ return onlyView(session, canDisplay, 'could take this', viewId);
652
+ }
653
+ // A synteny view's rows count as open views (they are what shows a region and
654
+ // a track), and an agent looking at one synteny view read "3 views are open"
655
+ // with nothing saying two of them were its rows.
656
+ function parentView(session, view) {
657
+ return openViews(session).find(v => v.ownViews.includes(view));
658
+ }
659
+ function describeView(session, view) {
660
+ const v = viewSelf(view);
661
+ const on = v.assemblyNames?.length ? ` on ${v.assemblyNames.join(', ')}` : '';
662
+ const at = v.coarseVisibleLocStrings ? ` at ${v.coarseVisibleLocStrings}` : '';
663
+ const parent = parentView(session, view);
664
+ const row = parent ? `, a row of ${parent.id}` : '';
665
+ return `${v.id} (${v.type}${on}${at}${row})`;
666
+ }
667
+ function onlyView(session, candidates, relation, viewId) {
668
+ const named = candidates.find(v => v.id === viewId);
669
+ if (named) {
670
+ return named;
671
+ }
672
+ const [first, ...rest] = candidates;
673
+ if (!first) {
674
+ throw new Error('No open view');
675
+ }
676
+ if (rest.length) {
677
+ throw new Error(`${candidates.length} views ${relation}: ${candidates.map(v => describeView(session, v)).join('; ')} — pass viewId to say which`);
678
+ }
679
+ return first;
680
+ }
681
+ function viewById(session, viewId) {
682
+ const candidates = openViews(session);
683
+ if (viewId !== undefined) {
684
+ const named = candidates.find(v => v.id === viewId);
685
+ if (!named) {
686
+ throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => describeView(session, v)).join('; ') || 'none'}`);
687
+ }
688
+ return named;
689
+ }
690
+ if (!candidates.length) {
691
+ throw new Error('No view is open — jb.loadSessionSpec can open one');
692
+ }
693
+ return onlyView(session, candidates, 'are open');
694
+ }
695
+ function inScope(viewId) {
696
+ return viewId === undefined ? 'open view' : `view in ${viewId}`;
697
+ }
698
+ function viewScope(session, viewId) {
699
+ return viewId === undefined
700
+ ? openViews(session)
701
+ : viewAndNested(viewById(session, viewId));
702
+ }
703
+ function shownTrackModel(session, trackId, viewId) {
704
+ const trackOf = (v) => viewTracks(v).find(t => t.configuration.trackId === trackId);
705
+ const showing = viewScope(session, viewId).filter(v => trackOf(v));
706
+ // undefined here was followed by ".activeDisplay" on the next line of every
707
+ // agent's code, and a TypeError says nothing about which of the two causes
708
+ // it was
709
+ if (!showing.length) {
710
+ throw new Error(session.getTrackById(trackId)
711
+ ? `"${trackId}" is not shown in any ${inScope(viewId)} — await view.launchTrack("${trackId}") first; jb.sessionSummary() lists what each view shows`
712
+ : `No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
713
+ }
714
+ return trackOf(onlyView(session, showing, `show "${trackId}"`, viewId));
715
+ }
716
+ function isRegionLoc(loc) {
717
+ return (!!loc &&
718
+ typeof loc === 'object' &&
719
+ typeof loc.refName === 'string');
720
+ }
721
+ async function trackAssembly(session, conf, requested) {
722
+ // getConfAssemblyNamesOrNone, not the assemblyNames slot: an assembly's own
723
+ // sequence track has no such slot and answers through its parent assembly
724
+ const trackAssemblies = getConfAssemblyNamesOrNone(conf);
725
+ const assemblyName = requested ?? trackAssemblies[0];
726
+ if (assemblyName === undefined) {
727
+ throw new Error('The track names no assembly; pass assembly explicitly');
728
+ }
729
+ // a named assembly the track is not on renames the region against the wrong
730
+ // alias set and answers with the wrong assembly's coordinates, or nothing
731
+ if (requested !== undefined &&
732
+ trackAssemblies.length &&
733
+ !trackAssemblies.some(name => isSameAssemblyName(name, requested, session.assemblyManager))) {
734
+ throw new Error(`Track "${conf.trackId}" is on ${trackAssemblies.join(', ')}, not "${requested}"`);
735
+ }
736
+ const assembly = await session.assemblyManager.waitForAssembly(assemblyName);
737
+ if (!assembly) {
738
+ throw new Error(`Assembly "${assemblyName}" could not be loaded`);
739
+ }
740
+ return { assembly, assemblyName };
741
+ }
742
+ async function locToRegion(session, conf, loc, assemblyArg) {
743
+ if (typeof loc !== 'string' && !isRegionLoc(loc)) {
744
+ throw new Error(`jb.getFeatures takes loc as ONE place — a locstring ("ctgA:1-100") or a region object ({ refName, start, end }) — and got ${JSON.stringify(loc)}${Array.isArray(loc) ? '; several regions go in regions: [...]' : ''}`);
745
+ }
746
+ const { assembly, assemblyName } = await trackAssembly(session, conf, assemblyArg ?? (typeof loc === 'string' ? undefined : loc.assemblyName));
747
+ const parsed = typeof loc === 'string'
748
+ ? parseLocString(loc, refName => assembly.isValidRefName(refName))
749
+ : loc;
750
+ const refName = assembly.getCanonicalRefName(parsed.refName) ?? parsed.refName;
751
+ // parseLocString asks this for the string form. A region object skipping it
752
+ // read empty on a name the assembly does not have, which is the answer this
753
+ // surface exists to turn into an error.
754
+ if (!assembly.isValidRefName(refName)) {
755
+ throw new Error(`"${parsed.refName}" is not a sequence in ${assemblyName} — jb.visibleRegions() and jb.sessionSummary() name what is there`);
756
+ }
757
+ const bounds = assembly.regions?.find(r => r.refName === refName);
758
+ return {
759
+ assemblyName,
760
+ refName,
761
+ start: parsed.start ?? bounds?.start ?? 0,
762
+ end: parsed.end ?? bounds?.end ?? Number.MAX_SAFE_INTEGER,
763
+ };
764
+ }
765
+ async function visibleRegionsOf(session, viewId, preferTrackId, trackAssemblies = []) {
766
+ // `in`, not evaluation: visibleRegions is a getter that THROWS ("width
767
+ // undefined") until the view's component mounts and sets a width — a
768
+ // freshly spec-loaded view stays in that state briefly even after the
769
+ // app-phase marker reads ready, since a view with no width has no display
770
+ // fetching anything.
771
+ const regionBearing = viewScope(session, viewId).filter(v => 'visibleRegions' in v);
772
+ if (!regionBearing.length) {
773
+ throw new Error(`No ${inScope(viewId)} shows a region — pass loc, or open a linear view`);
774
+ }
775
+ // a view on another assembly than the track would hand its region to a file
776
+ // that has no such sequence, which answers nothing, silently
777
+ const candidates = trackAssemblies.length
778
+ ? regionBearing.filter(v => !viewSelf(v).assemblyNames?.length ||
779
+ viewSelf(v).assemblyNames.some(name => trackAssemblies.some(t => isSameAssemblyName(name, t, session.assemblyManager))))
780
+ : regionBearing;
781
+ if (!candidates.length) {
782
+ throw new Error(`No open view is on ${trackAssemblies.join(', ')} (open views: ${regionBearing.map(v => describeView(session, v)).join('; ')}) — pass loc, or open a view on that assembly`);
783
+ }
784
+ // among region-bearing views, the ones actually showing the track — two
785
+ // views on two assemblies would otherwise send the first view's namespace to
786
+ // the second view's file, which answers nothing, silently
787
+ const showing = candidates.filter(v => viewTracks(v).some(t => t.configuration.trackId === preferTrackId));
788
+ const chosen = onlyView(session, showing.length ? showing : candidates, showing.length ? `show "${preferTrackId}"` : 'show a region', viewId);
789
+ const view = viewSelf(chosen);
790
+ const deadline = Date.now() + 10_000;
791
+ let visible;
792
+ while (visible === undefined) {
793
+ if (view.error !== undefined) {
794
+ throw new Error(`View ${view.id} failed to initialize: ${String(view.error)} — pass loc with assembly, or open a view that loads`);
795
+ }
796
+ if (view.initialized !== false) {
797
+ try {
798
+ visible = view.visibleRegions;
799
+ }
800
+ catch {
801
+ // not mounted yet
802
+ }
803
+ }
804
+ if (visible?.length) {
805
+ break;
806
+ }
807
+ visible = undefined;
808
+ if (Date.now() >= deadline) {
809
+ throw new Error('The view has not finished initializing a visible region — pass loc, or navigate first');
810
+ }
811
+ await delay(250);
812
+ }
813
+ // whole bases: a block edge is a fractional bp, and 30000.000000000004
814
+ // reads as a bug in every answer that echoes it
815
+ return visible.map(({ refName, start, end, assemblyName }) => ({
816
+ refName,
817
+ start: Math.floor(start),
818
+ end: Math.ceil(end),
819
+ assemblyName,
820
+ }));
821
+ }
822
+ // The same RPCs the track's display issues, under the track's own session id
823
+ // (getConfRpcSessionId), so a shown track's parsed index and chunk cache are
824
+ // reused. Retained and released like a track does, so an un-shown track's
825
+ // worker cache is dropped once this read is done. Regions are renamed to the
826
+ // file's own refNames during serialization (RpcMethodTypeWithRenameRegions),
827
+ // and the features come back rebuilt as SimpleFeature. What crosses the worker
828
+ // boundary is bounded by the byte gate that runs first.
829
+ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
830
+ const conf = session.getTrackById(trackId);
831
+ if (!conf) {
832
+ throw new Error(`No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
833
+ }
834
+ const adapterConfig = readConfObject(conf, 'adapter');
835
+ const sessionId = getConfRpcSessionId(conf);
836
+ // The adapter's own declared limit where it has one, so this does not quietly
837
+ // disagree with the size the track's display already refuses to render —
838
+ // the reasoning BaseTrackModel.exportByteLimit spells out for "Save track
839
+ // data", which is the other path that fetches because someone asked rather
840
+ // than because a view scrolled.
841
+ const byteLimit = agentByteLimit(readConfObject(conf, ['adapter', 'fetchSizeLimit']), requestedByteLimit);
842
+ const { rpcManager } = session;
843
+ const controller = new AbortController();
844
+ const { signal } = controller;
845
+ // desktop's MCP relay gives up at 150s, so the read must not outlive it —
846
+ // and an agent-triggered read of a dense region wants a ceiling either way
847
+ const stopTimer = setTimeout(() => {
848
+ controller.abort();
849
+ }, 120_000);
850
+ retainAdapterSession(rpcManager, sessionId);
851
+ try {
852
+ // The agent's "region too large". A display refuses to fetch over its own
853
+ // gate and paints the reason; this path has no display, so without the
854
+ // same question asked here an agent that names a whole chromosome pulls it
855
+ // — over someone else's data host, into the page it is about to
856
+ // screenshot. Same index-only estimate the gated RPC takes first, and a
857
+ // refusal rather than a truncation: a short answer that looked like the
858
+ // whole answer is the failure this surface exists to prevent.
859
+ const bytes = await rpcManager.call(sessionId, 'CoreGetRegionByteEstimate',
860
+ // eslint-disable-next-line no-restricted-syntax -- reports nothing: an agent awaits the returned features and no display or dialog exists to show a phase label or bar
861
+ { adapterConfig, regions, scope: 'largestRegion', signal });
862
+ if (bytes !== undefined && bytes > byteLimit) {
863
+ throw new Error(`region too large for jb.getFeatures: the largest region is ~${bytes} bytes against a limit of ${byteLimit}. Narrow the region, or pass an explicit byteLimit if you mean to pull this much.`);
864
+ }
865
+ // eslint-disable-next-line no-restricted-syntax -- reports nothing: see the estimate above
866
+ return await rpcManager.call(sessionId, 'CoreGetFeatures', {
867
+ adapterConfig,
868
+ regions,
869
+ signal,
870
+ });
871
+ }
872
+ catch (e) {
873
+ throw signal.aborted
874
+ ? new Error(`jb.getFeatures gave up on track "${trackId}" after 120s. Narrow the region, or check that its data host is answering.`, { cause: e })
875
+ : e;
876
+ }
877
+ finally {
878
+ clearTimeout(stopTimer);
879
+ void releaseAdapterSession(rpcManager, sessionId);
880
+ }
881
+ }
882
+ // A LocalPathLocation only reads under Electron: in a browser openLocation
883
+ // throws "can't use local files in the browser" at the FIRST READ, not here, so
884
+ // accepting one would report a track added and then fail inside the display
885
+ // where the agent is least likely to connect it to what it asked for.
886
+ const URL_TEXT = /^https?:\/\//;
887
+ const ABSOLUTE_PATH = /^(?:\/|[a-zA-Z]:[\\/]|\\\\)/;
888
+ function isFileLocationText(spec) {
889
+ return URL_TEXT.test(spec) || ABSOLUTE_PATH.test(spec);
890
+ }
891
+ function fileLocation(spec) {
892
+ if (URL_TEXT.test(spec)) {
893
+ return { uri: spec, locationType: 'UriLocation' };
894
+ }
895
+ // The app's working directory is not the agent's, and a relative path
896
+ // resolves against the app's: under a packaged app that is "/", so the read
897
+ // fails at the first fetch and reports through the display, not here.
898
+ if (!ABSOLUTE_PATH.test(spec)) {
899
+ throw new Error(`jb.addTrack needs an absolute local path or a URL: "${spec}" is relative and would resolve against the app's working directory, not yours.`);
900
+ }
901
+ if (!isElectron) {
902
+ throw new Error(`jb.addTrack needs a URL in a browser, and "${spec}" is a local path. JBrowse Web cannot read local files except through the Add track file picker, which needs a real file chosen by hand.`);
903
+ }
904
+ return { localPath: spec, locationType: 'LocalPathLocation' };
905
+ }
906
+ // A stacked set of bigWigs is one track over a MultiWiggleAdapter, which the
907
+ // per-file guesser cannot express — the web take hand-wrote the config. The
908
+ // subadapters form rather than the `bigWigs` shorthand: that one takes
909
+ // absolute URLs only, and desktop's locations are paths.
910
+ // the file's name without its bigWig extension, or undefined for any other file
911
+ function bigWigStem(location) {
912
+ const fileName = location.split(/[/\\]/).pop() ?? location;
913
+ const match = /^(.+)\.(bw|bigwig)$/i.exec(fileName);
914
+ return match?.[1];
915
+ }
916
+ function multiWiggleTrackConf(locations, assemblyName) {
917
+ const stems = locations.map(l => bigWigStem(l));
918
+ const notBigWig = locations.filter((_, i) => stems[i] === undefined);
919
+ if (notBigWig.length) {
920
+ throw new Error(`jb.addTrack takes a list of locations only for bigWigs (one stacked MultiQuantitativeTrack); not bigWig: ${notBigWig.join(', ')}. Add other formats one at a time.`);
921
+ }
922
+ const adapter = {
923
+ type: 'MultiWiggleAdapter',
924
+ subadapters: locations.map((l, i) => ({
925
+ type: 'BigWigAdapter',
926
+ name: stems[i],
927
+ bigWigLocation: fileLocation(l),
928
+ })),
929
+ };
930
+ return {
931
+ trackId: `multiwiggle-${objectHash(adapter).slice(0, 8)}`,
932
+ type: 'MultiQuantitativeTrack',
933
+ name: stems.join(', '),
934
+ assemblyNames: [assemblyName],
935
+ adapter,
936
+ };
937
+ }
938
+ // one location, a list of them, or nothing usable
939
+ function locationsOf(value) {
940
+ return Array.isArray(value)
941
+ ? value.filter((l) => typeof l === 'string')
942
+ : typeof value === 'string'
943
+ ? value
944
+ : '';
945
+ }
946
+ function describeSchema(type) {
947
+ const meta = getConfigurationSchemaMetadata(type);
948
+ if (meta) {
949
+ const shorthand = shorthandTargets(meta);
950
+ return {
951
+ type: meta.name,
952
+ slots: describeSlots(type),
953
+ ...(Object.keys(shorthand).length > 0 ? { shorthand } : {}),
954
+ };
955
+ }
956
+ const bare = mst.unwrapType(type);
957
+ if (mst.isArrayType(bare)) {
958
+ const items = describeSchema(bare.getChildType());
959
+ return items ? { type: 'array', items } : undefined;
960
+ }
961
+ if (mst.isUnionType(bare)) {
962
+ const members = [bare.getSubTypes()]
963
+ .flat()
964
+ .flatMap(sub => sub && typeof sub === 'object' ? [describeSchema(sub)] : [])
965
+ .filter(d => d !== undefined);
966
+ return members.length > 0
967
+ ? {
968
+ type: 'union',
969
+ oneOf: Object.fromEntries(members.map(m => [m.type, m])),
970
+ }
971
+ : undefined;
972
+ }
973
+ return undefined;
974
+ }
975
+ // Vocabulary introspection: every config slot a config node's schema defines,
976
+ // read off the schema rather than the live node, so code never has to guess
977
+ // which settings keys exist — an unknown key is not an error, only an entry in
978
+ // applyDisplaySettings' `unapplied` list. A nested object (`facet`, `color`)
979
+ // lists its own slots and the slot its string shorthand lifts into, and a list
980
+ // of them (`marks`, `transform`) its entry's.
981
+ function describeSlots(confOrType) {
982
+ const definition = getConfigurationSchemaDefinition(confOrType) ?? {};
983
+ return Object.fromEntries(Object.entries(definition).flatMap(([name, def]) => {
984
+ if (isSlotDefinitionEntry(def)) {
985
+ return [
986
+ [
987
+ name,
988
+ {
989
+ type: def.type,
990
+ ...(def.description ? { description: def.description } : {}),
991
+ defaultValue: def.defaultValue,
992
+ },
993
+ ],
994
+ ];
995
+ }
996
+ const described = mst.isType(def) ? describeSchema(def) : undefined;
997
+ return described ? [[name, described]] : [];
998
+ }));
999
+ }
1000
+ // The raw primitive under all of the above: Claude-authored code against the
1001
+ // live model graph. The renderer already runs with nodeIntegration and the
1002
+ // bridge socket is user-only, so this grants what the surface as a whole
1003
+ // already grants — expressed directly instead of through a curated verb.
1004
+ // The re-export registry is what pluginManager.jbrequire serves, the modules
1005
+ // external plugins link against. It stays empty until a runtime plugin loads,
1006
+ // and a loaded plugin fills it with the product's map, which serves every
1007
+ // bundled @jbrowse package rather than core alone — so this fills only an
1008
+ // empty registry.
1009
+ export async function ensureReExports() {
1010
+ if (Object.keys(getReExportRegistry()).length === 0) {
1011
+ setReExportRegistry((await import('@jbrowse/core/ReExports/modules')).default);
1012
+ }
1013
+ }
1014
+ // The front door for an agent that just found `jb` and knows nothing else —
1015
+ // jbrowse-web's console banner and meta tag point here, because a browser
1016
+ // agent has no MCP docs tool and no initialize instructions to learn the
1017
+ // contract from.
1018
+ const JB_HELP = `jb drives this JBrowse app programmatically (window.jb in a browser; the same object is the "jb" argument of JBrowse Desktop's run_javascript MCP tool).
1019
+
1020
+ Orient first: jb.sessionSummary(). Introspect, never guess: jb.listTracks(search?, limit?) answers { total, tracks } with the trackIds; jb.describeSlots(jb.trackModel('someTrackId').activeDisplay.configuration) for the settings keys a display accepts — an unknown settings key is not an error, it lands in applyDisplaySettings' "unapplied" list as { key, reason }, so read the report; jb.inspect(node) — jb.view(), jb.trackModel('someTrackId'), session.views[0] — for its getters, actions and modelType.
1021
+
1022
+ The model is mobx-state-tree: mutate only through actions (raw assignment throws), and write display settings with track.applyDisplaySettings(settings). A feature or variant track groups and colors through two settings, track.applyDisplaySettings({ facet: 'strand' or { field, domain? }, color: a CSS color or { field: 'type', domain?, range? } }), where null resets any setting; it filters with activeDisplay.setFilter(['jexl:feature.score > 10']). An alignments track takes the same facet, its field a read dimension ('pairOrientation', 'mapq', ...) or a tag ('tags.HP'). LinearMarkDisplay plots a track's features as ggplot2 does, on a feature, alignments, variant or quantitative track, in five settings: marks, a list drawn in order of { mark: 'bar' | 'point' | 'rule' | 'line' | 'span' | 'text' | 'link', encoding: { y: 'score', x?, x2?, row?, color?, shape?, text?, size? }, each a feature field or a jexl: expression, a constant written { value: 'red' }, transform?: steps such as { type: 'coverage' } or a bin then an aggregate, which fill the y they write, minBpPerPx?, maxBpPerPx? }, beside transform, facet, rows and scales ({ y: { type, domainMin, domainMax, autoscaleGroup, title } }). await view.launchTrack(trackId, {}, { type: 'LinearMarkDisplay', marks: [...] }) draws a track with it, shown already or not (on a GWASTrack, type 'LinearManhattanDisplay'). Its markPlot is those five as JSON: edit structuredClone(activeDisplay.markPlot), check it with activeDisplay.plotProblems(plot), and apply it with applyDisplaySettings(plot), on this track or another track's mark display; activeDisplay.notices names a mark the config cannot draw, or one whose field no loaded feature holds. Build views declaratively with jb.loadSessionSpec({ views: [{ type: 'LinearGenomeView', assembly, loc, tracks: [...] }] }), which replaces the session; jb.addView(oneViewSpec) opens one more view beside what is open; jb.setSession(document) rewrites the session as a document — what jb.mst.getSnapshot(jb.session) answers, edited: a view keeps its id and is patched in place, loc on it navigates, a { trackId } entry in its tracks opens that track. Act on a view with await view.navToLocString('BRCA1' or 'chr1:1-1000'), which also SHOWS the track whose search index answered a gene name unless a 4th arg { showHitTrack: false } says not to; await view.launchTrack(trackId, {}, settings) shows a track with settings, view.hideTrack(trackId) hides it. Arrange open views into panels with session.layoutViews({ direction: 'horizontal', children: [{ views: [viewId] }, ...] }), a leaf naming view ids or session.views indexes; await jb.fitToWindow() shrinks what is open until the session fits the window; add data with jb.addTrack({ location }) (an absolute path or a URL), or show a track the catalog already holds with jb.addTrack({ trackId, settings? }); read data with await jb.getFeatures({ trackId, loc?, assembly?, viewId?, regions?, byteLimit? }), which renames refNames ("chr1" vs "1") so the file answers and reads on the worker the track's display uses. Anything lower level is jb.require('@jbrowse/core/util') and friends, the module registry plugins link against. After changing anything, await jb.waitReady(ms) and read its notifications and notReady lists before trusting the screen.
1023
+
1024
+ Views nest and several can be open. jb.view(viewId?) is the open view, and jb.view(), jb.trackModel(trackId), jb.visibleRegions(), jb.addTrack and jb.getFeatures over a visible region throw naming the candidates rather than picking one when more than one view could answer — pass viewId (from jb.sessionSummary()) to say which. A synteny or breakpoint view's viewId also covers its rows: the named view answers when it can, and otherwise its rows do.
1025
+
1026
+ Full guide: https://jbrowse.org/jb2/docs/agents_live_model (offline in JBrowse Desktop's MCP docs tool).`;
1027
+ // The helper library an agent drives the app through. Built from the plugin
1028
+ // manager alone, so one of these serves a whole app rather than one session —
1029
+ // which is what lets jbrowse-web hand the same object to every caller for the
1030
+ // life of a plugin manager.
1031
+ export function createJbApi(pluginManager, options = {}) {
1032
+ // Resolved per call, never captured: jb.loadSessionSpec REPLACES the session,
1033
+ // and a helper bound to the old one keeps answering from a detached tree —
1034
+ // which reads as stale data rather than throwing, so the agent is told about
1035
+ // a session that no longer exists. `jb.session` is how code re-reads it after
1036
+ // a spec load.
1037
+ const live = () => {
1038
+ const current = sessionOf(pluginManager);
1039
+ if (!current) {
1040
+ throw new Error('No session is open, so there is nothing for this helper to read. Build one with jb.loadSessionSpec({ views: [...] }), which needs no session of its own, or load a config with the open tool.');
1041
+ }
1042
+ return current;
1043
+ };
1044
+ // Every settle an agent can reach goes through here, so a notification
1045
+ // cannot be consumed by one of these helpers without the caller hearing
1046
+ // about it.
1047
+ // a declaration, not a generic arrow: `<T>(x: T) => …` in a .ts file is a
1048
+ // JSX tag to babel, which is what jest parses these with
1049
+ function reported(settle) {
1050
+ const messages = settle.notifications;
1051
+ if (Array.isArray(messages) && messages.length > 0) {
1052
+ options.onNotifications?.(messages);
1053
+ }
1054
+ return settle;
1055
+ }
1056
+ const jb = {
1057
+ help: JB_HELP,
1058
+ get session() {
1059
+ return live();
1060
+ },
1061
+ get rootModel() {
1062
+ return pluginManager.rootModel;
1063
+ },
1064
+ require: pluginManager.jbrequire,
1065
+ ensureRequire: ensureReExports,
1066
+ mst,
1067
+ mobx,
1068
+ readConfObject,
1069
+ getConf,
1070
+ describeSlots,
1071
+ // a default because an omitted number made the deadline NaN, and a view
1072
+ // that never readied then held the call open to the relay's own timeout
1073
+ waitReady: async (timeoutMs = 30_000) => reported(await waitReady(timeoutMs, live())),
1074
+ sessionSummary: () => sessionSummary(live()),
1075
+ inspect: (target, maxInspectBytes) => inspectSession(live(), { target, maxBytes: maxInspectBytes }),
1076
+ listTracks: (search, limit) => listTracks(live(), search, limit),
1077
+ view: (viewId) => viewById(live(), viewId),
1078
+ trackModel: (trackId, viewId) => shownTrackModel(live(), trackId, viewId),
1079
+ visibleRegions: (viewId) => visibleRegionsOf(live(), viewId),
1080
+ loadSessionSpec: async (spec, settleMs) => reported(await loadSpec(pluginManager, { spec, settleMs })),
1081
+ addView: async (spec, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await addView(pluginManager, live(), spec, settleMs)),
1082
+ setSession: async (document, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await setSession(pluginManager, live(), document, settleMs)),
1083
+ fitToWindow: async (settleMs = 30_000) => reported(await fitToWindow(live(), settleMs)),
1084
+ addTrack: (opts) => addTrack(pluginManager, live(), opts).then(reported),
1085
+ // Two of four filmed takes wrote jb.getFeatures('trackId', loc) and lost a
1086
+ // turn to "No track with trackId undefined", and a third wrote
1087
+ // jb.getFeatures('trackId', loc, { assembly }) and had the options
1088
+ // silently ignored — so the positional form is accepted alongside the
1089
+ // object it was documented as, options third.
1090
+ getFeatures: async (args, positionalLoc, positionalOpts) => {
1091
+ const fetchArgs = typeof args === 'string'
1092
+ ? { trackId: args, loc: positionalLoc, ...positionalOpts }
1093
+ : args;
1094
+ const session = live();
1095
+ const conf = session.getTrackById(fetchArgs.trackId);
1096
+ if (!conf) {
1097
+ throw new Error(`No track with trackId "${fetchArgs.trackId}" — jb.listTracks() shows what is available`);
1098
+ }
1099
+ // `regions` entries go through locToRegion as `loc` does. Passed
1100
+ // straight to the fetch they skipped the canonical rename, and an entry
1101
+ // carrying no assemblyName reached renameRegionsIfNeeded with nothing to
1102
+ // rename against — an alias spelling then read empty and said nothing.
1103
+ const regions = fetchArgs.regions
1104
+ ? await Promise.all(fetchArgs.regions.map(region => locToRegion(session, conf, region, fetchArgs.assembly)))
1105
+ : fetchArgs.loc !== undefined
1106
+ ? [
1107
+ await locToRegion(session, conf, fetchArgs.loc, fetchArgs.assembly),
1108
+ ]
1109
+ : await visibleRegionsOf(session, fetchArgs.viewId, fetchArgs.trackId, getConfAssemblyNamesOrNone(conf));
1110
+ return fetchFeatures(session, fetchArgs.trackId, regions, fetchArgs.byteLimit);
1111
+ },
1112
+ };
1113
+ return jb;
1114
+ }
1115
+ // a spec load on a busy machine passed 30s and answered settled:false over
1116
+ // views that were fine; the wait exits the moment they are ready
1117
+ const ADD_TRACK_SETTLE_MS = 60_000;
1118
+ function namesAFile(value) {
1119
+ return Array.isArray(value)
1120
+ ? value.some(l => typeof l === 'string' && isFileLocationText(l))
1121
+ : typeof value === 'string' && isFileLocationText(value);
1122
+ }
1123
+ // The name an agent meant as a catalog trackId: `trackId`, or a `location`
1124
+ // that is neither a URL nor a path. Agent runs reached for jb.addTrack to SHOW
1125
+ // a track already in the catalog in both spellings, and each paid a round trip
1126
+ // for a refusal that only named another helper.
1127
+ //
1128
+ // A location that names a file is the file route whatever else came with it:
1129
+ // `trackId` beside `location` is how a track CONFIG spells this, and taking it
1130
+ // as the catalog route would refuse the file the caller asked to add.
1131
+ function catalogName(args) {
1132
+ if (namesAFile(args.location)) {
1133
+ return undefined;
1134
+ }
1135
+ if (typeof args.trackId === 'string') {
1136
+ return args.trackId;
1137
+ }
1138
+ return typeof args.location === 'string' ? args.location : undefined;
1139
+ }
1140
+ async function addTrack(pluginManager, session, args) {
1141
+ const named = catalogName(args);
1142
+ const inCatalog = named === undefined ? undefined : session.getTrackById(named);
1143
+ // before the add-rights check: showing a track the catalog already holds
1144
+ // adds nothing to it
1145
+ if (inCatalog) {
1146
+ return showCatalogTrack(pluginManager, session, inCatalog, args);
1147
+ }
1148
+ if (named !== undefined && typeof args.trackId === 'string') {
1149
+ throw new Error(`No track with trackId "${args.trackId}" — jb.listTracks() shows what the catalog holds, and jb.addTrack({ location }) adds a file to it.`);
1150
+ }
1151
+ if (!isSessionWithAddSessionTrack(session)) {
1152
+ throw new Error('This session cannot add tracks');
1153
+ }
1154
+ const location = locationsOf(args.location);
1155
+ if (!location.length) {
1156
+ throw new Error('jb.addTrack needs a location (an absolute local path or a URL) to add a file, or the trackId of a track already in the catalog to show it — jb.listTracks() lists those.');
1157
+ }
1158
+ const requested = typeof args.assembly === 'string' ? args.assembly : session.assemblyNames[0];
1159
+ if (requested === undefined) {
1160
+ throw new Error('The session has no assemblies to attach the track to');
1161
+ }
1162
+ // resolved to the session's own spelling: an alias ("vvx" for "volvox",
1163
+ // "GRCh38" for "hg38") names the assembly and would otherwise be refused
1164
+ const assembly = session.assemblyNames.find(name => isSameAssemblyName(name, requested, session.assemblyManager));
1165
+ if (assembly === undefined) {
1166
+ throw new Error(`Assembly "${requested}" is not in this session (has: ${session.assemblyNames.join(', ')})`);
1167
+ }
1168
+ const conf = Array.isArray(location)
1169
+ ? multiWiggleTrackConf(location, assembly)
1170
+ : guessTrackConfForLocation(fileLocation(location), typeof args.index === 'string' ? fileLocation(args.index) : undefined, pluginManager, assembly);
1171
+ session.addSessionTrackConf({
1172
+ ...conf,
1173
+ ...(typeof args.name === 'string' ? { name: args.name } : {}),
1174
+ });
1175
+ const summary = {
1176
+ trackId: conf.trackId,
1177
+ trackType: conf.type,
1178
+ adapterType: conf.adapter.type,
1179
+ assembly,
1180
+ };
1181
+ return args.show === false
1182
+ ? summary
1183
+ : {
1184
+ ...summary,
1185
+ ...(await showTrack(pluginManager, session, {
1186
+ trackId: conf.trackId,
1187
+ trackType: conf.type,
1188
+ adapter: conf.adapter,
1189
+ assembly,
1190
+ }, args)),
1191
+ };
1192
+ }
1193
+ // A track already in the catalog, shown: the same view choice and the same
1194
+ // settle the file route ends in.
1195
+ // `conf` is whatever `session.getTrackById` answered, which is a track config
1196
+ // but not necessarily a `BaseTrackConfig` — an assembly's ReferenceSequenceTrack
1197
+ // declares a subset of those slots. The four reads below are all ones it has.
1198
+ async function showCatalogTrack(pluginManager, session, conf, args) {
1199
+ // index, assembly and name describe the file being added, and the catalog's
1200
+ // copy already has all three — taking them here would drop them in silence
1201
+ const fileArgs = ['index', 'assembly', 'name'].filter(key => args[key] !== undefined);
1202
+ if (fileArgs.length) {
1203
+ throw new Error(`"${conf.trackId}" is in the catalog already, so ${fileArgs.join(' and ')} would be ignored — drop ${fileArgs.length > 1 ? 'them' : 'it'}, or pass a location to add a new file.`);
1204
+ }
1205
+ const track = {
1206
+ trackId: conf.trackId,
1207
+ trackType: conf.type,
1208
+ assembly: getConfAssemblyNamesOrNone(conf)[0],
1209
+ };
1210
+ const adapterType = readConfObject(conf, ['adapter', 'type']);
1211
+ const summary = { ...track, ...(adapterType ? { adapterType } : {}) };
1212
+ // show:false over a catalog trackId asks for nothing — there is no file to
1213
+ // add — and showing it anyway would contradict the flag
1214
+ return args.show === false
1215
+ ? summary
1216
+ : {
1217
+ ...summary,
1218
+ ...(await showTrack(pluginManager, session, { ...track, adapter: { type: adapterType } }, args)),
1219
+ };
1220
+ }
1221
+ function isSettings(value) {
1222
+ return !!value && typeof value === 'object' && !Array.isArray(value);
1223
+ }
1224
+ async function showTrack(pluginManager, session, track, args) {
1225
+ const view = pickView(session, args, 'launchTrack', {
1226
+ assembly: track.assembly,
1227
+ track: { type: track.trackType, adapter: track.adapter },
1228
+ pluginManager,
1229
+ });
1230
+ // awaited: a display state model is lazy in every plugin here, and the
1231
+ // synchronous showTrack answers before the chunk lands — so the settle below
1232
+ // would run against a session that does not hold the track yet, and report
1233
+ // ready over it
1234
+ await viewSelf(view).launchTrack(track.trackId, {}, isSettings(args.settings) ? args.settings : undefined);
1235
+ // 0 skips the settle, so several adds can share one jb.waitReady
1236
+ const settleMs = typeof args.settleMs === 'number' ? args.settleMs : ADD_TRACK_SETTLE_MS;
1237
+ return {
1238
+ shownInView: view.id,
1239
+ ...(settleMs > 0 ? await waitReady(settleMs, session) : {}),
1240
+ };
1241
+ }
1242
+ /**
1243
+ * One view from one spec entry, into the open session, through the same
1244
+ * launcher extension point `jb.loadSessionSpec` takes for each of its views —
1245
+ * so the entry means the same thing in both places, a ProteinView's
1246
+ * `connectedView` included. `session.launchView` is the other route and does
1247
+ * not: it is `addView` with a snapshot, so it never runs a view's launcher.
1248
+ *
1249
+ * A key the view does not take throws before anything opens. A spec has no
1250
+ * return channel and launches anyway with an error toast; an awaited call has
1251
+ * one, and a view that opened without the setting the agent typed is the
1252
+ * quietly-wrong answer this surface exists to refuse.
1253
+ */
1254
+ async function addView(pluginManager, session, spec, settleMs) {
1255
+ if (typeof spec !== 'object' || typeof spec.type !== 'string') {
1256
+ throw new Error('jb.addView takes one view spec — an entry of a session spec\'s "views" array, e.g. { type: "LinearGenomeView", assembly, loc, tracks } (docs topic "session-spec")');
1257
+ }
1258
+ const unlaunchable = viewTypeProblem(pluginManager, spec.type);
1259
+ if (unlaunchable) {
1260
+ throw new Error(unlaunchable);
1261
+ }
1262
+ const { view: entry, problem } = await launchableSpecView(pluginManager, spec);
1263
+ if (problem) {
1264
+ throw new Error(`${problem} — nothing was opened. docs topic "session-spec" lists the keys ${entry.type} takes`);
1265
+ }
1266
+ const { created, view, failure } = await launchSpecView(session, pluginManager, entry);
1267
+ if (failure) {
1268
+ throw new Error(failure.message, { cause: failure.cause });
1269
+ }
1270
+ if (!view) {
1271
+ throw new Error(`${entry.type} opened no view`);
1272
+ }
1273
+ const summary = {
1274
+ viewId: view.id,
1275
+ ...(created.length > 1 ? { created: created.map(v => v.id) } : {}),
1276
+ };
1277
+ return settleMs > 0
1278
+ ? { ...summary, ...(await waitReady(settleMs, session)) }
1279
+ : summary;
1280
+ }
1281
+ /**
1282
+ * The session as one document, rewritten: what `getSnapshot(session)` answers,
1283
+ * edited, handed back. MST reconciles by identifier, so a view, track or
1284
+ * display whose `id` the document keeps is patched in place and stays mounted,
1285
+ * one the document drops is closed, and an entry with no id is new. A view's
1286
+ * launch keys are accepted beside its built state — `loc` navigates it, a
1287
+ * `{ trackId }` entry in its `tracks` opens that track — because the partition
1288
+ * that sorts them runs on every snapshot the view is given, not only its
1289
+ * first.
1290
+ *
1291
+ * Top-level keys the document leaves out keep their current value, so
1292
+ * `{ views: [...] }` is a complete instruction.
1293
+ *
1294
+ * Views leave through the session's own detach first (ADR-069): `applySnapshot`
1295
+ * destroys what the target lacks in place, under components still mounted
1296
+ * over it, which is what undo had to route around.
1297
+ */
1298
+ // A key nothing on the far side takes, per view entry. MST drops one silently,
1299
+ // and the partition that names it runs at attach — which a view the document
1300
+ // KEEPS never reaches, so the commonest edit was the quiet one.
1301
+ //
1302
+ // Reported, not refused, like the same mistake on a session spec: a document
1303
+ // that works today must not cost the whole call. `acceptedKeys` answers for a
1304
+ // legacy spelling too — a view's `passThrough` declares the ones its own
1305
+ // preProcessSnapshot converts, which is what makes this a question about the
1306
+ // view type rather than about whatever members the live node happens to have.
1307
+ function unknownViewKeys(pluginManager, entry) {
1308
+ if (typeof entry !== 'object' ||
1309
+ entry === null ||
1310
+ typeof entry.type !== 'string') {
1311
+ return [];
1312
+ }
1313
+ const { type, views, ...keyed } = entry;
1314
+ const nested = Array.isArray(views)
1315
+ ? views.flatMap(view => unknownViewKeys(pluginManager, view))
1316
+ : [];
1317
+ if (!pluginManager.getElementTypeRecord('view').has(type)) {
1318
+ return nested;
1319
+ }
1320
+ const viewType = pluginManager.getViewType(type);
1321
+ const accepted = viewType.acceptedKeys ??
1322
+ Object.keys(viewType.stateModel.properties);
1323
+ const unknown = Object.keys(keyed).filter(key => !accepted.includes(key));
1324
+ return [
1325
+ ...(unknown.length
1326
+ ? [
1327
+ `${unknownKeysMessage(type, unknown)}${viewType.launchKeys
1328
+ ? ''
1329
+ : ' — a document runs no launcher, so its launch keys go through jb.addView'}`,
1330
+ ]
1331
+ : []),
1332
+ ...nested,
1333
+ ];
1334
+ }
1335
+ async function setSession(pluginManager, session, document, settleMs) {
1336
+ if (typeof document !== 'object' ||
1337
+ document === null ||
1338
+ Array.isArray(document)) {
1339
+ throw new Error('jb.setSession takes the session as a document: what jb.mst.getSnapshot(jb.session) answers, edited. Top-level keys left out keep their current value.');
1340
+ }
1341
+ const next = {
1342
+ ...getSnapshot(session),
1343
+ ...document,
1344
+ };
1345
+ // a lazily registered view or display type is not in the session's type union
1346
+ // until its model loads, and applySnapshot is synchronous
1347
+ await pluginManager.preloadSessionTypes(next);
1348
+ for (const problem of (Array.isArray(next.views) ? next.views : []).flatMap(entry => unknownViewKeys(pluginManager, entry))) {
1349
+ session.notifyError(problem);
1350
+ }
1351
+ try {
1352
+ ;
1353
+ session.takeOutViewsMissingFrom?.(next);
1354
+ applySnapshot(session, next);
1355
+ }
1356
+ catch (e) {
1357
+ const detail = e instanceof Error ? e.message : String(e);
1358
+ throw new Error(`jb.setSession refused the document: ${detail.length > 1500 ? `${detail.slice(0, 1500)}…` : detail}`, { cause: e });
1359
+ }
1360
+ const settle = await waitReady(settleMs, session);
1361
+ return { ...settle, session: sessionSummary(session) };
1362
+ }
1363
+ // Under the 45 s one evaluation gets from the Claude in Chrome extension: a
1364
+ // cold hosted config settling for the full minute answered nothing there,
1365
+ // while the settle result already names what is still not ready
1366
+ const SPEC_SETTLE_DEFAULT_MS = 30_000;
1367
+ async function loadSpec(pluginManager, args) {
1368
+ const spec = args.spec;
1369
+ const valid = typeof spec === 'object' &&
1370
+ spec !== null &&
1371
+ Array.isArray(spec.views);
1372
+ if (!valid) {
1373
+ throw new Error('loadSessionSpec needs a spec object with a views array');
1374
+ }
1375
+ await loadSessionSpec(spec, pluginManager);
1376
+ // the session was REPLACED by the spec load — settle against the new one
1377
+ const session = sessionOf(pluginManager);
1378
+ const settle = await waitReady(args.settleMs ?? SPEC_SETTLE_DEFAULT_MS, session);
1379
+ return {
1380
+ ...settle,
1381
+ ...(session ? { session: sessionSummary(session) } : {}),
1382
+ };
1383
+ }