@jbrowse/app-core 5.0.0-beta.7 → 5.0.0-beta.9

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.
@@ -1,19 +1,18 @@
1
- import { setReExportRegistry } from '@jbrowse/core/ReExports/registry';
2
- import { getConf, getConfigurationSchemaDefinition, isSlotDefinitionEntry, readConfObject, } from '@jbrowse/core/configuration';
1
+ import { getReExportRegistry, setReExportRegistry, } from '@jbrowse/core/ReExports/registry';
2
+ import { getConf, getConfigurationSchemaDefinition, getConfigurationSchemaOptions, isConfigurationSubschema, isSlotDefinitionEntry, readConfObject, } from '@jbrowse/core/configuration';
3
3
  import { releaseAdapterSession, retainAdapterSession, } from '@jbrowse/core/data_adapters/adapterSessionRefcount';
4
4
  import { adapterConfigCacheKey } from '@jbrowse/core/data_adapters/dataAdapterCache';
5
- import { getFeatureAdapterOrThrow } from '@jbrowse/core/data_adapters/getFeatureAdapter';
6
5
  import { adapterByteLimit } from '@jbrowse/core/rpc/byteBudget';
7
- import { getRpcSessionId, isElectron, isSessionWithAddSessionTrack, objectHash, parseLocString, renameRegionsIfNeeded, } from '@jbrowse/core/util';
8
- import { openTracks, openViews } from '@jbrowse/core/util/openViews';
9
- import { createStopToken, stopStopToken } from '@jbrowse/core/util/stopToken';
6
+ import { isElectron, isSessionWithAddSessionTrack, objectHash, parseLocString, } from '@jbrowse/core/util';
7
+ import { openTracks, openViews, viewAndNested, } from '@jbrowse/core/util/openViews';
10
8
  import { allSessionTracks, getConfAssemblyNamesOrNone, guessTrackConfForLocation, isSameAssemblyName, viewCanDisplayTrack, viewDisplayNames, } from '@jbrowse/core/util/tracks';
9
+ import { unknownKeysMessage } from '@jbrowse/core/util/withLaunchInput';
11
10
  import * as mst from '@jbrowse/mobx-state-tree';
12
- import { getSnapshot, getType, isStateTreeNode } from '@jbrowse/mobx-state-tree';
11
+ import { applySnapshot, getSnapshot, getType, isStateTreeNode, } from '@jbrowse/mobx-state-tree';
13
12
  import * as mobx from 'mobx';
14
13
  // relative, not '@jbrowse/app-core': a package self-import would make this
15
14
  // module depend on the barrel that exports it
16
- import { loadSessionSpec } from '../SessionSpec/index.js';
15
+ import { launchSpecView, launchableSpecView, loadSessionSpec, viewTypeProblem, } from '../SessionSpec/index.js';
17
16
  // Matches BaseTrackModel's DEFAULT_EXPORT_BYTE_LIMIT: both bound a fetch
18
17
  // someone asked for by name rather than one a view scrolled into, so they
19
18
  // should refuse at the same size.
@@ -66,6 +65,7 @@ function displayState(track) {
66
65
  ...(display.renderError
67
66
  ? { renderError: String(display.renderError) }
68
67
  : {}),
68
+ ...(display.notices?.length ? { notices: [...display.notices] } : {}),
69
69
  };
70
70
  }
71
71
  catch (e) {
@@ -115,16 +115,20 @@ export function sessionOf(pluginManager) {
115
115
  * The three terms are AppReadyMarker's, and they have to be: the marker decides
116
116
  * whether the settle ends, this decides what it says when it doesn't, and a
117
117
  * term in one and not the other is a settle that answers `false` with an empty
118
- * `notReady`. `launching` is the one that was missing — `initialized` goes true
119
- * the moment a linear view's regions land, while the same apply pass still has
120
- * the spec's tracks to attach, so a spec load timing out there reported no
121
- * reason at all.
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.
122
126
  */
123
127
  function viewState(v) {
124
128
  if (v.error !== undefined) {
125
129
  return { error: String(v.error) };
126
130
  }
127
- if (v.initialized === false) {
131
+ if (v.initialized === false && v.showImportForm !== true) {
128
132
  return { phase: 'initializing' };
129
133
  }
130
134
  return v.pendingLaunch === undefined
@@ -157,8 +161,8 @@ function viewSummary(view) {
157
161
  })),
158
162
  }
159
163
  : {}),
160
- ...(Array.isArray(v.views) && v.views.length
161
- ? { views: v.views.map(sub => viewSummary(sub)) }
164
+ ...(view.ownViews.length
165
+ ? { views: view.ownViews.map(sub => viewSummary(sub)) }
162
166
  : {}),
163
167
  };
164
168
  }
@@ -232,27 +236,25 @@ function memberNames(node) {
232
236
  }
233
237
  return { getters: getters.sort(), methods: methods.sort() };
234
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
+ }
235
250
  function inspectSession(session, args) {
236
- const path = typeof args.path === 'string' ? args.path : '';
237
251
  const maxBytes = typeof args.maxBytes === 'number' ? args.maxBytes : 20_000;
238
- let node = session;
239
- const walked = [];
240
- for (const segment of path.split('.').filter(Boolean)) {
241
- node =
242
- node !== null && typeof node === 'object'
243
- ? node[segment]
244
- : undefined;
245
- if (node === undefined) {
246
- throw new Error(`Nothing at "${path}" (undefined after "${walked.join('.') || '(root)'}"). Inspect the parent path to see its keys and getters.`);
247
- }
248
- walked.push(segment);
249
- }
250
- const members = node !== null && typeof node === 'object' && !Array.isArray(node)
252
+ const node = inspectTarget(session, args.target);
253
+ const members = typeof node === 'object' && !Array.isArray(node)
251
254
  ? memberNames(node)
252
255
  : { getters: [], methods: [] };
253
256
  const json = safeJson(node);
254
257
  const base = {
255
- path: path || '(session root)',
256
258
  // the name the docs tool files a type under: docs topic "model:<modelType>"
257
259
  ...(isStateTreeNode(node) ? { modelType: getType(node).name } : {}),
258
260
  bytes: json.length,
@@ -266,12 +268,12 @@ function inspectSession(session, args) {
266
268
  return Array.isArray(plain)
267
269
  ? {
268
270
  ...base,
269
- note: `too large to return whole — ${plain.length} items; index in with .N or raise maxBytes`,
271
+ note: `too large to return whole — ${plain.length} items; inspect one of them or raise maxBytes`,
270
272
  items: plain.slice(0, 20).map(item => describeBrief(item)),
271
273
  }
272
274
  : {
273
275
  ...base,
274
- note: 'too large to return whole — drill down by path or raise maxBytes',
276
+ note: 'too large to return whole — inspect a child of it or raise maxBytes',
275
277
  keys: Object.fromEntries(Object.entries(plain).map(([k, v]) => [
276
278
  k,
277
279
  describeBrief(v),
@@ -325,7 +327,8 @@ root = document) {
325
327
  let outcome;
326
328
  while (outcome === undefined) {
327
329
  const ready = root.querySelector('[data-app-phase="ready"]') !== null &&
328
- root.querySelector('[data-testid="loading-overlay"]') === null;
330
+ root.querySelector('[data-testid="loading-overlay"]') === null &&
331
+ root.querySelector('[data-display-animating="true"]') === null;
329
332
  readySince = ready ? (readySince ?? Date.now()) : undefined;
330
333
  if (readySince !== undefined && Date.now() - readySince >= READY_HOLD_MS) {
331
334
  outcome = { settled: true };
@@ -355,7 +358,7 @@ root = document) {
355
358
  .filter(v => 'phase' in v || 'error' in v),
356
359
  ...allTracks(session)
357
360
  .map(t => ({ trackId: t.configuration.trackId, ...displayState(t) }))
358
- .filter(t => 'phase' in t || 'error' in t),
361
+ .filter(t => 'phase' in t || 'error' in t || 'notices' in t),
359
362
  ]
360
363
  : [];
361
364
  const offscreen = session ? offscreenViews(session, root) : undefined;
@@ -375,7 +378,7 @@ root = document) {
375
378
  function offscreenViews(session, root) {
376
379
  const win = root.ownerDocument?.defaultView ?? window;
377
380
  const windowHeight = win.innerHeight;
378
- const pageHeight = win.document.documentElement.scrollHeight;
381
+ const measured = overflow(root);
379
382
  const containers = new Map([
380
383
  ...root.querySelectorAll('[data-testid^="view-container-"]'),
381
384
  ].map(el => [el.dataset.testid.slice('view-container-'.length), el]));
@@ -391,23 +394,154 @@ function offscreenViews(session, root) {
391
394
  ]
392
395
  : [];
393
396
  });
394
- return pageHeight > windowHeight || views.length
397
+ return measured.pageHeight > measured.windowHeight || views.length
395
398
  ? {
396
- pageHeight,
397
- windowHeight,
399
+ pageHeight: measured.pageHeight,
400
+ windowHeight: measured.windowHeight,
398
401
  scrollY: Math.round(win.scrollY),
399
402
  views,
400
- note: 'the session is taller than the window; a viewport screenshot cuts these views off — shrink track heights, or screenshot with fullPage: true. A view with a negative top is scrolled out above the viewport (scrollY says by how much), not missing',
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',
401
404
  }
402
405
  : undefined;
403
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(root) {
411
+ const container = root.querySelector('[data-testid^="view-container-"]');
412
+ const win = root.ownerDocument?.defaultView ?? window;
413
+ for (let el = container?.parentElement; el; el = el.parentElement) {
414
+ const { overflowY } = win.getComputedStyle(el);
415
+ if ((overflowY === 'auto' || overflowY === 'scroll') &&
416
+ el.scrollHeight > el.clientHeight) {
417
+ return el;
418
+ }
419
+ }
420
+ return undefined;
421
+ }
422
+ // The trailing overscroll ViewStack renders below the last view, which exists so
423
+ // a newly-added view can scroll to the middle of the port. It is room, not
424
+ // content, and it is up to 300px of a scrollHeight, so a shrink that charges it
425
+ // to the views leaves them that much short of the window — the E. coli take's
426
+ // dotplot was cut to 312px of the 600 that fit.
427
+ function overscrollHeight(scroller) {
428
+ const spacer = scroller.querySelector('[data-testid="view-stack-overscroll"]');
429
+ return spacer ? Math.round(spacer.getBoundingClientRect().height) : 0;
430
+ }
431
+ function overflow(root) {
432
+ const scroller = scrollerOf(root);
433
+ if (scroller) {
434
+ return {
435
+ pageHeight: scroller.scrollHeight - overscrollHeight(scroller),
436
+ windowHeight: scroller.clientHeight,
437
+ };
438
+ }
439
+ const win = root.ownerDocument?.defaultView ?? window;
440
+ return {
441
+ pageHeight: win.document.documentElement.scrollHeight,
442
+ windowHeight: win.innerHeight,
443
+ };
444
+ }
445
+ // What a shrink can leave a display, a synteny band or a dotplot at: enough to
446
+ // still read a track's name and see that it drew.
447
+ const MIN_HEIGHT_PX = 40;
448
+ // Everything on screen whose height is its own state: each shown track's
449
+ // display, each synteny band, and a view whose height is a declared property
450
+ // rather than the sum of its tracks (a dotplot). A linear view's `height` is a
451
+ // getter over its tracks, so shrinking it is shrinking them.
452
+ function shrinkables(session) {
453
+ return openViews(session).flatMap(view => {
454
+ const v = view;
455
+ const declared = isStateTreeNode(view) &&
456
+ getType(view).properties
457
+ ?.height !== undefined;
458
+ const own = declared && typeof v.height === 'number' && v.setHeight
459
+ ? [{ what: `view ${v.id}`, height: v.height, setHeight: v.setHeight }]
460
+ : [];
461
+ const bands = (v.levels ?? []).flatMap((level, i) => typeof level.height === 'number' && level.setHeight
462
+ ? [
463
+ {
464
+ what: `view ${v.id} band ${i}`,
465
+ height: level.height,
466
+ setHeight: level.setHeight,
467
+ },
468
+ ]
469
+ : []);
470
+ const displays = viewTracks(view).flatMap(track => {
471
+ const d = track.activeDisplay;
472
+ return d && typeof d.height === 'number' && d.setHeight
473
+ ? [
474
+ {
475
+ what: track.configuration.trackId,
476
+ height: d.height,
477
+ setHeight: d.setHeight,
478
+ },
479
+ ]
480
+ : [];
481
+ });
482
+ return [...own, ...bands, ...displays];
483
+ });
484
+ }
485
+ /**
486
+ * Shrink what is open until the session fits the window, and say what moved.
487
+ *
488
+ * Every filmed take spent two to five screenshot rounds on this by hand: the
489
+ * settle reported `offscreen`, the agent guessed which heights to cut and by
490
+ * how much, and looked again. The overflow is arithmetic the settle already
491
+ * does; this spends it across every display, band and dotplot in proportion to
492
+ * the headroom each has above the floor, so tall tracks give up the most and
493
+ * nothing goes below readable.
494
+ */
495
+ async function fitToWindow(session, settleMs, root = document) {
496
+ const before = overflow(root);
497
+ const excess = before.pageHeight - before.windowHeight;
498
+ if (excess <= 0) {
499
+ return { fits: true, ...before };
500
+ }
501
+ const items = shrinkables(session);
502
+ const headroom = items.map(i => Math.max(0, i.height - MIN_HEIGHT_PX));
503
+ const available = headroom.reduce((a, b) => a + b, 0);
504
+ const cut = Math.min(excess, available);
505
+ const shrunk = items.flatMap((item, i) => {
506
+ const share = available ? Math.round((cut * headroom[i]) / available) : 0;
507
+ if (share <= 0) {
508
+ return [];
509
+ }
510
+ const to = item.height - share;
511
+ item.setHeight(to);
512
+ return [{ what: item.what, from: item.height, to }];
513
+ });
514
+ const settle = await waitReady(settleMs, session, root);
515
+ const after = overflow(root);
516
+ const left = after.pageHeight - after.windowHeight;
517
+ return {
518
+ fits: left <= 0,
519
+ overflowBefore: excess,
520
+ overflowAfter: Math.max(0, left),
521
+ shrunk,
522
+ ...(left > 0
523
+ ? {
524
+ note: available < excess
525
+ ? `everything shrinkable is at its ${MIN_HEIGHT_PX} px floor — hide a track or a view, or screenshot with fullPage: true`
526
+ : 'still taller than the window after the settle — call jb.fitToWindow() again',
527
+ }
528
+ : {}),
529
+ ...settle,
530
+ };
531
+ }
532
+ // The catalog is the largest result of nearly every agent run — the 129 volvox
533
+ // rows were 20 KB — so a row carries what picks a track out of the list and
534
+ // nothing more. The adapter type is `jb.inspect`'s or `jb.describeSlots`' to
535
+ // answer for the one track an agent went on to use.
536
+ //
537
+ // Dropping assemblyNames wherever the session has a single assembly saved
538
+ // nothing and made the row shape vary by session: a config declares far more
539
+ // assemblies than it opens, and volvox — every eval run's config — declares 10.
404
540
  function trackEntry(conf) {
405
- const adapter = readConfObject(conf, 'adapter');
406
541
  return {
407
542
  trackId: conf.trackId,
408
543
  name: readConfObject(conf, 'name'),
409
544
  type: conf.type,
410
- ...(adapter.type ? { adapterType: adapter.type } : {}),
411
545
  // getConfAssemblyNamesOrNone, not the slot: an assembly's sequence track
412
546
  // has no assemblyNames slot and answers through its parent assembly
413
547
  assemblyNames: getConfAssemblyNamesOrNone(conf),
@@ -439,21 +573,11 @@ function listTracks(session, searchArg, limitArg) {
439
573
  };
440
574
  }
441
575
  function pickView(session, args, capability, wants) {
442
- const viewId = typeof args.viewId === 'string' ? args.viewId : '';
443
- const candidates = openViews(session);
444
- if (viewId) {
445
- const named = candidates.find(v => v.id === viewId);
446
- if (!named) {
447
- throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => `${v.id} (${v.type})`).join(', ')}`);
448
- }
449
- if (typeof viewSelf(named)[capability] !== 'function') {
450
- throw new Error(`View ${named.id} (${named.type}) does not support this`);
451
- }
452
- return named;
453
- }
576
+ const viewId = typeof args.viewId === 'string' ? args.viewId : undefined;
577
+ const candidates = viewScope(session, viewId);
454
578
  const able = candidates.filter(v => typeof viewSelf(v)[capability] === 'function');
455
579
  if (!able.length) {
456
- throw new Error(`No open view supports this. Open views: ${candidates.map(v => v.type).join(', ') || 'none'} — jb.loadSessionSpec can open one.`);
580
+ throw new Error(`No ${inScope(viewId)} supports this (${candidates.map(v => v.type).join(', ') || 'none open'}) — jb.loadSessionSpec can open one.`);
457
581
  }
458
582
  // A view on another assembly would show the track and render nothing, with a
459
583
  // successful-looking result — the same silent mismatch visibleRegionsOf
@@ -470,26 +594,33 @@ function pickView(session, args, capability, wants) {
470
594
  if (!canDisplay.length) {
471
595
  throw new Error(`No open view can display a ${wants.trackType} (open views: ${onAssembly.map(v => v.type).join(', ')})`);
472
596
  }
473
- return onlyView(canDisplay, 'could take this');
597
+ return onlyView(session, canDisplay, 'could take this', viewId);
598
+ }
599
+ // A synteny view's rows count as open views (they are what shows a region and
600
+ // a track), and an agent looking at one synteny view read "3 views are open"
601
+ // with nothing saying two of them were its rows.
602
+ function parentView(session, view) {
603
+ return openViews(session).find(v => v.ownViews.includes(view));
474
604
  }
475
- function describeView(view) {
605
+ function describeView(session, view) {
476
606
  const v = viewSelf(view);
477
607
  const on = v.assemblyNames?.length ? ` on ${v.assemblyNames.join(', ')}` : '';
478
608
  const at = v.coarseVisibleLocStrings ? ` at ${v.coarseVisibleLocStrings}` : '';
479
- return `${v.id} (${v.type}${on}${at})`;
480
- }
481
- // A name that matches several views has no right first answer: two linear
482
- // views of one assembly at two loci both "show the track", and taking the
483
- // first restyles one row or reads one region while the settle reports both.
484
- // The named-but-missing case already throws and lists the open views, so the
485
- // unnamed-and-plural case does the same.
486
- function onlyView(candidates, relation) {
609
+ const parent = parentView(session, view);
610
+ const row = parent ? `, a row of ${parent.id}` : '';
611
+ return `${v.id} (${v.type}${on}${at}${row})`;
612
+ }
613
+ function onlyView(session, candidates, relation, viewId) {
614
+ const named = candidates.find(v => v.id === viewId);
615
+ if (named) {
616
+ return named;
617
+ }
487
618
  const [first, ...rest] = candidates;
488
619
  if (!first) {
489
620
  throw new Error('No open view');
490
621
  }
491
622
  if (rest.length) {
492
- throw new Error(`${candidates.length} views ${relation}: ${candidates.map(v => describeView(v)).join('; ')} — pass viewId to say which`);
623
+ throw new Error(`${candidates.length} views ${relation}: ${candidates.map(v => describeView(session, v)).join('; ')} — pass viewId to say which`);
493
624
  }
494
625
  return first;
495
626
  }
@@ -498,58 +629,77 @@ function viewById(session, viewId) {
498
629
  if (viewId !== undefined) {
499
630
  const named = candidates.find(v => v.id === viewId);
500
631
  if (!named) {
501
- throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => describeView(v)).join('; ') || 'none'}`);
632
+ throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => describeView(session, v)).join('; ') || 'none'}`);
502
633
  }
503
634
  return named;
504
635
  }
505
636
  if (!candidates.length) {
506
637
  throw new Error('No view is open — jb.loadSessionSpec can open one');
507
638
  }
508
- return onlyView(candidates, 'are open');
639
+ return onlyView(session, candidates, 'are open');
640
+ }
641
+ function inScope(viewId) {
642
+ return viewId === undefined ? 'open view' : `view in ${viewId}`;
643
+ }
644
+ function viewScope(session, viewId) {
645
+ return viewId === undefined
646
+ ? openViews(session)
647
+ : viewAndNested(viewById(session, viewId));
509
648
  }
510
649
  function shownTrackModel(session, trackId, viewId) {
511
- const pool = viewId === undefined
512
- ? allTracks(session)
513
- : viewTracks(viewById(session, viewId));
514
- const shown = pool.filter(t => t.configuration.trackId === trackId);
515
- if (shown.length > 1) {
516
- const where = openViews(session)
517
- .filter(v => viewTracks(v).some(t => t.configuration.trackId === trackId))
518
- .map(v => describeView(v));
519
- throw new Error(`"${trackId}" is shown in ${shown.length} views: ${where.join('; ')} — pass viewId to say which`);
520
- }
521
- const [track] = shown;
650
+ const trackOf = (v) => viewTracks(v).find(t => t.configuration.trackId === trackId);
651
+ const showing = viewScope(session, viewId).filter(v => trackOf(v));
522
652
  // undefined here was followed by ".activeDisplay" on the next line of every
523
653
  // agent's code, and a TypeError says nothing about which of the two causes
524
654
  // it was
525
- if (!track) {
655
+ if (!showing.length) {
526
656
  throw new Error(session.getTrackById(trackId)
527
- ? `"${trackId}" is not shown in ${viewId === undefined ? 'any open view' : `view ${viewId}`} — await view.launchTrack("${trackId}") first; jb.sessionSummary() lists what each view shows`
657
+ ? `"${trackId}" is not shown in any ${inScope(viewId)} — await view.launchTrack("${trackId}") first; jb.sessionSummary() lists what each view shows`
528
658
  : `No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
529
659
  }
530
- return track;
660
+ return trackOf(onlyView(session, showing, `show "${trackId}"`, viewId));
531
661
  }
532
- async function locToRegion(session, conf, loc, assemblyArg) {
662
+ function isRegionLoc(loc) {
663
+ return (!!loc &&
664
+ typeof loc === 'object' &&
665
+ typeof loc.refName === 'string');
666
+ }
667
+ async function trackAssembly(session, conf, requested) {
533
668
  // getConfAssemblyNamesOrNone, not the assemblyNames slot: an assembly's own
534
669
  // sequence track has no such slot and answers through its parent assembly
535
670
  const trackAssemblies = getConfAssemblyNamesOrNone(conf);
536
- const assemblyName = assemblyArg ?? trackAssemblies[0];
671
+ const assemblyName = requested ?? trackAssemblies[0];
537
672
  if (assemblyName === undefined) {
538
673
  throw new Error('The track names no assembly; pass assembly explicitly');
539
674
  }
540
675
  // a named assembly the track is not on renames the region against the wrong
541
676
  // alias set and answers with the wrong assembly's coordinates, or nothing
542
- if (assemblyArg !== undefined &&
677
+ if (requested !== undefined &&
543
678
  trackAssemblies.length &&
544
- !trackAssemblies.some(name => isSameAssemblyName(name, assemblyArg, session.assemblyManager))) {
545
- throw new Error(`Track "${conf.trackId}" is on ${trackAssemblies.join(', ')}, not "${assemblyArg}"`);
679
+ !trackAssemblies.some(name => isSameAssemblyName(name, requested, session.assemblyManager))) {
680
+ throw new Error(`Track "${conf.trackId}" is on ${trackAssemblies.join(', ')}, not "${requested}"`);
546
681
  }
547
682
  const assembly = await session.assemblyManager.waitForAssembly(assemblyName);
548
683
  if (!assembly) {
549
684
  throw new Error(`Assembly "${assemblyName}" could not be loaded`);
550
685
  }
551
- const parsed = parseLocString(loc, refName => assembly.isValidRefName(refName));
686
+ return { assembly, assemblyName };
687
+ }
688
+ async function locToRegion(session, conf, loc, assemblyArg) {
689
+ if (typeof loc !== 'string' && !isRegionLoc(loc)) {
690
+ 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: [...]' : ''}`);
691
+ }
692
+ const { assembly, assemblyName } = await trackAssembly(session, conf, assemblyArg ?? (typeof loc === 'string' ? undefined : loc.assemblyName));
693
+ const parsed = typeof loc === 'string'
694
+ ? parseLocString(loc, refName => assembly.isValidRefName(refName))
695
+ : loc;
552
696
  const refName = assembly.getCanonicalRefName(parsed.refName) ?? parsed.refName;
697
+ // parseLocString asks this for the string form. A region object skipping it
698
+ // read empty on a name the assembly does not have, which is the answer this
699
+ // surface exists to turn into an error.
700
+ if (!assembly.isValidRefName(refName)) {
701
+ throw new Error(`"${parsed.refName}" is not a sequence in ${assemblyName} — jb.visibleRegions() and jb.sessionSummary() name what is there`);
702
+ }
553
703
  const bounds = assembly.regions?.find(r => r.refName === refName);
554
704
  return {
555
705
  assemblyName,
@@ -564,9 +714,9 @@ async function visibleRegionsOf(session, viewId, preferTrackId, trackAssemblies
564
714
  // freshly spec-loaded view stays in that state briefly even after the
565
715
  // app-phase marker reads ready, since a view with no width has no display
566
716
  // fetching anything.
567
- const regionBearing = openViews(session).filter(v => (!viewId || v.id === viewId) && 'visibleRegions' in v);
717
+ const regionBearing = viewScope(session, viewId).filter(v => 'visibleRegions' in v);
568
718
  if (!regionBearing.length) {
569
- throw new Error('No view that shows a region — pass loc, or open a linear view first');
719
+ throw new Error(`No ${inScope(viewId)} shows a region — pass loc, or open a linear view`);
570
720
  }
571
721
  // a view on another assembly than the track would hand its region to a file
572
722
  // that has no such sequence, which answers nothing, silently
@@ -575,13 +725,13 @@ async function visibleRegionsOf(session, viewId, preferTrackId, trackAssemblies
575
725
  viewSelf(v).assemblyNames.some(name => trackAssemblies.some(t => isSameAssemblyName(name, t, session.assemblyManager))))
576
726
  : regionBearing;
577
727
  if (!candidates.length) {
578
- throw new Error(`No open view is on ${trackAssemblies.join(', ')} (open views: ${regionBearing.map(v => describeView(v)).join('; ')}) — pass loc, or open a view on that assembly`);
728
+ 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`);
579
729
  }
580
730
  // among region-bearing views, the ones actually showing the track — two
581
731
  // views on two assemblies would otherwise send the first view's namespace to
582
732
  // the second view's file, which answers nothing, silently
583
733
  const showing = candidates.filter(v => viewTracks(v).some(t => t.configuration.trackId === preferTrackId));
584
- const chosen = onlyView(showing.length ? showing : candidates, showing.length ? `show "${preferTrackId}"` : 'show a region');
734
+ const chosen = onlyView(session, showing.length ? showing : candidates, showing.length ? `show "${preferTrackId}"` : 'show a region', viewId);
585
735
  const view = viewSelf(chosen);
586
736
  const deadline = Date.now() + 10_000;
587
737
  let visible;
@@ -638,11 +788,12 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
638
788
  // than because a view scrolled.
639
789
  const byteLimit = agentByteLimit(readConfObject(conf, ['adapter', 'fetchSizeLimit']), requestedByteLimit);
640
790
  const { rpcManager } = session;
641
- const stopToken = createStopToken();
791
+ const controller = new AbortController();
792
+ const { signal } = controller;
642
793
  // desktop's MCP relay gives up at 150s, so the read must not outlive it —
643
794
  // and an agent-triggered read of a dense region wants a ceiling either way
644
795
  const stopTimer = setTimeout(() => {
645
- stopStopToken(stopToken);
796
+ controller.abort();
646
797
  }, 120_000);
647
798
  retainAdapterSession(rpcManager, sessionId);
648
799
  try {
@@ -655,7 +806,7 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
655
806
  // whole answer is the failure this surface exists to prevent.
656
807
  const bytes = await rpcManager.call(sessionId, 'CoreGetRegionByteEstimate',
657
808
  // 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
658
- { adapterConfig, regions, scope: 'largestRegion', stopToken });
809
+ { adapterConfig, regions, scope: 'largestRegion', signal });
659
810
  if (bytes !== undefined && bytes > byteLimit) {
660
811
  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.`);
661
812
  }
@@ -663,7 +814,7 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
663
814
  return await rpcManager.call(sessionId, 'CoreGetFeatures', {
664
815
  adapterConfig,
665
816
  regions,
666
- stopToken,
817
+ signal,
667
818
  });
668
819
  }
669
820
  finally {
@@ -675,14 +826,19 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
675
826
  // throws "can't use local files in the browser" at the FIRST READ, not here, so
676
827
  // accepting one would report a track added and then fail inside the display
677
828
  // where the agent is least likely to connect it to what it asked for.
829
+ const URL_TEXT = /^https?:\/\//;
830
+ const ABSOLUTE_PATH = /^(?:\/|[a-zA-Z]:[\\/]|\\\\)/;
831
+ function isFileLocationText(spec) {
832
+ return URL_TEXT.test(spec) || ABSOLUTE_PATH.test(spec);
833
+ }
678
834
  function fileLocation(spec) {
679
- if (/^https?:\/\//.test(spec)) {
835
+ if (URL_TEXT.test(spec)) {
680
836
  return { uri: spec, locationType: 'UriLocation' };
681
837
  }
682
838
  // The app's working directory is not the agent's, and a relative path
683
839
  // resolves against the app's: under a packaged app that is "/", so the read
684
840
  // fails at the first fetch and reports through the display, not here.
685
- if (!/^(?:\/|[a-zA-Z]:[\\/]|\\\\)/.test(spec)) {
841
+ if (!ABSOLUTE_PATH.test(spec)) {
686
842
  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.`);
687
843
  }
688
844
  if (!isElectron) {
@@ -733,32 +889,55 @@ function locationsOf(value) {
733
889
  // Vocabulary introspection: every config slot a live config node's schema
734
890
  // defines, so code never has to guess which settings keys exist — an unknown
735
891
  // key is not an error, only an entry in applyDisplaySettings' `unapplied` list.
892
+ // A nested object (`facet`, `color`) lists its own slots, and the slot its
893
+ // string shorthand lifts into.
736
894
  function describeSlots(conf) {
737
895
  const definition = getConfigurationSchemaDefinition(conf) ?? {};
738
- return Object.fromEntries(Object.entries(definition).flatMap(([name, def]) => isSlotDefinitionEntry(def)
739
- ? [
740
- [
741
- name,
742
- {
743
- type: def.type,
744
- ...(def.description ? { description: def.description } : {}),
745
- defaultValue: def.defaultValue,
746
- },
747
- ],
748
- ]
749
- : []));
896
+ return Object.fromEntries(Object.entries(definition).flatMap(([name, def]) => {
897
+ if (isSlotDefinitionEntry(def)) {
898
+ return [
899
+ [
900
+ name,
901
+ {
902
+ type: def.type,
903
+ ...(def.description ? { description: def.description } : {}),
904
+ defaultValue: def.defaultValue,
905
+ },
906
+ ],
907
+ ];
908
+ }
909
+ if (isConfigurationSubschema(conf, name)) {
910
+ const node = conf[name];
911
+ const shorthand = getConfigurationSchemaOptions(node)?.shorthand;
912
+ return [
913
+ [
914
+ name,
915
+ {
916
+ type: mst
917
+ .getType(node)
918
+ .name.replace(/ConfigurationSchema$/, ''),
919
+ slots: describeSlots(node),
920
+ ...(shorthand ? { shorthand } : {}),
921
+ },
922
+ ],
923
+ ];
924
+ }
925
+ return [];
926
+ }));
750
927
  }
751
928
  // The raw primitive under all of the above: Claude-authored code against the
752
929
  // live model graph. The renderer already runs with nodeIntegration and the
753
930
  // bridge socket is user-only, so this grants what the surface as a whole
754
931
  // already grants — expressed directly instead of through a curated verb.
755
- // The re-export registry is what pluginManager.jbrequire serves: the same
756
- // pinned ABI module names external plugins link against (ReExports/modules.ts,
757
- // abiBaseline.json). It stays empty until a runtime plugin loads, so each
758
- // evaluate (re)installs it — import() is memoized, so this is one lookup and
759
- // an idempotent assignment, with no module-level flag to hold.
932
+ // The re-export registry is what pluginManager.jbrequire serves, the modules
933
+ // external plugins link against. It stays empty until a runtime plugin loads,
934
+ // and a loaded plugin fills it with the product's map, which serves every
935
+ // bundled @jbrowse package rather than core alone — so this fills only an
936
+ // empty registry.
760
937
  export async function ensureReExports() {
761
- setReExportRegistry((await import('@jbrowse/core/ReExports/modules')).default);
938
+ if (Object.keys(getReExportRegistry()).length === 0) {
939
+ setReExportRegistry((await import('@jbrowse/core/ReExports/modules')).default);
940
+ }
762
941
  }
763
942
  // The front door for an agent that just found `jb` and knows nothing else —
764
943
  // jbrowse-web's console banner and meta tag point here, because a browser
@@ -766,13 +945,13 @@ export async function ensureReExports() {
766
945
  // contract from.
767
946
  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).
768
947
 
769
- 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, so read the report; jb.inspect('views.0') for a live node's getters, actions and modelType.
948
+ 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.
770
949
 
771
- The model is mobx-state-tree: mutate only through actions (raw assignment throws), and write display settings with track.applyDisplaySettings(settings). Build views declaratively with jb.loadSessionSpec({ views: [{ type: 'LinearGenomeView', assembly, loc, tracks: [...] }] }); arrange the views already open into panels with session.layoutViews({ direction: 'horizontal', children: [{ views: [viewId] }, ...] }) — leaves name view ids or indexes into session.views; add data with jb.addTrack({ location }) (an absolute path or a URL); read data with await jb.getFeatures({ trackId, loc?, assembly?, viewId?, regions?, byteLimit? }) (or jb.getFeatures(trackId, loc?, opts?)), which renames refNames ("chr1" vs "1") so the file answers and reads on the worker the track's display uses — raw adapter code must call jb.renameRegionsIfNeeded itself. After changing anything, await jb.waitReady(ms) and read its notifications and notReady lists before trusting the screen.
950
+ 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.setJexlFilters([jexl]). An alignments track takes the same facet, its field a read dimension ('pairOrientation', 'mapq', ...) or a tag ('tags.HP'). 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.
772
951
 
773
- 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.
952
+ 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.
774
953
 
775
- Full guide: https://jbrowse.org/jb2/docs/agents_live_model (JBrowse Desktop serves the same guide offline through its MCP docs tool — Help menu, "Connect an AI agent...").`;
954
+ Full guide: https://jbrowse.org/jb2/docs/agents_live_model (offline in JBrowse Desktop's MCP docs tool).`;
776
955
  // The helper library an agent drives the app through. Built from the plugin
777
956
  // manager alone, so one of these serves a whole app rather than one session —
778
957
  // which is what lets jbrowse-web hand the same object to every caller for the
@@ -817,22 +996,19 @@ export function createJbApi(pluginManager, options = {}) {
817
996
  readConfObject,
818
997
  getConf,
819
998
  describeSlots,
820
- parseLocString,
821
- getFeatureAdapterOrThrow,
822
- getRpcSessionId,
823
- renameRegionsIfNeeded,
824
- createStopToken,
825
- stopStopToken,
826
999
  // a default because an omitted number made the deadline NaN, and a view
827
1000
  // that never readied then held the call open to the relay's own timeout
828
1001
  waitReady: async (timeoutMs = 30_000) => reported(await waitReady(timeoutMs, live())),
829
1002
  sessionSummary: () => sessionSummary(live()),
830
- inspect: (path, maxInspectBytes) => inspectSession(live(), { path, maxBytes: maxInspectBytes }),
1003
+ inspect: (target, maxInspectBytes) => inspectSession(live(), { target, maxBytes: maxInspectBytes }),
831
1004
  listTracks: (search, limit) => listTracks(live(), search, limit),
832
1005
  view: (viewId) => viewById(live(), viewId),
833
1006
  trackModel: (trackId, viewId) => shownTrackModel(live(), trackId, viewId),
834
1007
  visibleRegions: (viewId) => visibleRegionsOf(live(), viewId),
835
1008
  loadSessionSpec: async (spec, settleMs) => reported(await loadSpec(pluginManager, { spec, settleMs })),
1009
+ addView: async (spec, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await addView(pluginManager, live(), spec, settleMs)),
1010
+ setSession: async (document, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await setSession(pluginManager, live(), document, settleMs)),
1011
+ fitToWindow: async (settleMs = 30_000) => reported(await fitToWindow(live(), settleMs)),
836
1012
  addTrack: (opts) => addTrack(pluginManager, live(), opts).then(reported),
837
1013
  // Two of four filmed takes wrote jb.getFeatures('trackId', loc) and lost a
838
1014
  // turn to "No track with trackId undefined", and a third wrote
@@ -848,12 +1024,17 @@ export function createJbApi(pluginManager, options = {}) {
848
1024
  if (!conf) {
849
1025
  throw new Error(`No track with trackId "${fetchArgs.trackId}" — jb.listTracks() shows what is available`);
850
1026
  }
851
- const regions = fetchArgs.regions ??
852
- (fetchArgs.loc !== undefined
1027
+ // `regions` entries go through locToRegion as `loc` does. Passed
1028
+ // straight to the fetch they skipped the canonical rename, and an entry
1029
+ // carrying no assemblyName reached renameRegionsIfNeeded with nothing to
1030
+ // rename against — an alias spelling then read empty and said nothing.
1031
+ const regions = fetchArgs.regions
1032
+ ? await Promise.all(fetchArgs.regions.map(region => locToRegion(session, conf, region, fetchArgs.assembly)))
1033
+ : fetchArgs.loc !== undefined
853
1034
  ? [
854
1035
  await locToRegion(session, conf, fetchArgs.loc, fetchArgs.assembly),
855
1036
  ]
856
- : await visibleRegionsOf(session, fetchArgs.viewId, fetchArgs.trackId, getConfAssemblyNamesOrNone(conf)));
1037
+ : await visibleRegionsOf(session, fetchArgs.viewId, fetchArgs.trackId, getConfAssemblyNamesOrNone(conf));
857
1038
  return fetchFeatures(session, fetchArgs.trackId, regions, fetchArgs.byteLimit);
858
1039
  },
859
1040
  };
@@ -862,13 +1043,45 @@ export function createJbApi(pluginManager, options = {}) {
862
1043
  // a spec load on a busy machine passed 30s and answered settled:false over
863
1044
  // views that were fine; the wait exits the moment they are ready
864
1045
  const ADD_TRACK_SETTLE_MS = 60_000;
1046
+ function namesAFile(value) {
1047
+ return Array.isArray(value)
1048
+ ? value.some(l => typeof l === 'string' && isFileLocationText(l))
1049
+ : typeof value === 'string' && isFileLocationText(value);
1050
+ }
1051
+ // The name an agent meant as a catalog trackId: `trackId`, or a `location`
1052
+ // that is neither a URL nor a path. Agent runs reached for jb.addTrack to SHOW
1053
+ // a track already in the catalog in both spellings, and each paid a round trip
1054
+ // for a refusal that only named another helper.
1055
+ //
1056
+ // A location that names a file is the file route whatever else came with it:
1057
+ // `trackId` beside `location` is how a track CONFIG spells this, and taking it
1058
+ // as the catalog route would refuse the file the caller asked to add.
1059
+ function catalogName(args) {
1060
+ if (namesAFile(args.location)) {
1061
+ return undefined;
1062
+ }
1063
+ if (typeof args.trackId === 'string') {
1064
+ return args.trackId;
1065
+ }
1066
+ return typeof args.location === 'string' ? args.location : undefined;
1067
+ }
865
1068
  async function addTrack(pluginManager, session, args) {
1069
+ const named = catalogName(args);
1070
+ const inCatalog = named === undefined ? undefined : session.getTrackById(named);
1071
+ // before the add-rights check: showing a track the catalog already holds
1072
+ // adds nothing to it
1073
+ if (inCatalog) {
1074
+ return showCatalogTrack(pluginManager, session, inCatalog, args);
1075
+ }
1076
+ if (named !== undefined && typeof args.trackId === 'string') {
1077
+ 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.`);
1078
+ }
866
1079
  if (!isSessionWithAddSessionTrack(session)) {
867
1080
  throw new Error('This session cannot add tracks');
868
1081
  }
869
1082
  const location = locationsOf(args.location);
870
1083
  if (!location.length) {
871
- throw new Error('jb.addTrack needs a location (an absolute local path or a URL)');
1084
+ 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.');
872
1085
  }
873
1086
  const requested = typeof args.assembly === 'string' ? args.assembly : session.assemblyNames[0];
874
1087
  if (requested === undefined) {
@@ -893,25 +1106,182 @@ async function addTrack(pluginManager, session, args) {
893
1106
  adapterType: conf.adapter.type,
894
1107
  assembly,
895
1108
  };
896
- if (args.show === false) {
897
- return summary;
1109
+ return args.show === false
1110
+ ? summary
1111
+ : {
1112
+ ...summary,
1113
+ ...(await showTrack(pluginManager, session, { trackId: conf.trackId, trackType: conf.type, assembly }, args)),
1114
+ };
1115
+ }
1116
+ // A track already in the catalog, shown: the same view choice and the same
1117
+ // settle the file route ends in.
1118
+ // `conf` is whatever `session.getTrackById` answered, which is a track config
1119
+ // but not necessarily a `BaseTrackConfig` — an assembly's ReferenceSequenceTrack
1120
+ // declares a subset of those slots. The four reads below are all ones it has.
1121
+ async function showCatalogTrack(pluginManager, session, conf, args) {
1122
+ // index, assembly and name describe the file being added, and the catalog's
1123
+ // copy already has all three — taking them here would drop them in silence
1124
+ const fileArgs = ['index', 'assembly', 'name'].filter(key => args[key] !== undefined);
1125
+ if (fileArgs.length) {
1126
+ 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.`);
898
1127
  }
899
- const view = pickView(session, args, 'launchTrack', {
900
- assembly,
1128
+ const track = {
1129
+ trackId: conf.trackId,
901
1130
  trackType: conf.type,
1131
+ assembly: getConfAssemblyNamesOrNone(conf)[0],
1132
+ };
1133
+ const adapterType = readConfObject(conf, ['adapter', 'type']);
1134
+ const summary = { ...track, ...(adapterType ? { adapterType } : {}) };
1135
+ // show:false over a catalog trackId asks for nothing — there is no file to
1136
+ // add — and showing it anyway would contradict the flag
1137
+ return args.show === false
1138
+ ? summary
1139
+ : {
1140
+ ...summary,
1141
+ ...(await showTrack(pluginManager, session, track, args)),
1142
+ };
1143
+ }
1144
+ function isSettings(value) {
1145
+ return !!value && typeof value === 'object' && !Array.isArray(value);
1146
+ }
1147
+ async function showTrack(pluginManager, session, track, args) {
1148
+ const view = pickView(session, args, 'launchTrack', {
1149
+ assembly: track.assembly,
1150
+ trackType: track.trackType,
902
1151
  pluginManager,
903
1152
  });
904
1153
  // awaited: a display state model is lazy in every plugin here, and the
905
1154
  // synchronous showTrack answers before the chunk lands — so the settle below
906
1155
  // would run against a session that does not hold the track yet, and report
907
1156
  // ready over it
908
- await viewSelf(view).launchTrack(conf.trackId);
909
- const shown = { ...summary, shownInView: view.id };
1157
+ await viewSelf(view).launchTrack(track.trackId, {}, isSettings(args.settings) ? args.settings : undefined);
910
1158
  // 0 skips the settle, so several adds can share one jb.waitReady
911
1159
  const settleMs = typeof args.settleMs === 'number' ? args.settleMs : ADD_TRACK_SETTLE_MS;
1160
+ return {
1161
+ shownInView: view.id,
1162
+ ...(settleMs > 0 ? await waitReady(settleMs, session) : {}),
1163
+ };
1164
+ }
1165
+ /**
1166
+ * One view from one spec entry, into the open session, through the same
1167
+ * launcher extension point `jb.loadSessionSpec` takes for each of its views —
1168
+ * so the entry means the same thing in both places, a ProteinView's
1169
+ * `connectedView` included. `session.launchView` is the other route and does
1170
+ * not: it is `addView` with a snapshot, so it never runs a view's launcher.
1171
+ *
1172
+ * A key the view does not take throws before anything opens. A spec has no
1173
+ * return channel and launches anyway with an error toast; an awaited call has
1174
+ * one, and a view that opened without the setting the agent typed is the
1175
+ * quietly-wrong answer this surface exists to refuse.
1176
+ */
1177
+ async function addView(pluginManager, session, spec, settleMs) {
1178
+ if (typeof spec !== 'object' || typeof spec.type !== 'string') {
1179
+ 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")');
1180
+ }
1181
+ const unlaunchable = viewTypeProblem(pluginManager, spec.type);
1182
+ if (unlaunchable) {
1183
+ throw new Error(unlaunchable);
1184
+ }
1185
+ const { view: entry, problem } = await launchableSpecView(pluginManager, spec);
1186
+ if (problem) {
1187
+ throw new Error(`${problem} — nothing was opened. docs topic "session-spec" lists the keys ${entry.type} takes`);
1188
+ }
1189
+ const { created, view, failure } = await launchSpecView(session, pluginManager, entry);
1190
+ if (failure) {
1191
+ throw new Error(failure.message, { cause: failure.cause });
1192
+ }
1193
+ if (!view) {
1194
+ throw new Error(`${entry.type} opened no view`);
1195
+ }
1196
+ const summary = {
1197
+ viewId: view.id,
1198
+ ...(created.length > 1 ? { created: created.map(v => v.id) } : {}),
1199
+ };
912
1200
  return settleMs > 0
913
- ? { ...shown, ...(await waitReady(settleMs, session)) }
914
- : shown;
1201
+ ? { ...summary, ...(await waitReady(settleMs, session)) }
1202
+ : summary;
1203
+ }
1204
+ /**
1205
+ * The session as one document, rewritten: what `getSnapshot(session)` answers,
1206
+ * edited, handed back. MST reconciles by identifier, so a view, track or
1207
+ * display whose `id` the document keeps is patched in place and stays mounted,
1208
+ * one the document drops is closed, and an entry with no id is new. A view's
1209
+ * launch keys are accepted beside its built state — `loc` navigates it, a
1210
+ * `{ trackId }` entry in its `tracks` opens that track — because the partition
1211
+ * that sorts them runs on every snapshot the view is given, not only its
1212
+ * first.
1213
+ *
1214
+ * Top-level keys the document leaves out keep their current value, so
1215
+ * `{ views: [...] }` is a complete instruction.
1216
+ *
1217
+ * Views leave through the session's own detach first (ADR-069): `applySnapshot`
1218
+ * destroys what the target lacks in place, under components still mounted
1219
+ * over it, which is what undo had to route around.
1220
+ */
1221
+ // A key nothing on the far side takes, per view entry. MST drops one silently,
1222
+ // and the partition that names it runs at attach — which a view the document
1223
+ // KEEPS never reaches, so the commonest edit was the quiet one.
1224
+ //
1225
+ // Reported, not refused, like the same mistake on a session spec: a document
1226
+ // that works today must not cost the whole call. `acceptedKeys` answers for a
1227
+ // legacy spelling too — a view's `passThrough` declares the ones its own
1228
+ // preProcessSnapshot converts, which is what makes this a question about the
1229
+ // view type rather than about whatever members the live node happens to have.
1230
+ function unknownViewKeys(pluginManager, entry) {
1231
+ if (typeof entry !== 'object' ||
1232
+ entry === null ||
1233
+ typeof entry.type !== 'string') {
1234
+ return [];
1235
+ }
1236
+ const { type, views, ...keyed } = entry;
1237
+ const nested = Array.isArray(views)
1238
+ ? views.flatMap(view => unknownViewKeys(pluginManager, view))
1239
+ : [];
1240
+ if (!pluginManager.getElementTypeRecord('view').has(type)) {
1241
+ return nested;
1242
+ }
1243
+ const viewType = pluginManager.getViewType(type);
1244
+ const accepted = viewType.acceptedKeys ??
1245
+ Object.keys(viewType.stateModel.properties);
1246
+ const unknown = Object.keys(keyed).filter(key => !accepted.includes(key));
1247
+ return [
1248
+ ...(unknown.length
1249
+ ? [
1250
+ `${unknownKeysMessage(type, unknown)}${viewType.launchKeys
1251
+ ? ''
1252
+ : ' — a document runs no launcher, so its launch keys go through jb.addView'}`,
1253
+ ]
1254
+ : []),
1255
+ ...nested,
1256
+ ];
1257
+ }
1258
+ async function setSession(pluginManager, session, document, settleMs) {
1259
+ if (typeof document !== 'object' ||
1260
+ document === null ||
1261
+ Array.isArray(document)) {
1262
+ 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.');
1263
+ }
1264
+ const next = {
1265
+ ...getSnapshot(session),
1266
+ ...document,
1267
+ };
1268
+ // a lazily registered view or display type is not in the session's type union
1269
+ // until its model loads, and applySnapshot is synchronous
1270
+ await pluginManager.preloadSessionTypes(next);
1271
+ for (const problem of (Array.isArray(next.views) ? next.views : []).flatMap(entry => unknownViewKeys(pluginManager, entry))) {
1272
+ session.notifyError(problem);
1273
+ }
1274
+ try {
1275
+ ;
1276
+ session.takeOutViewsMissingFrom?.(next);
1277
+ applySnapshot(session, next);
1278
+ }
1279
+ catch (e) {
1280
+ const detail = e instanceof Error ? e.message : String(e);
1281
+ throw new Error(`jb.setSession refused the document: ${detail.length > 1500 ? `${detail.slice(0, 1500)}…` : detail}`, { cause: e });
1282
+ }
1283
+ const settle = await waitReady(settleMs, session);
1284
+ return { ...settle, session: sessionSummary(session) };
915
1285
  }
916
1286
  // Under the 45 s one evaluation gets from the Claude in Chrome extension: a
917
1287
  // cold hosted config settling for the full minute answered nothing there,