gloomberb 0.16.0 → 0.16.1

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 (167) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/data.ts +18 -3
  3. package/src/api-client/index.ts +3 -0
  4. package/src/api-client/members.ts +26 -0
  5. package/src/api-client/perps.ts +4 -0
  6. package/src/api-client/types.ts +7 -1
  7. package/src/app/global-shortcuts.ts +14 -6
  8. package/src/app/keybindings/actions.ts +1 -1
  9. package/src/app/runtime/update.ts +38 -2
  10. package/src/app.tsx +10 -6
  11. package/src/cli/pane-functions/function-help.ts +9 -2
  12. package/src/components/command-bar/commands/collection.ts +5 -1
  13. package/src/components/command-bar/routes/root/command-items.ts +9 -2
  14. package/src/components/command-bar/routes/root/plugin-install.ts +2 -2
  15. package/src/components/command-bar/routes/root/provider-search.ts +19 -5
  16. package/src/components/command-bar/routes/root/runtime.ts +14 -5
  17. package/src/components/command-bar/routes/ticker-search/results.ts +31 -5
  18. package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
  19. package/src/components/command-bar/routing/actions.ts +2 -0
  20. package/src/components/command-bar/selection-runtime.ts +38 -0
  21. package/src/components/command-bar/surface/index.tsx +7 -2
  22. package/src/components/index.ts +2 -2
  23. package/src/components/layout/floating-pane.tsx +6 -0
  24. package/src/components/layout/header.tsx +24 -7
  25. package/src/components/layout/missing-pane.tsx +2 -2
  26. package/src/components/layout/pane/header.tsx +63 -30
  27. package/src/components/layout/pane/index.tsx +6 -0
  28. package/src/components/layout/pane/title.ts +31 -7
  29. package/src/components/layout/shell/drag/index.ts +6 -5
  30. package/src/components/layout/shell/drag/runtime.ts +3 -0
  31. package/src/components/layout/shell/index.tsx +6 -0
  32. package/src/components/layout/shell/pane/layers.tsx +15 -7
  33. package/src/components/layout/shell/terminal-pointer-runtime.ts +24 -3
  34. package/src/components/layout/shell/ticker-link-menu.ts +23 -9
  35. package/src/components/metric-treemap/heat-surface.tsx +718 -0
  36. package/src/components/metric-treemap/heat-tiles.ts +210 -0
  37. package/src/components/metric-treemap/index.tsx +2 -0
  38. package/src/components/metric-treemap/layout.ts +6 -0
  39. package/src/components/metric-treemap/scene.ts +432 -0
  40. package/src/components/pane-settings-dialog/field-dialogs.tsx +1 -0
  41. package/src/components/toggle-list.tsx +38 -12
  42. package/src/components/ui/country-flag-art.tsx +449 -0
  43. package/src/components/ui/country-flag.tsx +41 -0
  44. package/src/components/ui/data-table/types.ts +13 -0
  45. package/src/components/ui/icon.tsx +2 -0
  46. package/src/components/ui/index.ts +4 -0
  47. package/src/components/ui/multi-select/dialog.tsx +108 -22
  48. package/src/components/ui/range-track.tsx +46 -0
  49. package/src/components/ui/tabs.tsx +4 -0
  50. package/src/core/state/app/types.ts +10 -1
  51. package/src/i18n/es.ts +5 -3
  52. package/src/i18n/ja.ts +5 -3
  53. package/src/i18n/ko.ts +5 -3
  54. package/src/i18n/zh-cn.ts +5 -3
  55. package/src/i18n/zh-tw.ts +5 -3
  56. package/src/layout/ticker-navigation.ts +28 -2
  57. package/src/market-data/quotes/contributions.ts +6 -0
  58. package/src/market-data/quotes/freshness.ts +36 -0
  59. package/src/market-data/quotes/resolution.ts +22 -8
  60. package/src/market-data/quotes/time.ts +16 -0
  61. package/src/plugins/builtin/analytics/risk-client.ts +185 -59
  62. package/src/plugins/builtin/analytics/risk-headless.ts +55 -49
  63. package/src/plugins/builtin/analytics/risk-math.ts +33 -13
  64. package/src/plugins/builtin/analytics/risk-model.ts +250 -60
  65. package/src/plugins/builtin/analytics/risk-pane.tsx +61 -30
  66. package/src/plugins/builtin/analytics/risk-test-data.ts +182 -0
  67. package/src/plugins/builtin/buildout/cells.tsx +15 -12
  68. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  69. package/src/plugins/builtin/buildout/detail/index.tsx +0 -1
  70. package/src/plugins/builtin/buildout/detail/intel.tsx +6 -6
  71. package/src/plugins/builtin/buildout/detail/site.tsx +4 -7
  72. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -19
  73. package/src/plugins/builtin/catalysts/index.tsx +2 -2
  74. package/src/plugins/builtin/catalysts/pane.tsx +3 -1
  75. package/src/plugins/builtin/cds/index.tsx +1 -0
  76. package/src/plugins/builtin/debt-maturities/wall-bar.tsx +14 -41
  77. package/src/plugins/builtin/dividend-yield/client.ts +4 -2
  78. package/src/plugins/builtin/earnings/client.ts +2 -2
  79. package/src/plugins/builtin/earnings-calls/index.tsx +1 -0
  80. package/src/plugins/builtin/fx-matrix/direction.ts +81 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +71 -33
  82. package/src/plugins/builtin/fx-matrix/live-legs.ts +17 -0
  83. package/src/plugins/builtin/fx-matrix/pairs.ts +68 -14
  84. package/src/plugins/builtin/fx-matrix/settings.ts +62 -0
  85. package/src/plugins/builtin/jobs/index.tsx +1 -0
  86. package/src/plugins/builtin/jobs/share-bars.tsx +5 -28
  87. package/src/plugins/builtin/market-heatmap/data.ts +28 -2
  88. package/src/plugins/builtin/market-heatmap/index.tsx +250 -79
  89. package/src/plugins/builtin/market-heatmap/live.ts +88 -0
  90. package/src/plugins/builtin/market-heatmap/portfolio.ts +27 -7
  91. package/src/plugins/builtin/market-movers/client.ts +3 -2
  92. package/src/plugins/builtin/market-movers/headless.ts +8 -1
  93. package/src/plugins/builtin/market-movers/screener.ts +18 -5
  94. package/src/plugins/builtin/members/client.ts +36 -0
  95. package/src/plugins/builtin/members/headless.ts +33 -0
  96. package/src/plugins/builtin/members/index.tsx +20 -0
  97. package/src/plugins/builtin/members/model.ts +60 -0
  98. package/src/plugins/builtin/members/pane.tsx +168 -0
  99. package/src/plugins/builtin/mna/index.tsx +2 -1
  100. package/src/plugins/builtin/mna/pane.tsx +5 -2
  101. package/src/plugins/builtin/options/index.tsx +2 -1
  102. package/src/plugins/builtin/pe-band/client.ts +29 -2
  103. package/src/plugins/builtin/pe-band/headless.ts +2 -2
  104. package/src/plugins/builtin/pe-band/model.ts +11 -7
  105. package/src/plugins/builtin/pe-band/pane.tsx +2 -1
  106. package/src/plugins/builtin/pe-band/report-dates.ts +78 -0
  107. package/src/plugins/builtin/perps/client.ts +17 -3
  108. package/src/plugins/builtin/perps/equity-identity.ts +16 -0
  109. package/src/plugins/builtin/perps/equity-row.tsx +6 -4
  110. package/src/plugins/builtin/plugin-marketplace/activation.ts +19 -0
  111. package/src/plugins/builtin/plugin-marketplace/pane.tsx +2 -2
  112. package/src/plugins/builtin/plugin-marketplace/store.ts +2 -0
  113. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +43 -2
  114. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +48 -5
  115. package/src/plugins/builtin/scanner/hilo-bars.tsx +12 -70
  116. package/src/plugins/builtin/scanner/hilo-model.ts +0 -18
  117. package/src/plugins/builtin/sectors/move-bar.tsx +10 -32
  118. package/src/plugins/builtin/social-mentions/index.tsx +1 -1
  119. package/src/plugins/builtin/thirteenf/index.tsx +2 -1
  120. package/src/plugins/builtin/ticker-detail/overview/components.tsx +5 -60
  121. package/src/plugins/builtin/ticker-detail/overview/pane.tsx +1 -1
  122. package/src/plugins/builtin/ticker-detail/pane.tsx +1 -1
  123. package/src/plugins/builtin/ticker-detail/quote-footer.ts +7 -1
  124. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +20 -4
  125. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +2 -2
  126. package/src/plugins/builtin/vol-surface/index.tsx +1 -1
  127. package/src/plugins/catalog-browser.ts +2 -0
  128. package/src/plugins/catalog-manifest.ts +1 -0
  129. package/src/plugins/catalog-ui.ts +3 -1
  130. package/src/plugins/catalog.ts +1 -1
  131. package/src/plugins/host-link.ts +33 -1
  132. package/src/plugins/installer.ts +48 -12
  133. package/src/plugins/loader.ts +18 -3
  134. package/src/plugins/manager-node.ts +15 -5
  135. package/src/plugins/registry/index.ts +1 -1
  136. package/src/plugins/registry/pane-settings.ts +25 -7
  137. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  138. package/src/renderers/dom/data-table/row.tsx +50 -16
  139. package/src/renderers/dom/desktop/icons.tsx +1 -0
  140. package/src/renderers/dom/host/box.tsx +69 -26
  141. package/src/renderers/dom/host/tabs.tsx +37 -1
  142. package/src/renderers/dom/styles.css +40 -5
  143. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +11 -6
  144. package/src/renderers/electrobun/bun/desktop/backend.ts +1 -0
  145. package/src/renderers/electrobun/bun/desktop/update.ts +181 -87
  146. package/src/renderers/electrobun/bun/index.ts +73 -6
  147. package/src/renderers/electrobun/bun/window/remembered-frame.ts +122 -0
  148. package/src/renderers/electrobun/shared/protocol.ts +3 -0
  149. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +2 -1
  150. package/src/renderers/electrobun/view/main.tsx +7 -1
  151. package/src/renderers/electrobun/view/renderer-window-error.ts +18 -0
  152. package/src/renderers/electrobun/view/update-host.ts +45 -21
  153. package/src/sources/gloomberb-cloud/normalizers.ts +9 -2
  154. package/src/sources/provider-router/financials.ts +3 -1
  155. package/src/telemetry/benign-window-error.ts +7 -0
  156. package/src/telemetry/crash-reports-dom.ts +2 -0
  157. package/src/test-support/daily-nav.ts +25 -0
  158. package/src/theme/heat-colors.ts +174 -1
  159. package/src/tickers/instrument-kind.ts +34 -7
  160. package/src/tickers/search/asset-classes.ts +24 -9
  161. package/src/tickers/search/index.ts +44 -11
  162. package/src/tickers/search/ranking.ts +23 -3
  163. package/src/types/financials.ts +2 -0
  164. package/src/types/plugin.ts +18 -5
  165. package/src/ui/context-menu.tsx +1 -0
  166. package/src/ui/host.tsx +2 -0
  167. package/src/updater/index.ts +29 -2
@@ -1,4 +1,4 @@
1
- import { Box, Text, useUiCapabilities } from "../../../ui";
1
+ import { Box, Text, useUiCapabilities, type InputRenderable } from "../../../ui";
2
2
  import { TextAttributes } from "../../../ui";
3
3
  import { useShortcut, useViewport } from "../../../react/input";
4
4
  import { type AlertContext, useDialog, useDialogKeyboard } from "../../../ui/dialog";
@@ -18,6 +18,7 @@ import { isDetailBackNavigationKey } from "../../../utils/back-navigation";
18
18
  import { isPlainKeyboardEvent } from "../../../utils/keyboard";
19
19
  import { ToggleList } from "../../toggle-list";
20
20
  import { Button } from "../button";
21
+ import { TextField } from "../fields";
21
22
  import { listCursorMove } from "../list-view";
22
23
  import { DialogFrame } from "../frame";
23
24
  import { Popover } from "../popover";
@@ -64,6 +65,13 @@ export interface MultiSelectDialogContentProps extends AlertContext {
64
65
  emptyLabel?: string;
65
66
  idPrefix?: string;
66
67
  rowAction?: MultiSelectRowAction;
68
+ /**
69
+ * For a long list: a filter box over the rows (typing narrows them by label
70
+ * and description), each description beside its label and, when ordered,
71
+ * each selection's position at the row's end. The keyboard stays with the
72
+ * box: arrows move, Space picks, [ and ] reorder, Enter is done.
73
+ */
74
+ searchable?: boolean;
67
75
  }
68
76
 
69
77
  export interface MultiSelectDialogButtonProps {
@@ -82,6 +90,8 @@ export interface MultiSelectDialogButtonProps {
82
90
  onOpenChange?: (open: boolean) => void;
83
91
  /** Opens the dialog on desktop too, since the popover menu has no row actions. */
84
92
  rowAction?: MultiSelectRowAction;
93
+ /** A filter box over the rows; opens the dialog on desktop too, since the popover menu has none. */
94
+ searchable?: boolean;
85
95
  }
86
96
 
87
97
  export interface MultiSelectPopoverAnchorPoint {
@@ -118,6 +128,26 @@ function stopMouseEvent(event?: DialogTriggerEvent) {
118
128
  const DESKTOP_ROW_HEIGHT = 1.35;
119
129
  /** Terminal rows the dialog spends around its list: frame, title, buttons. */
120
130
  const TERMINAL_DIALOG_CHROME_ROWS = 10;
131
+ /** Cells a searchable list shows on the desktop and web; the rest scrolls. */
132
+ const DESKTOP_SEARCHABLE_LIST_CELLS = 16;
133
+
134
+ /** A key that types a character: the filter box takes it. */
135
+ function isTextKey(event: { sequence?: string; ctrl?: boolean; meta?: boolean; alt?: boolean }): boolean {
136
+ return event.sequence?.length === 1 && event.sequence >= " " && event.sequence !== "\u007f"
137
+ && !event.ctrl && !event.meta && !event.alt;
138
+ }
139
+
140
+ function matchesFilter(option: MultiSelectOption, needle: string): boolean {
141
+ return `${option.label} ${option.description ?? ""}`.toLowerCase().includes(needle);
142
+ }
143
+
144
+ /** The picks in their order, then every other option in the order given. */
145
+ function selectedFirstOptions(options: readonly MultiSelectOption[], selectedValues: readonly string[]): MultiSelectOption[] {
146
+ const byValue = new Map(options.map((option) => [option.value, option]));
147
+ const picked = selectedValues.flatMap((value) => byValue.get(value) ?? []);
148
+ const chosen = new Set(picked.map((option) => option.value));
149
+ return [...picked, ...options.filter((option) => !chosen.has(option.value))];
150
+ }
121
151
 
122
152
  type FocusedNode = { blur?(): void; closest?(selector: string): unknown };
123
153
 
@@ -219,19 +249,32 @@ export function MultiSelectDialogContent({
219
249
  ordered = false,
220
250
  idPrefix,
221
251
  rowAction,
252
+ searchable = false,
222
253
  }: MultiSelectDialogContentProps) {
223
254
  const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
224
255
  const optionByValue = useMemo(() => new Map(options.map((option) => [option.value, option])), [options]);
225
256
  const [selectedValues, setSelectedValues] = useState(() => normalizeDialogSelectedValues(options, selectedValuesProp, ordered));
226
257
  const [displayValues, setDisplayValues] = useState(() => getMultiSelectDisplayValues(options, selectedValuesProp, ordered));
227
258
  const knownSelectedValues = selectedValues.filter((value) => optionByValue.has(value));
259
+ // A long ordered list reads as the list being built on top, the rest below;
260
+ // a short one keeps every option in its row and swaps the picks among them.
261
+ const selectedFirst = searchable && ordered;
228
262
  const displayOptions = useMemo(
229
- () => orderMultiSelectOptionsForDisplay(options, displayValues),
230
- [displayValues, options],
263
+ () => (selectedFirst
264
+ ? selectedFirstOptions(options, selectedValues)
265
+ : orderMultiSelectOptionsForDisplay(options, displayValues)),
266
+ [displayValues, options, selectedFirst, selectedValues],
267
+ );
268
+ const [filter, setFilter] = useState("");
269
+ const filterRef = useRef<InputRenderable | null>(null);
270
+ const needle = searchable ? filter.trim().toLowerCase() : "";
271
+ const visibleOptions = useMemo(
272
+ () => (needle ? displayOptions.filter((option) => matchesFilter(option, needle)) : displayOptions),
273
+ [displayOptions, needle],
231
274
  );
232
275
  const [selectedOptionId, setSelectedOptionId] = useState(options[0]?.value ?? "");
233
- const selectedIndex = Math.max(0, displayOptions.findIndex((option) => option.value === selectedOptionId));
234
- const selectedOption = displayOptions[selectedIndex];
276
+ const selectedIndex = Math.max(0, visibleOptions.findIndex((option) => option.value === selectedOptionId));
277
+ const selectedOption = visibleOptions[selectedIndex];
235
278
  const selectedOptionValue = selectedOption?.value ?? "";
236
279
  const selectedValueOrder = knownSelectedValues.indexOf(selectedOptionValue);
237
280
  const canMoveUp = ordered && selectedValueOrder > 0;
@@ -258,7 +301,14 @@ export function MultiSelectDialogContent({
258
301
  setSelectedOptionId(displayOptions[0]?.value ?? "");
259
302
  }, [displayOptions, selectedOptionId]);
260
303
 
261
- const toggleItems = displayOptions.map((option) => {
304
+ // A touch screen would raise its keyboard over the list the moment it opens.
305
+ useEffect(() => {
306
+ if (!searchable || (globalThis as { matchMedia?: (query: string) => { matches: boolean } }).matchMedia?.("(pointer: coarse)").matches) return;
307
+ const timer = setTimeout(() => filterRef.current?.focus?.(), 0);
308
+ return () => clearTimeout(timer);
309
+ }, [searchable]);
310
+
311
+ const toggleItems = visibleOptions.map((option) => {
262
312
  const order = knownSelectedValues.indexOf(option.value);
263
313
  const orderDescription = ordered && order >= 0
264
314
  ? `Order ${order + 1} of ${knownSelectedValues.length}.`
@@ -269,15 +319,19 @@ export function MultiSelectDialogContent({
269
319
  label: labelOverrides[option.value] ?? option.label,
270
320
  disabled: option.disabled,
271
321
  enabled: selectedValues.includes(option.value),
272
- description: [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
322
+ description: searchable
323
+ ? option.description
324
+ : [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
325
+ detail: searchable && ordered && order >= 0 ? String(order + 1) : undefined,
273
326
  };
274
327
  });
275
328
  const viewport = useViewport();
276
329
  // A short terminal would clip rows under the dialog's edge: the list scrolls
277
- // in whatever height is left.
330
+ // in whatever height is left. The height follows the whole list, not the rows
331
+ // the filter leaves, so typing does not make the dialog jump.
278
332
  const listHeight = isDesktopWeb
279
- ? Math.min(12, Math.max(5, displayOptions.length * DESKTOP_ROW_HEIGHT))
280
- : Math.max(3, Math.min(12, Math.max(6, toggleItems.length), viewport.height - TERMINAL_DIALOG_CHROME_ROWS));
333
+ ? Math.min(searchable ? DESKTOP_SEARCHABLE_LIST_CELLS : 12, Math.max(5, displayOptions.length * DESKTOP_ROW_HEIGHT))
334
+ : Math.max(3, Math.min(12, Math.max(6, displayOptions.length), viewport.height - TERMINAL_DIALOG_CHROME_ROWS - (searchable ? 2 : 0)));
281
335
  const pageSize = Math.floor(listHeight / (isDesktopWeb ? DESKTOP_ROW_HEIGHT : 1)) - 1;
282
336
 
283
337
  const applySelectedValues = async (nextValues: string[], nextDisplayValues = displayValues) => {
@@ -296,16 +350,23 @@ export function MultiSelectDialogContent({
296
350
 
297
351
  const toggleOption = async (option: MultiSelectOption | undefined) => {
298
352
  if (!option || option.disabled) return;
299
- await applySelectedValues(ordered
353
+ const nextValues = ordered
300
354
  ? toggleOrderedMultiSelectValue(options, selectedValues, option.value)
301
- : toggleMultiSelectValue(options, selectedValues, option.value));
355
+ : toggleMultiSelectValue(options, selectedValues, option.value);
356
+ const row = visibleOptions.findIndex((entry) => entry.value === option.value);
357
+ await applySelectedValues(nextValues);
358
+ if (!selectedFirst) return;
359
+ // The row moved between the picks and the rest. The cursor stays on its
360
+ // line, so Space picks or drops a row and moves on to the next.
361
+ const next = selectedFirstOptions(options, nextValues).filter((entry) => !needle || matchesFilter(entry, needle));
362
+ setSelectedOptionId(next[Math.min(Math.max(row, 0), next.length - 1)]?.value ?? option.value);
302
363
  };
303
364
 
304
365
  const moveOption = async (direction: "up" | "down") => {
305
366
  if (!ordered || !selectedOption) return;
306
367
  await applySelectedValues(
307
368
  moveMultiSelectValue(options, selectedValues, selectedOption.value, direction),
308
- moveMultiSelectDisplayValue(displayValues, knownSelectedValues, selectedOption.value, direction),
369
+ selectedFirst ? displayValues : moveMultiSelectDisplayValue(displayValues, knownSelectedValues, selectedOption.value, direction),
309
370
  );
310
371
  };
311
372
 
@@ -318,11 +379,24 @@ export function MultiSelectDialogContent({
318
379
  };
319
380
 
320
381
  useDialogKeyboard((event) => {
382
+ if (searchable) {
383
+ // The box owns every key that types or edits text; the list keeps the rest.
384
+ const typing = event.targetEditable === true;
385
+ const editsText = isTextKey(event) ? event.sequence !== " " && event.name !== "[" && event.name !== "]"
386
+ : typing && ["backspace", "delete", "left", "right", "home", "end"].includes(event.name ?? "");
387
+ const backsUp = !typing && filter !== "" && (event.name === "backspace" || event.name === "delete");
388
+ if (editsText || backsUp) {
389
+ // A click on a row or button took the focus; the first key typed brings it back.
390
+ if (!typing) filterRef.current?.focus?.();
391
+ return;
392
+ }
393
+ if (isSpaceKey(event)) event.preventDefault();
394
+ }
321
395
  event.stopPropagation();
322
396
  const move = listCursorMove(event, pageSize);
323
397
  if (move) {
324
- if (isDesktopWeb) releaseFocusOutsideList();
325
- setSelectedOptionId(displayOptions[move(displayOptions, selectedIndex)]?.value ?? selectedOptionId);
398
+ if (isDesktopWeb && !searchable) releaseFocusOutsideList();
399
+ setSelectedOptionId(visibleOptions[move(visibleOptions, selectedIndex)]?.value ?? selectedOptionId);
326
400
  } else if (isSpaceKey(event)) {
327
401
  void toggleOption(selectedOption).catch(() => {});
328
402
  } else if (event.name === "[" && ordered) {
@@ -334,15 +408,24 @@ export function MultiSelectDialogContent({
334
408
  } else if (event.name === "enter" || event.name === "return" || event.name === "escape" || isDetailBackNavigationKey(event)) {
335
409
  dismiss();
336
410
  }
337
- }, dialogId);
411
+ }, searchable ? { scope: dialogId, allowEditable: true } : dialogId);
338
412
 
339
413
  return (
340
414
  <DialogFrame title={title} showTitleDivider={!isDesktopWeb}>
341
415
  <Box
342
416
  flexDirection="column"
343
417
  gap={1}
344
- style={isDesktopWeb ? { minWidth: 520 } : undefined}
418
+ style={isDesktopWeb ? { minWidth: "min(520px, calc(100vw - 96px))" } : undefined}
345
419
  >
420
+ {searchable && (
421
+ <TextField
422
+ inputRef={filterRef}
423
+ value={filter}
424
+ placeholder="Filter"
425
+ focused
426
+ onChange={setFilter}
427
+ />
428
+ )}
346
429
  <ToggleList
347
430
  items={toggleItems}
348
431
  selectedIdx={selectedIndex}
@@ -350,11 +433,12 @@ export function MultiSelectDialogContent({
350
433
  height={listHeight}
351
434
  scrollable
352
435
  showSelectedDescription={false}
436
+ showDescriptions={searchable}
353
437
  rowIdPrefix={idPrefix ? `${idPrefix}:option` : undefined}
354
438
  rowGap={isDesktopWeb ? 0 : undefined}
355
439
  rowHeight={isDesktopWeb ? DESKTOP_ROW_HEIGHT : undefined}
356
440
  surface={isDesktopWeb ? "plain" : undefined}
357
- onSelect={(index) => setSelectedOptionId(displayOptions[index]?.value ?? selectedOptionId)}
441
+ onSelect={(index) => setSelectedOptionId(visibleOptions[index]?.value ?? selectedOptionId)}
358
442
  onToggle={(id) => {
359
443
  setSelectedOptionId(id);
360
444
  void toggleOption(optionByValue.get(id)).catch(() => {});
@@ -364,7 +448,7 @@ export function MultiSelectDialogContent({
364
448
  flexDirection="row"
365
449
  gap={1}
366
450
  justifyContent={isDesktopWeb ? "flex-end" : undefined}
367
- style={isDesktopWeb ? { paddingTop: 6 } : undefined}
451
+ style={isDesktopWeb ? { paddingTop: 6, flexWrap: "wrap" } : undefined}
368
452
  >
369
453
  {ordered && (
370
454
  <>
@@ -403,6 +487,7 @@ function MultiSelectDialogButtonInner({
403
487
  shortcutActive = false,
404
488
  onOpenChange,
405
489
  rowAction,
490
+ searchable = false,
406
491
  }: MultiSelectDialogButtonProps, ref: ForwardedRef<MultiSelectDialogButtonHandle>) {
407
492
  const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
408
493
  const dialog = useDialog();
@@ -415,7 +500,7 @@ function MultiSelectDialogButtonInner({
415
500
  const openDialog = useCallback((event?: DialogTriggerEvent, anchorPoint?: MultiSelectPopoverAnchorPoint) => {
416
501
  stopMouseEvent(event);
417
502
  if (disabled) return;
418
- if (isDesktopWeb && !ordered && !rowAction) {
503
+ if (isDesktopWeb && !ordered && !rowAction && !searchable) {
419
504
  setPopoverAnchorPoint(anchorPoint ?? null);
420
505
  setPopoverOpen(true);
421
506
  onOpenChange?.(true);
@@ -435,10 +520,11 @@ function MultiSelectDialogButtonInner({
435
520
  emptyLabel={emptyLabel}
436
521
  idPrefix={idPrefix}
437
522
  rowAction={rowAction}
523
+ searchable={searchable}
438
524
  />
439
525
  ),
440
526
  }).catch(() => {}).finally(() => onOpenChange?.(false));
441
- }, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, rowAction, selectedValues, title]);
527
+ }, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, rowAction, searchable, selectedValues, title]);
442
528
 
443
529
  const closePopover = useCallback(() => {
444
530
  setPopoverOpen(false);
@@ -521,7 +607,7 @@ function MultiSelectDialogButtonInner({
521
607
  );
522
608
  }
523
609
 
524
- if (isDesktopWeb && !ordered && !rowAction) {
610
+ if (isDesktopWeb && !ordered && !rowAction && !searchable) {
525
611
  return (
526
612
  <Popover
527
613
  open={popoverOpen}
@@ -0,0 +1,46 @@
1
+ import { useThemeColors } from "../../theme/theme-context";
2
+ import { Box, Text, useUiCapabilities } from "../../ui";
3
+
4
+ export interface RangeTrackProps {
5
+ /** Where the value sits between the low and the high, 0 to 1. */
6
+ position: number;
7
+ /** Cells the track spans. */
8
+ width: number;
9
+ markerColor: string;
10
+ }
11
+
12
+ /**
13
+ * Where a value sits between a low and a high (a day range, a 52-week range):
14
+ * a rule with a dot on it. The caller draws the endpoints. The terminal draws
15
+ * a box-drawing rule; the desktop draws a real line and dot, never glyphs.
16
+ */
17
+ export function RangeTrack({ position, width, markerColor }: RangeTrackProps) {
18
+ const colors = useThemeColors();
19
+ const isDesktopWeb = useUiCapabilities().nativePaneChrome === true;
20
+ const at = position > 0 ? Math.min(1, position) : 0;
21
+ if (width <= 0) return null;
22
+ if (isDesktopWeb) {
23
+ return (
24
+ <Box width={width} height={1} flexShrink={0} style={{ position: "relative", justifyContent: "center" }} data-gloom-role="range-track">
25
+ <Box style={{ position: "absolute", left: 0, right: 0, height: "2px", borderRadius: "1px", backgroundColor: colors.border }} />
26
+ <Box style={{
27
+ position: "absolute",
28
+ left: `${at * 100}%`,
29
+ width: "8px",
30
+ height: "8px",
31
+ marginLeft: "-4px",
32
+ borderRadius: "50%",
33
+ backgroundColor: markerColor,
34
+ }} />
35
+ </Box>
36
+ );
37
+ }
38
+ const marker = Math.min(width - 1, Math.round(at * (width - 1)));
39
+ return (
40
+ <Box width={width} height={1} flexShrink={0} flexDirection="row">
41
+ <Text fg={colors.border}>{"─".repeat(marker)}</Text>
42
+ <Text fg={markerColor}>●</Text>
43
+ <Text fg={colors.border}>{"─".repeat(width - marker - 1)}</Text>
44
+ </Box>
45
+ );
46
+ }
@@ -43,6 +43,8 @@ export interface TabsProps {
43
43
  addLabel?: string;
44
44
  onAdd?: () => void;
45
45
  onReorder?: (fromValue: string, toValue: string) => void;
46
+ /** A press that moves, on a tab that is not reordered. Desktop title-bar tabs use it to move the window. */
47
+ onDrag?: () => void;
46
48
  focused?: boolean;
47
49
  keyboardNavigation?: boolean;
48
50
  /**
@@ -67,6 +69,7 @@ export function Tabs({
67
69
  addLabel = "+",
68
70
  onAdd,
69
71
  onReorder,
72
+ onDrag,
70
73
  focused = false,
71
74
  keyboardNavigation = true,
72
75
  paneMenu = true,
@@ -229,6 +232,7 @@ export function Tabs({
229
232
  addLabel={addLabel}
230
233
  onAdd={onAdd}
231
234
  onReorder={onReorder}
235
+ onDrag={onDrag}
232
236
  focused={focused}
233
237
  palette={palette}
234
238
  />
@@ -45,10 +45,18 @@ interface CommandBarRunQueryLaunchRequest extends CommandBarLaunchRequestBase {
45
45
  query: string;
46
46
  }
47
47
 
48
+ /** The add row opened the bar so the chosen venue joins this collection. */
49
+ interface CommandBarAddListingLaunchRequest extends CommandBarLaunchRequestBase {
50
+ kind: "add-listing";
51
+ collectionId: string;
52
+ collectionKind: "watchlist" | "portfolio";
53
+ }
54
+
48
55
  /** Forms open in their own modal (`openFormModal`), so the bar launches only its own screens. */
49
56
  type CommandBarLaunch =
50
57
  | { kind: "ticker-search"; query?: string }
51
- | { kind: "run-query"; query: string };
58
+ | { kind: "run-query"; query: string }
59
+ | { kind: "add-listing"; collectionId: string; collectionKind: "watchlist" | "portfolio" };
52
60
 
53
61
  export interface AppState {
54
62
  config: AppConfig;
@@ -74,6 +82,7 @@ export interface AppState {
74
82
  commandBarLaunchRequest:
75
83
  | CommandBarTickerSearchLaunchRequest
76
84
  | CommandBarRunQueryLaunchRequest
85
+ | CommandBarAddListingLaunchRequest
77
86
  | null;
78
87
  themePreview: string | null;
79
88
  refreshing: Set<string>;
package/src/i18n/es.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * fixed-width terminal layouts stay readable.
6
6
  */
7
7
  export const es: Record<string, string> = {
8
+ "NAV · as of {date}": "NAV · al {date}",
8
9
  // ── Common UI ────────────────────────────────────────────────
9
10
  "Back": "Atrás",
10
11
  "Done": "Listo",
@@ -377,7 +378,6 @@ export const es: Record<string, string> = {
377
378
  "Price trigger alerts with notifications": "Alertas de precio con notificaciones",
378
379
  "Create a price alert from a symbol, condition, and target price": "Crear una alerta de precio con símbolo, condición y precio objetivo",
379
380
  "Public market heatmap for the largest US stocks and ETFs.": "Mapa de calor de las mayores acciones y ETF de EE. UU.",
380
- "Largest US stocks and ETFs, sized by market cap or assets and colored by daily move.": "Mayores acciones y ETF de EE. UU., dimensionados por capitalización o activos y coloreados por variación diaria.",
381
381
  "Top gainers, losers, most active, and trending tickers": "Mayores alzas, bajas, más activos y tickers en tendencia",
382
382
  "Top gainers, losers, most active, and trending tickers.": "Mayores alzas, bajas, más activos y tickers en tendencia.",
383
383
  "S&P 500 sector and industry performance via ETF proxies": "Rendimiento sectorial e industrial del S&P 500 vía ETF",
@@ -524,7 +524,7 @@ export const es: Record<string, string> = {
524
524
  "Quit the terminal app.": "Salir de la aplicación de terminal.",
525
525
  "Copy the active terminal selection.": "Copiar la selección activa del terminal.",
526
526
  "Paste clipboard text into the active input.": "Pegar el portapapeles en la entrada activa.",
527
- "Install an available app update when one is shown.": "Instalar una actualización disponible cuando se muestre.",
527
+ "Restart into a downloaded app update, or retry one that failed.": "Reinicia con una actualización descargada o reintenta una fallida.",
528
528
  "Close the focused pane, docked or floating.": "Cerrar el panel activo, anclado o flotante.",
529
529
  "Close all floating panes.": "Cerrar todos los paneles flotantes.",
530
530
  "Edit settings for the focused pane.": "Editar los ajustes del panel activo.",
@@ -943,7 +943,9 @@ export const es: Record<string, string> = {
943
943
 
944
944
  // ── Main dashboard ──────────────────────────────────────────
945
945
  "Installing update...": "Instalando actualización...",
946
- "Update installed, restart to apply": "Actualización instalada; reinicia para aplicar",
946
+ "Update ready, restart to apply": "Actualización lista; reinicia para aplicar",
947
+ "Update ready": "Actualización lista",
948
+ "Restart": "Reiniciar",
947
949
  "Checking for updates...": "Buscando actualizaciones...",
948
950
  "Downloading v{version}: {percent}%": "Descargando v{version}: {percent}%",
949
951
  "Update failed: {error}": "Error al actualizar: {error}",
package/src/i18n/ja.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * fixed-width terminal layouts stay readable.
6
6
  */
7
7
  export const ja: Record<string, string> = {
8
+ "NAV · as of {date}": "NAV · {date}時点",
8
9
  // ── Common UI ────────────────────────────────────────────────
9
10
  "Back": "戻る",
10
11
  "Done": "完了",
@@ -378,7 +379,6 @@ export const ja: Record<string, string> = {
378
379
  "Price trigger alerts with notifications": "通知付きの価格トリガーアラート",
379
380
  "Create a price alert from a symbol, condition, and target price": "銘柄コード、条件、目標価格から価格アラートを作成",
380
381
  "Public market heatmap for the largest US stocks and ETFs.": "米国大型株と ETF の市場ヒートマップ。",
381
- "Largest US stocks and ETFs, sized by market cap or assets and colored by daily move.": "米国大型株と ETF を時価総額・資産規模で表示し、日次騰落率で色分けします。",
382
382
  "Top gainers, losers, most active, and trending tickers": "値上がり、値下がり、出来高、注目銘柄",
383
383
  "Top gainers, losers, most active, and trending tickers.": "値上がり、値下がり、出来高、注目銘柄。",
384
384
  "S&P 500 sector and industry performance via ETF proxies": "ETF を使った S&P 500 のセクター・業種別騰落率",
@@ -525,7 +525,7 @@ export const ja: Record<string, string> = {
525
525
  "Quit the terminal app.": "ターミナル アプリを終了します。",
526
526
  "Copy the active terminal selection.": "アクティブな端末の選択をコピーします。",
527
527
  "Paste clipboard text into the active input.": "クリップボードのテキストをアクティブな入力に貼り付けます。",
528
- "Install an available app update when one is shown.": "利用可能なアプリのアップデートが表示されたら、それをインストールします。",
528
+ "Restart into a downloaded app update, or retry one that failed.": "ダウンロード済みのアップデートで再起動するか、失敗したアップデートを再試行します。",
529
529
  "Close the focused pane, docked or floating.": "フォーカスされたペインをドッキングまたはフローティング状態で閉じます。",
530
530
  "Close all floating panes.": "すべてのフローティング ペインを閉じます。",
531
531
  "Edit settings for the focused pane.": "フォーカスされたペインの設定を編集します。",
@@ -957,7 +957,9 @@ export const ja: Record<string, string> = {
957
957
 
958
958
  // ── Main dashboard ──────────────────────────────────────────
959
959
  "Installing update...": "アップデートをインストール中...",
960
- "Update installed, restart to apply": "アップデートがインストールされました。適用するには再起動してください",
960
+ "Update ready, restart to apply": "アップデートの準備ができました。適用するには再起動してください",
961
+ "Update ready": "アップデートの準備完了",
962
+ "Restart": "再起動",
961
963
  "Checking for updates...": "アップデートを確認中...",
962
964
  "Downloading v{version}: {percent}%": "v{version} をダウンロード中: {percent}%",
963
965
  "Update failed: {error}": "更新に失敗しました: {error}",
package/src/i18n/ko.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Keep finance terminology precise and translations concise for dense layouts.
4
4
  */
5
5
  export const ko: Record<string, string> = {
6
+ "NAV · as of {date}": "NAV · {date} 기준",
6
7
  // ── Common UI ────────────────────────────────────────────────
7
8
  "Back": "뒤로",
8
9
  "Done": "완료",
@@ -376,7 +377,6 @@ export const ko: Record<string, string> = {
376
377
  "Price trigger alerts with notifications": "알림이 있는 목표가 알림",
377
378
  "Create a price alert from a symbol, condition, and target price": "종목코드, 조건 및 목표가로 가격 알림 생성",
378
379
  "Public market heatmap for the largest US stocks and ETFs.": "미국 대형주 및 ETF 시장 히트맵.",
379
- "Largest US stocks and ETFs, sized by market cap or assets and colored by daily move.": "미국 대형주와 ETF를 시가총액·자산 규모 및 일일 변동률로 표시.",
380
380
  "Top gainers, losers, most active, and trending tickers": "상승률·하락률·거래량 상위 및 인기 종목",
381
381
  "Top gainers, losers, most active, and trending tickers.": "상승률·하락률·거래량 상위 및 인기 종목.",
382
382
  "S&P 500 sector and industry performance via ETF proxies": "ETF 기준 S&P 500 섹터 및 산업 성과",
@@ -523,7 +523,7 @@ export const ko: Record<string, string> = {
523
523
  "Quit the terminal app.": "터미널 앱을 종료합니다.",
524
524
  "Copy the active terminal selection.": "활성 터미널 선택을 복사합니다.",
525
525
  "Paste clipboard text into the active input.": "클립보드 텍스트를 활성 입력에 붙여넣습니다.",
526
- "Install an available app update when one is shown.": "사용 가능한 앱 업데이트가 표시되면 설치하세요.",
526
+ "Restart into a downloaded app update, or retry one that failed.": "다운로드한 업데이트로 다시 시작하거나, 실패한 업데이트를 다시 시도합니다.",
527
527
  "Close the focused pane, docked or floating.": "도킹 또는 플로팅된 포커스 패널을 닫습니다.",
528
528
  "Close all floating panes.": "모든 플로팅 패널을 닫습니다.",
529
529
  "Edit settings for the focused pane.": "포커스 패널 설정을 편집합니다.",
@@ -955,7 +955,9 @@ export const ko: Record<string, string> = {
955
955
 
956
956
  // ── Main dashboard ──────────────────────────────────────────
957
957
  "Installing update...": "업데이트 설치 중...",
958
- "Update installed, restart to apply": "업데이트 설치 완료. 적용하려면 다시 시작하세요",
958
+ "Update ready, restart to apply": "업데이트 준비 완료. 적용하려면 다시 시작하세요",
959
+ "Update ready": "업데이트 준비 완료",
960
+ "Restart": "다시 시작",
959
961
  "Checking for updates...": "업데이트 확인 중...",
960
962
  "Downloading v{version}: {percent}%": "v{version} 다운로드 중: {percent}%",
961
963
  "Update failed: {error}": "업데이트 실패: {error}",
package/src/i18n/zh-cn.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * fixed-width terminal layouts stay readable.
6
6
  */
7
7
  export const zhCN: Record<string, string> = {
8
+ "NAV · as of {date}": "NAV · 截至{date}",
8
9
  // ── Common UI ────────────────────────────────────────────────
9
10
  "Back": "返回",
10
11
  "Done": "完成",
@@ -378,7 +379,6 @@ export const zhCN: Record<string, string> = {
378
379
  "Price trigger alerts with notifications": "带通知的价格触发提醒",
379
380
  "Create a price alert from a symbol, condition, and target price": "用代码、条件和目标价创建价格提醒",
380
381
  "Public market heatmap for the largest US stocks and ETFs.": "美股大盘股与 ETF 的市场热力图。",
381
- "Largest US stocks and ETFs, sized by market cap or assets and colored by daily move.": "按市值/规模定大小、按日涨跌着色的美股与 ETF。",
382
382
  "Top gainers, losers, most active, and trending tickers": "涨幅榜、跌幅榜、成交活跃与热门股票",
383
383
  "Top gainers, losers, most active, and trending tickers.": "涨幅榜、跌幅榜、成交活跃与热门股票。",
384
384
  "S&P 500 sector and industry performance via ETF proxies": "以 ETF 代理呈现的标普 500 板块与行业表现",
@@ -525,7 +525,7 @@ export const zhCN: Record<string, string> = {
525
525
  "Quit the terminal app.": "退出终端应用。",
526
526
  "Copy the active terminal selection.": "复制当前终端选中内容。",
527
527
  "Paste clipboard text into the active input.": "把剪贴板文本粘贴到当前输入框。",
528
- "Install an available app update when one is shown.": "有可用更新提示时安装更新。",
528
+ "Restart into a downloaded app update, or retry one that failed.": "使用已下载的更新重启,或重试失败的更新。",
529
529
  "Close the focused pane, docked or floating.": "关闭聚焦面板(停靠或浮动)。",
530
530
  "Close all floating panes.": "关闭所有浮动面板。",
531
531
  "Edit settings for the focused pane.": "编辑聚焦面板的设置。",
@@ -957,7 +957,9 @@ export const zhCN: Record<string, string> = {
957
957
 
958
958
  // ── Main dashboard ──────────────────────────────────────────
959
959
  "Installing update...": "正在安装更新...",
960
- "Update installed, restart to apply": "更新已安装,重启后生效",
960
+ "Update ready, restart to apply": "更新已就绪,重启后生效",
961
+ "Update ready": "更新已就绪",
962
+ "Restart": "重启",
961
963
  "Checking for updates...": "正在检查更新...",
962
964
  "Downloading v{version}: {percent}%": "正在下载 v{version}:{percent}%",
963
965
  "Update failed: {error}": "更新失败:{error}",
package/src/i18n/zh-tw.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * fixed-width terminal layouts stay readable.
6
6
  */
7
7
  export const zhTW: Record<string, string> = {
8
+ "NAV · as of {date}": "NAV · 截至{date}",
8
9
  // ── Common UI ────────────────────────────────────────────────
9
10
  "Back": "返回",
10
11
  "Done": "完成",
@@ -378,7 +379,6 @@ export const zhTW: Record<string, string> = {
378
379
  "Price trigger alerts with notifications": "透過通知的價格觸發提醒",
379
380
  "Create a price alert from a symbol, condition, and target price": "用代號、條件和目標價建立價格提醒",
380
381
  "Public market heatmap for the largest US stocks and ETFs.": "美股大盤股與 ETF 的市場熱力圖。",
381
- "Largest US stocks and ETFs, sized by market cap or assets and colored by daily move.": "按市值/規模定大小、按日漲跌著色的美股與 ETF。",
382
382
  "Top gainers, losers, most active, and trending tickers": "漲幅榜、跌幅榜、成交活躍與熱門股票",
383
383
  "Top gainers, losers, most active, and trending tickers.": "漲幅榜、跌幅榜、成交活躍與熱門股票。",
384
384
  "S&P 500 sector and industry performance via ETF proxies": "以 ETF 代表 S&P 500 類股與產業表現",
@@ -525,7 +525,7 @@ export const zhTW: Record<string, string> = {
525
525
  "Quit the terminal app.": "結束終端機應用程式。",
526
526
  "Copy the active terminal selection.": "複製目前終端機選取內容。",
527
527
  "Paste clipboard text into the active input.": "將剪貼簿文字貼到目前的輸入欄位。",
528
- "Install an available app update when one is shown.": "有可用更新提示時安裝更新。",
528
+ "Restart into a downloaded app update, or retry one that failed.": "使用已下載的更新重新啟動,或重試失敗的更新。",
529
529
  "Close the focused pane, docked or floating.": "關閉聚焦面板(停靠或浮動)。",
530
530
  "Close all floating panes.": "關閉所有浮動面板。",
531
531
  "Edit settings for the focused pane.": "編輯聚焦面板的設定。",
@@ -957,7 +957,9 @@ export const zhTW: Record<string, string> = {
957
957
 
958
958
  // ── Main dashboard ──────────────────────────────────────────
959
959
  "Installing update...": "正在安裝更新...",
960
- "Update installed, restart to apply": "更新已安裝,重新啟動後生效",
960
+ "Update ready, restart to apply": "更新已就緒,重新啟動後生效",
961
+ "Update ready": "更新已就緒",
962
+ "Restart": "重新啟動",
961
963
  "Checking for updates...": "正在檢查更新...",
962
964
  "Downloading v{version}: {percent}%": "正在下載 v{version}:{percent}%",
963
965
  "Update failed: {error}": "更新失敗:{error}",
@@ -22,8 +22,8 @@ export function listVisibleTickerSourcePanes(
22
22
  }
23
23
 
24
24
  /**
25
- * Whether the pane menu may link this pane to a ticker source (`PaneDef.tickerFollower`). A source
26
- * never follows: its own cursor symbol would win over the binding.
25
+ * Whether the pane menu may link this pane to another pane's ticker (`PaneDef.tickerFollower`).
26
+ * A source never follows: its own cursor symbol would win over the binding.
27
27
  */
28
28
  export function canFollowTickerSource(
29
29
  instance: PaneInstanceConfig,
@@ -34,6 +34,32 @@ export function canFollowTickerSource(
34
34
  return typeof def.tickerFollower === "function" ? def.tickerFollower(instance) : def.tickerFollower === true;
35
35
  }
36
36
 
37
+ /**
38
+ * A single-ticker pane other panes can follow. Lists and scanners stay sources; Ticker Research
39
+ * follows the same rule as any pane flagged `tickerFollower`.
40
+ */
41
+ export function isTickerLinkPeer(
42
+ instance: PaneInstanceConfig,
43
+ panes: ReadonlyMap<string, PaneDef>,
44
+ ): boolean {
45
+ return instance.paneId === TICKER_RESEARCH_PANE_ID || canFollowTickerSource(instance, panes);
46
+ }
47
+
48
+ /** True when `fromInstanceId`'s follow chain reaches `targetInstanceId`. */
49
+ export function followBindingReaches(
50
+ layout: LayoutConfig,
51
+ fromInstanceId: string,
52
+ targetInstanceId: string,
53
+ seen = new Set<string>(),
54
+ ): boolean {
55
+ if (seen.has(fromInstanceId)) return false;
56
+ seen.add(fromInstanceId);
57
+ const instance = findPaneInstance(layout, fromInstanceId);
58
+ if (instance?.binding?.kind !== "follow") return false;
59
+ const next = instance.binding.sourceInstanceId;
60
+ return next === targetInstanceId || followBindingReaches(layout, next, targetInstanceId, seen);
61
+ }
62
+
37
63
  /** The visible Ticker Research pane that follows `sourceInstanceId`, if any. */
38
64
  export function findTickerResearchFollower(
39
65
  layout: LayoutConfig,
@@ -152,6 +152,12 @@ export function mergeQuoteContribution(
152
152
  }
153
153
  if (!current) return next;
154
154
 
155
+ // NAV responses are complete dated observations. Never carry an earlier
156
+ // date or intraday session fields into one, or its marker into a later tick.
157
+ if (current.priceObservation != null || next.priceObservation != null) {
158
+ return { ...next, name: next.name ?? current.name };
159
+ }
160
+
155
161
  const priorBasis = resolvePriceBasis(current.priceBasis, current.instrumentType);
156
162
  const samePriceBasis = priorBasis !== null && priorBasis === resolvePriceBasis(next.priceBasis, next.instrumentType || current.instrumentType);
157
163
  const merged: QuoteContribution = {