gloomberb 0.7.3 → 0.7.5

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +59 -30
  3. package/src/components/chart/chart-renderer.ts +109 -32
  4. package/src/components/chart/chart-types.ts +18 -0
  5. package/src/components/chart/chart.test.ts +152 -5
  6. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  7. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  8. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  9. package/src/components/chart/market-session.ts +187 -0
  10. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  11. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  12. package/src/components/chart/stock-chart.tsx +36 -8
  13. package/src/components/chart/time-axis-label.tsx +56 -2
  14. package/src/components/command-bar/command-bar.test.tsx +1 -1
  15. package/src/components/command-bar/command-bar.tsx +26 -2
  16. package/src/components/command-bar/list-model.ts +8 -4
  17. package/src/components/command-bar/view-model.test.ts +20 -0
  18. package/src/components/command-bar/view-model.ts +15 -3
  19. package/src/components/layout/shell.test.tsx +26 -0
  20. package/src/components/layout/shell.tsx +69 -21
  21. package/src/components/ticker-list-table.tsx +4 -15
  22. package/src/core/state/session-persistence.ts +25 -0
  23. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  24. package/src/market-data/coordinator.test.ts +122 -2
  25. package/src/market-data/coordinator.ts +192 -55
  26. package/src/market-data/hooks.test.tsx +31 -0
  27. package/src/market-data/hooks.tsx +1 -0
  28. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  29. package/src/plugins/builtin/chat-controller.ts +8 -7
  30. package/src/plugins/builtin/chat.test.tsx +55 -0
  31. package/src/plugins/builtin/chat.tsx +20 -6
  32. package/src/plugins/builtin/holders/index.test.ts +30 -1
  33. package/src/plugins/builtin/holders/index.tsx +7 -3
  34. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  35. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  36. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  37. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  38. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  39. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  40. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  41. package/src/plugins/builtin/sectors/index.tsx +30 -9
  42. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  43. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  44. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  45. package/src/plugins/pane-manager.test.ts +31 -0
  46. package/src/plugins/pane-manager.ts +32 -6
  47. package/src/renderers/electrobun/bun/index.ts +83 -3
  48. package/src/renderers/electrobun/shared/protocol.ts +8 -1
  49. package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
  50. package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
  51. package/src/renderers/electrobun/view/main.tsx +38 -24
  52. package/src/renderers/electrobun/view/styles.css +48 -4
  53. package/src/sources/gloomberb-cloud.test.ts +55 -2
  54. package/src/sources/gloomberb-cloud.ts +180 -29
  55. package/src/sources/provider-router.test.ts +567 -10
  56. package/src/sources/provider-router.ts +323 -93
  57. package/src/sources/yahoo-finance.test.ts +37 -0
  58. package/src/sources/yahoo-finance.ts +48 -2
  59. package/src/state/app-bootstrap.test.ts +50 -23
  60. package/src/state/app-bootstrap.ts +26 -10
  61. package/src/state/session-persistence.test.ts +5 -3
  62. package/src/state/use-inline-tickers.ts +3 -0
  63. package/src/state/use-quote-streaming.ts +11 -0
  64. package/src/types/data-provider.ts +5 -1
  65. package/src/types/financials.ts +1 -0
  66. package/src/utils/api-client.test.ts +120 -0
  67. package/src/utils/api-client.ts +66 -19
  68. package/src/utils/exchanges.test.ts +16 -0
  69. package/src/utils/exchanges.ts +33 -0
  70. package/src/utils/options.ts +1 -1
  71. package/src/utils/price-history.test.ts +28 -1
  72. package/src/utils/price-history.ts +16 -0
  73. package/src/utils/quote-freshness.test.ts +150 -0
  74. package/src/utils/quote-freshness.ts +42 -1
  75. package/src/utils/quote-resolution.ts +2 -0
  76. package/src/utils/quote-time.test.ts +34 -0
  77. package/src/utils/quote-time.ts +18 -5
  78. package/src/utils/ticker-search.ts +72 -1
  79. package/src/version.ts +1 -1
  80. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -73,8 +73,10 @@ import {
73
73
  movePaneRelative,
74
74
  removePane,
75
75
  swapPanes,
76
+ type LayoutBounds,
76
77
  } from "../../plugins/pane-manager";
77
78
  import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
79
+ import { resolveAppHeaderHeightCells } from "../layout/shell";
78
80
  import { CHART_RENDERER_PREFERENCES } from "../chart/chart-types";
79
81
  import {
80
82
  getEmptyState,
@@ -168,6 +170,7 @@ interface CommandBarProps {
168
170
  pluginRegistry: PluginRegistry;
169
171
  quitApp: () => void;
170
172
  onCheckForUpdates?: () => void | Promise<void>;
173
+ onNativeOccluderChange?: (rect: LayoutBounds | null) => void;
171
174
  }
172
175
 
173
176
  type WorkflowStringValues = Record<string, string>;
@@ -645,6 +648,7 @@ export function CommandBar({
645
648
  pluginRegistry,
646
649
  quitApp,
647
650
  onCheckForUpdates,
651
+ onNativeOccluderChange,
648
652
  }: CommandBarProps) {
649
653
  const dispatch = useAppDispatch();
650
654
  const themeColors = useThemeColors();
@@ -709,7 +713,7 @@ export function CommandBar({
709
713
  }, []);
710
714
  const { symbol: activeTickerSymbol, ticker: activeTickerData, financials: activeFinancials } = useFocusedTicker();
711
715
  const { width: termWidth, height: termHeight } = useViewport();
712
- const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18 } = useUiCapabilities();
716
+ const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18, titleBarOverlay } = useUiCapabilities();
713
717
  const availableCommands = useMemo(
714
718
  () => nativePaneChrome
715
719
  ? commands.filter((command) => command.id !== "cycle-chart-renderer")
@@ -4000,7 +4004,11 @@ export function CommandBar({
4000
4004
  rootResultModel.items,
4001
4005
  rootTickerSearchArg,
4002
4006
  ]);
4003
- const orderedRootResults = useMemo(() => orderListResults(rootResults), [rootResults]);
4007
+ const rootSectionOrder = rootPlainTickerSearchArg ? "app-first" : "default";
4008
+ const orderedRootResults = useMemo(
4009
+ () => orderListResults(rootResults, { sectionOrder: rootSectionOrder }),
4010
+ [rootResults, rootSectionOrder],
4011
+ );
4004
4012
  const activeRootProviderResultsKey = useMemo(() => {
4005
4013
  if (!rootTickerSearchArg || rootProviderResultsQuery !== rootTickerSearchArg || !rootProviderResults) return null;
4006
4014
  return [
@@ -4316,6 +4324,7 @@ export function CommandBar({
4316
4324
  emptyDetail: emptyState.detail,
4317
4325
  footerLeft: getScreenFooterLeft(null),
4318
4326
  footerRight: getScreenFooterRight(null),
4327
+ sectionOrder: rootSectionOrder,
4319
4328
  };
4320
4329
  }
4321
4330
 
@@ -4463,6 +4472,7 @@ export function CommandBar({
4463
4472
  rootModeInfo.kind,
4464
4473
  rootQuery,
4465
4474
  orderedRootResults,
4475
+ rootSectionOrder,
4466
4476
  rootSearching,
4467
4477
  rootSelectedIdx,
4468
4478
  rootShortcutIntent,
@@ -5384,6 +5394,13 @@ export function CommandBar({
5384
5394
  : bodyHeight + 7;
5385
5395
  const barLeft = Math.max(4, Math.floor((termWidth - barWidth) / 2));
5386
5396
  const barTop = Math.max(1, Math.floor((termHeight - barHeight) / 2));
5397
+ const appHeaderHeight = resolveAppHeaderHeightCells({ titleBarOverlay, cellHeightPx });
5398
+ const nativeOccluderRect = useMemo<LayoutBounds>(() => ({
5399
+ x: barLeft,
5400
+ y: barTop - appHeaderHeight,
5401
+ width: barWidth,
5402
+ height: barHeight,
5403
+ }), [appHeaderHeight, barHeight, barLeft, barTop, barWidth]);
5387
5404
  const resultsInnerWidth = Math.max(12, barWidth - nativePanelPaddingColumns - contentPadding * 2);
5388
5405
  const trailingWidth = Math.max(8, Math.min(12, Math.floor(resultsInnerWidth * 0.18)));
5389
5406
  const labelWidth = Math.max(10, resultsInnerWidth - trailingWidth);
@@ -5413,6 +5430,13 @@ export function CommandBar({
5413
5430
  }
5414
5431
  }, [listBodyHeight, selectedScrollRowIndex, visibleListState?.kind, visibleListState?.query]);
5415
5432
 
5433
+ useLayoutEffect(() => {
5434
+ onNativeOccluderChange?.(nativeOccluderRect);
5435
+ return () => {
5436
+ onNativeOccluderChange?.(null);
5437
+ };
5438
+ }, [nativeOccluderRect, onNativeOccluderChange]);
5439
+
5416
5440
  useEffect(() => {
5417
5441
  if (!nativePaneChrome || currentRoute?.kind !== "workflow") return;
5418
5442
  const scrollBox = workflowScrollRef.current;
@@ -1,4 +1,4 @@
1
- import { buildSections } from "./view-model";
1
+ import { buildSections, type CommandBarSectionOrder } from "./view-model";
2
2
 
3
3
  export interface ResultItem {
4
4
  id: string;
@@ -31,6 +31,7 @@ export interface ListScreenState {
31
31
  emptyDetail: string;
32
32
  footerLeft: string;
33
33
  footerRight: string;
34
+ sectionOrder?: CommandBarSectionOrder;
34
35
  }
35
36
 
36
37
  export type CommandBarListRow =
@@ -41,13 +42,16 @@ export type CommandBarListRow =
41
42
  | { kind: "spinner"; id: string; label: string }
42
43
  | { kind: "filler"; id: string };
43
44
 
44
- export function orderListResults(results: ResultItem[]): ResultItem[] {
45
- return buildSections(results).flatMap((section) => section.items);
45
+ export function orderListResults(
46
+ results: ResultItem[],
47
+ options?: { sectionOrder?: CommandBarSectionOrder },
48
+ ): ResultItem[] {
49
+ return buildSections(results, options).flatMap((section) => section.items);
46
50
  }
47
51
 
48
52
  export function buildListRows(listState: ListScreenState): CommandBarListRow[] {
49
53
  const rows: CommandBarListRow[] = [];
50
- const sections = buildSections(listState.results);
54
+ const sections = buildSections(listState.results, { sectionOrder: listState.sectionOrder });
51
55
  let globalIdx = 0;
52
56
  sections.forEach((section, sectionIndex) => {
53
57
  if (sectionIndex > 0) {
@@ -47,6 +47,26 @@ describe("command bar view model helpers", () => {
47
47
  expect(sections.map((section) => section.category)).toEqual(["Tickers", "Config", "Danger", "Debug"]);
48
48
  });
49
49
 
50
+ test("keeps provider ticker suggestions behind app sections in app-first order", () => {
51
+ const sections = buildSections([
52
+ { id: "pane", category: "Panes" },
53
+ { id: "primary", category: "Primary Listing" },
54
+ { id: "other", category: "Other Listings" },
55
+ { id: "fund", category: "Funds & Derivatives" },
56
+ { id: "saved", category: "Saved" },
57
+ { id: "exact", category: "Exact Match" },
58
+ ], { sectionOrder: "app-first" });
59
+
60
+ expect(sections.map((section) => section.category)).toEqual([
61
+ "Exact Match",
62
+ "Saved",
63
+ "Panes",
64
+ "Primary Listing",
65
+ "Other Listings",
66
+ "Funds & Derivatives",
67
+ ]);
68
+ });
69
+
50
70
  test("derives row presentation for toggles and current rows", () => {
51
71
  expect(getRowPresentation({
52
72
  id: "plugin:news",
@@ -15,6 +15,8 @@ export interface CommandBarSection<T> {
15
15
  items: T[];
16
16
  }
17
17
 
18
+ export type CommandBarSectionOrder = "default" | "app-first";
19
+
18
20
  export interface CommandBarItemView {
19
21
  id: string;
20
22
  label: string;
@@ -59,7 +61,10 @@ export function resolveCommandBarMode(query: string, commandList?: Command[]): C
59
61
  }
60
62
  }
61
63
 
62
- export function buildSections<T extends { category: string }>(items: T[]): Array<CommandBarSection<T>> {
64
+ export function buildSections<T extends { category: string }>(
65
+ items: T[],
66
+ options?: { sectionOrder?: CommandBarSectionOrder },
67
+ ): Array<CommandBarSection<T>> {
63
68
  const sections: Array<CommandBarSection<T>> = [];
64
69
  for (const item of items) {
65
70
  let section = sections.find((candidate) => candidate.category === item.category);
@@ -72,7 +77,9 @@ export function buildSections<T extends { category: string }>(items: T[]): Array
72
77
  return sections
73
78
  .map((section, index) => ({ section, index }))
74
79
  .sort((a, b) => {
75
- const priorityDiff = getCategoryPriority(a.section.category) - getCategoryPriority(b.section.category);
80
+ const leftPriority = getCategoryPriority(a.section.category, options?.sectionOrder);
81
+ const rightPriority = getCategoryPriority(b.section.category, options?.sectionOrder);
82
+ const priorityDiff = leftPriority - rightPriority;
76
83
  return priorityDiff !== 0 ? priorityDiff : a.index - b.index;
77
84
  })
78
85
  .map(({ section }) => section);
@@ -126,10 +133,15 @@ export function truncateText(text: string, width: number): string {
126
133
  return `${text.slice(0, width - 3)}...`;
127
134
  }
128
135
 
129
- function getCategoryPriority(category: string): number {
136
+ function getCategoryPriority(category: string, sectionOrder: CommandBarSectionOrder = "default"): number {
130
137
  const normalized = category.trim().toLowerCase();
131
138
  if (normalized === "exact match") return -50;
132
139
  if (normalized === "saved") return -40;
140
+ if (sectionOrder === "app-first") {
141
+ if (normalized === "primary listing") return 100;
142
+ if (normalized === "other listings") return 110;
143
+ if (normalized === "funds & derivatives") return 120;
144
+ }
133
145
  if (normalized === "primary listing") return -30;
134
146
  if (normalized === "other listings") return -20;
135
147
  if (normalized === "funds & derivatives") return -10;
@@ -560,6 +560,32 @@ describe("Shell", () => {
560
560
  ]);
561
561
  });
562
562
 
563
+ test("keeps command bar native occlusion scoped to the panel", () => {
564
+ const state = buildNativeWindowState(
565
+ ["portfolio-list:main"],
566
+ [],
567
+ null,
568
+ { open: false, width: 120, contentHeight: 40 },
569
+ [
570
+ {
571
+ id: "command-bar:panel",
572
+ rect: { x: 24, y: 8, width: 72, height: 14 },
573
+ zIndex: Number.MAX_SAFE_INTEGER,
574
+ },
575
+ ],
576
+ );
577
+
578
+ expect(state.occluders).toEqual([
579
+ {
580
+ id: "command-bar:panel",
581
+ paneId: null,
582
+ rect: { x: 24, y: 9, width: 72, height: 14 },
583
+ zIndex: Number.MAX_SAFE_INTEGER,
584
+ },
585
+ ]);
586
+ expect(state.occluders.some((occluder) => occluder.id === "overlay:global")).toBe(false);
587
+ });
588
+
563
589
  test("adds dock dividers as global native occluders", () => {
564
590
  const state = buildNativeWindowState(
565
591
  ["left:main", "right:main"],
@@ -76,6 +76,7 @@ interface ShellProps {
76
76
  pluginRegistry: PluginRegistry;
77
77
  desktopWindowBridge?: DesktopWindowBridge;
78
78
  desktopDockPreview?: DesktopDockPreviewState | null;
79
+ commandBarNativeOccluder?: LayoutBounds | null;
79
80
  }
80
81
 
81
82
  interface HoverOverlay {
@@ -192,6 +193,7 @@ const DEFAULT_HEADER_HEIGHT = 1;
192
193
  const MENU_MIN_WIDTH = 18;
193
194
  const MENU_MAX_WIDTH = 44;
194
195
  const MENU_Z_INDEX = 10_000;
196
+ const DOCK_DIVIDER_SIZE = 1;
195
197
  const PANE_DRAG_THRESHOLD = 2;
196
198
  const PRECISE_PANE_DRAG_THRESHOLD = 0.15;
197
199
  const PANE_MANAGEMENT_ACCELERATORS = {
@@ -498,6 +500,39 @@ export function resolveNativeDockDividers(
498
500
  ));
499
501
  }
500
502
 
503
+ function resolveDividerPreviewRect(
504
+ axis: "horizontal" | "vertical",
505
+ bounds: LayoutBounds,
506
+ ratio: number,
507
+ nativePaneChrome: boolean,
508
+ ): LayoutBounds {
509
+ if (axis === "horizontal") {
510
+ const offset = nativePaneChrome
511
+ ? bounds.width * ratio
512
+ : bounds.width > DOCK_DIVIDER_SIZE
513
+ ? Math.round((bounds.width - DOCK_DIVIDER_SIZE) * ratio)
514
+ : Math.max(0, Math.round(bounds.width * ratio) - DOCK_DIVIDER_SIZE);
515
+ return {
516
+ x: nativePaneChrome ? bounds.x + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.x + offset,
517
+ y: bounds.y,
518
+ width: DOCK_DIVIDER_SIZE,
519
+ height: bounds.height,
520
+ };
521
+ }
522
+
523
+ const offset = nativePaneChrome
524
+ ? bounds.height * ratio
525
+ : bounds.height > DOCK_DIVIDER_SIZE
526
+ ? Math.round((bounds.height - DOCK_DIVIDER_SIZE) * ratio)
527
+ : Math.max(0, Math.round(bounds.height * ratio) - DOCK_DIVIDER_SIZE);
528
+ return {
529
+ x: bounds.x,
530
+ y: nativePaneChrome ? bounds.y + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.y + offset,
531
+ width: bounds.width,
532
+ height: DOCK_DIVIDER_SIZE,
533
+ };
534
+ }
535
+
501
536
  export function finalizePaneDragRelease(
502
537
  layout: LayoutConfig,
503
538
  paneId: string,
@@ -789,7 +824,12 @@ function resolveExternalDockPreview(
789
824
  }
790
825
  }
791
826
 
792
- export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview }: ShellProps) {
827
+ export function Shell({
828
+ pluginRegistry,
829
+ desktopWindowBridge,
830
+ desktopDockPreview,
831
+ commandBarNativeOccluder = null,
832
+ }: ShellProps) {
793
833
  useThemeColors();
794
834
  const dispatch = useAppDispatch();
795
835
  const config = useAppSelector((state) => state.config);
@@ -1047,7 +1087,9 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1047
1087
  event.stopPropagation();
1048
1088
  });
1049
1089
 
1050
- const dockGeometryOptions = useMemo(() => (nativePaneChrome ? { precise: true } : undefined), [nativePaneChrome]);
1090
+ const dockGeometryOptions = useMemo(() => (
1091
+ nativePaneChrome ? { precise: true } : { reserveDividerGutters: true }
1092
+ ), [nativePaneChrome]);
1051
1093
  const dockLeafLayouts = useMemo(() => getDockLeafLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
1052
1094
  const dockDividerLayouts = useMemo(() => getDockDividerLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
1053
1095
  const snapGuides = useMemo(() => makeSnapGuides(width, contentHeight), [contentHeight, width]);
@@ -1092,8 +1134,29 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1092
1134
  });
1093
1135
  }
1094
1136
 
1137
+ if (menuState) {
1138
+ occluders.push({
1139
+ id: `pane-menu:${menuState.paneId}`,
1140
+ rect: {
1141
+ x: menuState.x,
1142
+ y: menuState.y,
1143
+ width: menuState.width,
1144
+ height: menuState.items.length + 2,
1145
+ },
1146
+ zIndex: MENU_Z_INDEX,
1147
+ });
1148
+ }
1149
+
1150
+ if (commandBarNativeOccluder) {
1151
+ occluders.push({
1152
+ id: "command-bar:panel",
1153
+ rect: commandBarNativeOccluder,
1154
+ zIndex: Number.MAX_SAFE_INTEGER,
1155
+ });
1156
+ }
1157
+
1095
1158
  return occluders;
1096
- }, [activeHoverOverlay, activePaneDrag, dragFloatingRect, effectiveDockPreview]);
1159
+ }, [activeHoverOverlay, activePaneDrag, commandBarNativeOccluder, dragFloatingRect, effectiveDockPreview, menuState]);
1097
1160
  const nativeDockDividers = useMemo(
1098
1161
  () => resolveNativeDockDividers(dockDividerLayouts, dividerPreview),
1099
1162
  [dividerPreview, dockDividerLayouts],
@@ -1107,12 +1170,12 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1107
1170
  zIndex: pane.floating?.zIndex ?? 50,
1108
1171
  })),
1109
1172
  dragFloatingRect,
1110
- { open: overlayOpen, width, contentHeight },
1173
+ { open: dialogOpen, width, contentHeight },
1111
1174
  nativeTransientOccluders,
1112
1175
  nativeDockDividers,
1113
1176
  appHeaderHeight,
1114
1177
  ),
1115
- [appHeaderHeight, contentHeight, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders, overlayOpen, visibleFloatingPanes, width],
1178
+ [appHeaderHeight, contentHeight, dialogOpen, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders, visibleFloatingPanes, width],
1116
1179
  );
1117
1180
 
1118
1181
  useEffect(() => {
@@ -1194,22 +1257,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
1194
1257
  const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
1195
1258
  const delta = drag.axis === "horizontal" ? preciseX - drag.startX : preciseShellY - drag.startY;
1196
1259
  const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
1197
- const offset = drag.axis === "horizontal"
1198
- ? drag.bounds.width * nextRatio
1199
- : drag.bounds.height * nextRatio;
1200
- const nextRect = drag.axis === "horizontal"
1201
- ? {
1202
- x: nativePaneChrome ? drag.bounds.x + offset - 0.5 : drag.bounds.x + Math.round(offset) - 1,
1203
- y: drag.bounds.y,
1204
- width: 1,
1205
- height: drag.bounds.height,
1206
- }
1207
- : {
1208
- x: drag.bounds.x,
1209
- y: nativePaneChrome ? drag.bounds.y + offset - 0.5 : drag.bounds.y + Math.round(offset) - 1,
1210
- width: drag.bounds.width,
1211
- height: 1,
1212
- };
1260
+ const nextRect = resolveDividerPreviewRect(drag.axis, drag.bounds, nextRatio, nativePaneChrome);
1213
1261
  updateDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
1214
1262
  } else if (drag.type === "pane-drag") {
1215
1263
  if (!isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold)) {
@@ -56,17 +56,6 @@ const FLASHABLE_QUOTE_COLUMN_IDS = new Set([
56
56
  "pnl_pct",
57
57
  ]);
58
58
 
59
- function resolveQuoteFlashColor(direction: QuoteFlashDirection, fallbackColor: string): string {
60
- switch (direction) {
61
- case "up":
62
- return colors.positive;
63
- case "down":
64
- return colors.negative;
65
- default:
66
- return fallbackColor === colors.textDim ? colors.text : colors.textBright;
67
- }
68
- }
69
-
70
59
  export interface TickerListTableProps {
71
60
  columns: ColumnConfig[];
72
61
  tickers: TickerRecord[];
@@ -239,13 +228,13 @@ const TickerListRow = memo(function TickerListRow({
239
228
  const { text, color } = resolveCell(column, ticker, financials);
240
229
  const baseFg = color || (isSelected ? colors.selectedText : colors.text);
241
230
  const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
242
- const cellFg = shouldFlash
243
- ? resolveQuoteFlashColor(flashDirection, baseFg)
244
- : baseFg;
245
231
 
246
232
  return (
247
233
  <Box key={column.id} width={column.width + 1}>
248
- <Text fg={cellFg}>
234
+ <Text
235
+ fg={baseFg}
236
+ attributes={shouldFlash ? TextAttributes.DIM : TextAttributes.NONE}
237
+ >
249
238
  {padTo(text, column.width, column.align)}
250
239
  </Text>
251
240
  </Box>
@@ -73,6 +73,23 @@ function targetFromTicker(ticker: TickerRecord): HydrationTarget {
73
73
  });
74
74
  }
75
75
 
76
+ function resolvePortfolioPaneCollectionId(
77
+ config: AppConfig,
78
+ paneState: Record<string, unknown> | undefined,
79
+ instance: AppConfig["layout"]["instances"][number],
80
+ ): string | null {
81
+ const stateCollectionId = typeof paneState?.collectionId === "string" ? paneState.collectionId : null;
82
+ if (stateCollectionId) return stateCollectionId;
83
+ if (typeof instance.params?.collectionId === "string") return instance.params.collectionId;
84
+ return config.portfolios[0]?.id ?? config.watchlists[0]?.id ?? null;
85
+ }
86
+
87
+ function tickerInCollection(ticker: TickerRecord, collectionId: string | null): boolean {
88
+ if (!collectionId) return false;
89
+ return ticker.metadata.portfolios.includes(collectionId)
90
+ || ticker.metadata.watchlists.includes(collectionId);
91
+ }
92
+
76
93
  export function buildAppSessionSnapshot(state: SessionStateInput): AppSessionSnapshot {
77
94
  const seen = new Set<string>();
78
95
  const hydrationTargets: HydrationTarget[] = [];
@@ -104,6 +121,14 @@ export function buildAppSessionSnapshot(state: SessionStateInput): AppSessionSna
104
121
  if (instance.binding?.kind === "fixed") {
105
122
  pushTarget(state.tickers.get(instance.binding.symbol));
106
123
  }
124
+ if (instance.paneId === "portfolio-list") {
125
+ const collectionId = resolvePortfolioPaneCollectionId(state.config, paneState, instance);
126
+ for (const ticker of state.tickers.values()) {
127
+ if (tickerInCollection(ticker, collectionId)) {
128
+ pushTarget(ticker);
129
+ }
130
+ }
131
+ }
107
132
  }
108
133
 
109
134
  return {
@@ -43,6 +43,79 @@ async function flushCoordinator(): Promise<void> {
43
43
  }
44
44
 
45
45
  describe("MarketDataCoordinator key subscriptions", () => {
46
+ test("subscribes active quote targets as one provider batch", () => {
47
+ const subscriptions: QuoteSubscriptionTarget[][] = [];
48
+ const provider = createTestDataProvider({
49
+ id: "test-provider",
50
+ subscribeQuotes: (targets) => {
51
+ subscriptions.push(targets);
52
+ return () => {};
53
+ },
54
+ });
55
+ const coordinator = new MarketDataCoordinator(provider);
56
+
57
+ coordinator.subscribeQuotes([
58
+ {
59
+ instrument: { symbol: "AAPL", exchange: "NASDAQ" },
60
+ priority: { surface: "portfolio", visible: true, selected: true, weight: 100 },
61
+ },
62
+ { instrument: { symbol: "MSFT", exchange: "NASDAQ" } },
63
+ ]);
64
+
65
+ expect(subscriptions).toHaveLength(1);
66
+ expect(subscriptions[0]?.map((target) => target.symbol).sort()).toEqual(["AAPL", "MSFT"]);
67
+ expect(subscriptions[0]?.find((target) => target.symbol === "AAPL")).toMatchObject({
68
+ surface: "portfolio",
69
+ visible: true,
70
+ selected: true,
71
+ weight: 100,
72
+ });
73
+ });
74
+
75
+ test("keeps the highest-priority target when duplicate surfaces subscribe", () => {
76
+ const subscriptions: QuoteSubscriptionTarget[][] = [];
77
+ let disposals = 0;
78
+ const provider = createTestDataProvider({
79
+ id: "test-provider",
80
+ subscribeQuotes: (targets) => {
81
+ subscriptions.push(targets);
82
+ return () => {
83
+ disposals += 1;
84
+ };
85
+ },
86
+ });
87
+ const coordinator = new MarketDataCoordinator(provider);
88
+ const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
89
+
90
+ const unsubscribeDetail = coordinator.subscribeQuotes([{
91
+ instrument,
92
+ priority: { surface: "detail", visible: true, selected: true, weight: 100 },
93
+ }]);
94
+ coordinator.subscribeQuotes([{
95
+ instrument,
96
+ priority: { surface: "portfolio", visible: false, selected: false, weight: 10 },
97
+ }]);
98
+
99
+ expect(subscriptions).toHaveLength(1);
100
+ expect(subscriptions[0]?.[0]).toMatchObject({
101
+ surface: "detail",
102
+ visible: true,
103
+ selected: true,
104
+ weight: 100,
105
+ });
106
+
107
+ unsubscribeDetail();
108
+
109
+ expect(subscriptions).toHaveLength(2);
110
+ expect(disposals).toBe(1);
111
+ expect(subscriptions[1]?.[0]).toMatchObject({
112
+ surface: "portfolio",
113
+ visible: false,
114
+ selected: false,
115
+ weight: 10,
116
+ });
117
+ });
118
+
46
119
  test("notifies listeners for changed keys only", async () => {
47
120
  const { provider, emitQuote } = createProvider();
48
121
  const coordinator = new MarketDataCoordinator(provider);
@@ -97,29 +170,40 @@ describe("MarketDataCoordinator key subscriptions", () => {
97
170
  expect(coordinator.getVersion()).toBe(1);
98
171
  });
99
172
 
100
- test("ignores repeated stream quotes that only refresh timestamp noise", async () => {
173
+ test("applies repeated stream quotes that refresh quote freshness", async () => {
174
+ const realDateNow = Date.now;
101
175
  const { provider, emitQuote } = createProvider();
102
176
  const coordinator = new MarketDataCoordinator(provider);
103
177
  const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
104
178
  let calls = 0;
105
179
 
106
- coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { calls += 1; });
107
- coordinator.subscribeQuotes([{ instrument: aapl }]);
108
-
109
180
  const firstTimestamp = 1_700_000_000_000;
110
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp }));
111
- await flushCoordinator();
112
-
113
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp + 10_000 }));
114
- await flushCoordinator();
115
-
116
- expect(calls).toBe(1);
117
- expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp);
118
-
119
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 101, { lastUpdated: firstTimestamp + 20_000 }));
120
- await flushCoordinator();
121
-
122
- expect(calls).toBe(2);
123
- expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(101);
181
+ try {
182
+ coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { calls += 1; });
183
+ coordinator.subscribeQuotes([{ instrument: aapl }]);
184
+
185
+ Date.now = () => firstTimestamp;
186
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp }));
187
+ await flushCoordinator();
188
+
189
+ Date.now = () => firstTimestamp + 10_000;
190
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp + 10_000 }));
191
+ await flushCoordinator();
192
+
193
+ expect(calls).toBe(2);
194
+ expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp + 10_000);
195
+ expect(coordinator.getQuoteEntry(aapl).data?.receivedAt).toBe(firstTimestamp + 10_000);
196
+
197
+ Date.now = () => firstTimestamp + 20_000;
198
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 101, { lastUpdated: firstTimestamp + 10_000 }));
199
+ await flushCoordinator();
200
+
201
+ expect(calls).toBe(3);
202
+ expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(101);
203
+ expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp + 10_000);
204
+ expect(coordinator.getQuoteEntry(aapl).data?.receivedAt).toBe(firstTimestamp + 20_000);
205
+ } finally {
206
+ Date.now = realDateNow;
207
+ }
124
208
  });
125
209
  });