gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import type { DataProvider, SecFilingItem } from "../types/data-provider";
1
+ import type { CachedFinancialsTarget, DataProvider, QuoteSubscriptionTarget, SecFilingItem } from "../types/data-provider";
2
2
  import type { NewsArticle } from "../news/types";
3
3
  import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../types/financials";
4
4
  import type { ChartRequest, InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest } from "./request-types";
@@ -39,6 +39,7 @@ const SEC_FILINGS_CACHE_TTL_MS = 10 * 60_000;
39
39
  const SEC_CONTENT_CACHE_TTL_MS = 24 * 60 * 60_000;
40
40
  const ARTICLE_SUMMARY_CACHE_TTL_MS = 24 * 60 * 60_000;
41
41
  const FX_CACHE_TTL_MS = 30 * 60_000;
42
+ const QUOTE_SUBSCRIPTION_REMOVE_GRACE_MS = 250;
42
43
 
43
44
  function createBaselineChartRequest(instrument: InstrumentRef): ChartRequest {
44
45
  return {
@@ -223,6 +224,13 @@ export class MarketDataCoordinator {
223
224
  private readonly keyVersions = new Map<string, number>();
224
225
  private readonly inFlight = new Map<string, Promise<unknown>>();
225
226
  private readonly chartRequests = new Map<string, ChartRequest>();
227
+ private readonly quoteSubscriptions = new Map<string, {
228
+ target: QuoteSubscriptionTarget;
229
+ refCount: number;
230
+ unsubscribe: (() => void) | null;
231
+ removeTimer: ReturnType<typeof setTimeout> | null;
232
+ }>();
233
+ private pendingQuoteSubscriptionKeys = new Set<string>();
226
234
 
227
235
  private readonly quoteStore = new QueryStore<Quote>((key) => this.bump(key));
228
236
  private readonly snapshotStore = new QueryStore<TickerFinancials>((key) => this.bump(key));
@@ -427,6 +435,63 @@ export class MarketDataCoordinator {
427
435
  return promise;
428
436
  }
429
437
 
438
+ private cachedFinancialsTargetFromInstrument(instrument: InstrumentRef): CachedFinancialsTarget {
439
+ return {
440
+ symbol: instrument.symbol,
441
+ exchange: instrument.exchange,
442
+ brokerId: instrument.brokerId,
443
+ brokerInstanceId: instrument.brokerInstanceId,
444
+ instrument: instrument.instrument ?? null,
445
+ };
446
+ }
447
+
448
+ private quoteTargetFromInstrument(instrument: InstrumentRef): QuoteSubscriptionTarget {
449
+ return {
450
+ symbol: instrument.symbol,
451
+ exchange: instrument.exchange ?? "",
452
+ context: toMarketDataContext(instrument),
453
+ };
454
+ }
455
+
456
+ private storeSnapshotData(
457
+ instrument: InstrumentRef,
458
+ data: TickerFinancials,
459
+ source: string,
460
+ attempts: ProviderAttempt[],
461
+ ): QueryEntry<TickerFinancials> {
462
+ const normalized = normalizeTickerFinancialsPriceHistory(data);
463
+ const snapshotKey = buildSnapshotKey(instrument);
464
+ const entry = this.snapshotStore.update(snapshotKey, (current) =>
465
+ readyEntry(current, normalized, source, attempts, { keepLastGoodOnEmpty: true })
466
+ );
467
+ if (normalized.quote) {
468
+ this.quoteStore.set(buildQuoteKey(instrument), readyEntry(this.getQuoteEntry(instrument), normalized.quote, normalized.quote.providerId ?? source, attempts));
469
+ }
470
+ this.profileStore.set(buildProfileKey(instrument), readyEntry(this.profileStore.get(buildProfileKey(instrument)), normalized.profile ?? null, source, attempts, { keepLastGoodOnEmpty: true }));
471
+ this.fundamentalsStore.set(buildFundamentalsKey(instrument), readyEntry(this.fundamentalsStore.get(buildFundamentalsKey(instrument)), normalized.fundamentals ?? null, source, attempts, { keepLastGoodOnEmpty: true }));
472
+ this.statementsStore.set(
473
+ buildStatementsKey(instrument),
474
+ readyEntry(
475
+ this.statementsStore.get(buildStatementsKey(instrument)),
476
+ {
477
+ annualStatements: normalized.annualStatements ?? [],
478
+ quarterlyStatements: normalized.quarterlyStatements ?? [],
479
+ },
480
+ source,
481
+ attempts,
482
+ { keepLastGoodOnEmpty: true },
483
+ ),
484
+ );
485
+ if ((normalized.priceHistory ?? []).length > 0) {
486
+ const chartRequest = createBaselineChartRequest(instrument);
487
+ this.chartStore.set(
488
+ buildChartKey(chartRequest),
489
+ readyEntry(this.getChartEntry(chartRequest), normalized.priceHistory, source, attempts, { keepLastGoodOnEmpty: true }),
490
+ );
491
+ }
492
+ return entry;
493
+ }
494
+
430
495
  private findChartSeedEntry(
431
496
  key: string,
432
497
  request: ChartRequest,
@@ -485,42 +550,17 @@ export class MarketDataCoordinator {
485
550
  const startedAt = Date.now();
486
551
  traceMarketData("snapshot:start", { key, symbol: instrument.symbol, exchange: instrument.exchange ?? "" });
487
552
  try {
488
- const data = normalizeTickerFinancialsPriceHistory(await this.dataProvider.getTickerFinancials(
553
+ const data = await this.dataProvider.getTickerFinancials(
489
554
  instrument.symbol,
490
555
  instrument.exchange ?? "",
491
556
  {
492
557
  ...toMarketDataContext(instrument),
493
558
  cacheMode: options.forceRefresh ? "refresh" : "default",
494
559
  },
495
- ));
560
+ );
496
561
  const source = data.quote?.providerId ?? this.dataProvider.id;
497
562
  const attempts = [createAttempt(source, startedAt, data ? "success" : "empty")];
498
- const entry = this.snapshotStore.update(key, (current) => readyEntry(current, data, source, attempts, { keepLastGoodOnEmpty: true }));
499
- if (data.quote) {
500
- this.quoteStore.set(buildQuoteKey(instrument), readyEntry(this.getQuoteEntry(instrument), data.quote, data.quote.providerId ?? source, attempts));
501
- }
502
- this.profileStore.set(buildProfileKey(instrument), readyEntry(this.profileStore.get(buildProfileKey(instrument)), data.profile ?? null, source, attempts, { keepLastGoodOnEmpty: true }));
503
- this.fundamentalsStore.set(buildFundamentalsKey(instrument), readyEntry(this.fundamentalsStore.get(buildFundamentalsKey(instrument)), data.fundamentals ?? null, source, attempts, { keepLastGoodOnEmpty: true }));
504
- this.statementsStore.set(
505
- buildStatementsKey(instrument),
506
- readyEntry(
507
- this.statementsStore.get(buildStatementsKey(instrument)),
508
- {
509
- annualStatements: data.annualStatements ?? [],
510
- quarterlyStatements: data.quarterlyStatements ?? [],
511
- },
512
- source,
513
- attempts,
514
- { keepLastGoodOnEmpty: true },
515
- ),
516
- );
517
- if ((data.priceHistory ?? []).length > 0) {
518
- const chartRequest = createBaselineChartRequest(instrument);
519
- this.chartStore.set(
520
- buildChartKey(chartRequest),
521
- readyEntry(this.getChartEntry(chartRequest), data.priceHistory, source, attempts, { keepLastGoodOnEmpty: true }),
522
- );
523
- }
563
+ const entry = this.storeSnapshotData(instrument, data, source, attempts);
524
564
  traceMarketData("snapshot:ready", {
525
565
  key,
526
566
  symbol: instrument.symbol,
@@ -538,6 +578,49 @@ export class MarketDataCoordinator {
538
578
  });
539
579
  }
540
580
 
581
+ async loadSnapshotsBatch(
582
+ instruments: InstrumentRef[],
583
+ options: { forceRefresh?: boolean } = {},
584
+ ): Promise<QueryEntry<TickerFinancials>[]> {
585
+ const uniqueInstruments = [...new Map(instruments.map((instrument) => [buildSnapshotKey(instrument), instrument] as const)).values()];
586
+ const results = new Map<string, QueryEntry<TickerFinancials>>();
587
+ const misses: InstrumentRef[] = [];
588
+
589
+ for (const instrument of uniqueInstruments) {
590
+ const key = buildSnapshotKey(instrument);
591
+ const current = this.snapshotStore.get(key);
592
+ if (!options.forceRefresh && hasFreshEntryData(current, SNAPSHOT_CACHE_TTL_MS)) {
593
+ results.set(key, current);
594
+ } else {
595
+ misses.push(instrument);
596
+ }
597
+ }
598
+
599
+ if (misses.length > 0 && this.dataProvider.getTickerFinancialsBatch) {
600
+ const batchResults = await this.dataProvider.getTickerFinancialsBatch(
601
+ misses.map((instrument) => this.cachedFinancialsTargetFromInstrument(instrument)),
602
+ { forceRefresh: options.forceRefresh },
603
+ );
604
+ batchResults.forEach((item, index) => {
605
+ const instrument = misses[index];
606
+ if (!instrument) return;
607
+ const key = buildSnapshotKey(instrument);
608
+ if (!item.financials) return;
609
+ const source = item.financials.quote?.providerId ?? this.dataProvider.id;
610
+ const attempts = [createAttempt(source, Date.now(), "success")];
611
+ results.set(key, this.storeSnapshotData(instrument, item.financials, source, attempts));
612
+ });
613
+ }
614
+
615
+ await Promise.all(misses.map(async (instrument) => {
616
+ const key = buildSnapshotKey(instrument);
617
+ if (results.has(key)) return;
618
+ results.set(key, await this.loadSnapshot(instrument, options));
619
+ }));
620
+
621
+ return instruments.map((instrument) => results.get(buildSnapshotKey(instrument)) ?? this.getSnapshotEntry(instrument));
622
+ }
623
+
541
624
  async loadQuote(
542
625
  instrument: InstrumentRef,
543
626
  options: { forceRefresh?: boolean } = {},
@@ -567,6 +650,48 @@ export class MarketDataCoordinator {
567
650
  });
568
651
  }
569
652
 
653
+ async loadQuotesBatch(
654
+ instruments: InstrumentRef[],
655
+ options: { forceRefresh?: boolean } = {},
656
+ ): Promise<QueryEntry<Quote>[]> {
657
+ const uniqueInstruments = [...new Map(instruments.map((instrument) => [buildQuoteKey(instrument), instrument] as const)).values()];
658
+ const results = new Map<string, QueryEntry<Quote>>();
659
+ const misses: InstrumentRef[] = [];
660
+
661
+ for (const instrument of uniqueInstruments) {
662
+ const key = buildQuoteKey(instrument);
663
+ const current = this.quoteStore.get(key);
664
+ if (!options.forceRefresh && hasFreshEntryData(current, SNAPSHOT_CACHE_TTL_MS)) {
665
+ results.set(key, current);
666
+ } else {
667
+ misses.push(instrument);
668
+ }
669
+ }
670
+
671
+ if (misses.length > 0 && this.dataProvider.getQuotesBatch) {
672
+ const batchResults = await this.dataProvider.getQuotesBatch(
673
+ misses.map((instrument) => this.quoteTargetFromInstrument(instrument)),
674
+ { forceRefresh: options.forceRefresh },
675
+ );
676
+ batchResults.forEach((item, index) => {
677
+ const instrument = misses[index];
678
+ if (!instrument || !item.quote) return;
679
+ const key = buildQuoteKey(instrument);
680
+ const source = item.quote.providerId ?? this.dataProvider.id;
681
+ const attempts = [createAttempt(source, Date.now(), "success")];
682
+ results.set(key, this.quoteStore.update(key, (current) => readyQuoteEntry(current, item.quote!, source, attempts)));
683
+ });
684
+ }
685
+
686
+ await Promise.all(misses.map(async (instrument) => {
687
+ const key = buildQuoteKey(instrument);
688
+ if (results.has(key)) return;
689
+ results.set(key, await this.loadQuote(instrument, options));
690
+ }));
691
+
692
+ return instruments.map((instrument) => results.get(buildQuoteKey(instrument)) ?? this.getQuoteEntry(instrument));
693
+ }
694
+
570
695
  async loadChart(
571
696
  request: ChartRequest,
572
697
  options: { forceRefresh?: boolean } = {},
@@ -798,26 +923,71 @@ export class MarketDataCoordinator {
798
923
  return () => {};
799
924
  }
800
925
 
801
- const normalizedTargets = targets.map(({ instrument }) => ({
802
- symbol: instrument.symbol,
803
- exchange: instrument.exchange ?? "",
804
- context: toMarketDataContext(instrument),
805
- }));
806
-
807
- return this.dataProvider.subscribeQuotes(normalizedTargets, (target, quote) => {
808
- const instrument: InstrumentRef = {
809
- symbol: target.symbol,
810
- exchange: target.exchange ?? "",
811
- brokerId: target.context?.brokerId,
812
- brokerInstanceId: target.context?.brokerInstanceId,
813
- instrument: target.context?.instrument ?? null,
814
- };
926
+ const subscribedKeys = new Set<string>();
927
+ for (const { instrument } of targets) {
815
928
  const key = buildQuoteKey(instrument);
816
- const current = this.quoteStore.get(key);
817
- if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
818
- const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, Date.now(), "success")];
819
- this.quoteStore.set(key, readyQuoteEntry(current, quote, quote.providerId ?? this.dataProvider.id, attempts));
820
- });
929
+ subscribedKeys.add(key);
930
+ const existing = this.quoteSubscriptions.get(key);
931
+ if (existing) {
932
+ existing.refCount += 1;
933
+ if (existing.removeTimer) {
934
+ clearTimeout(existing.removeTimer);
935
+ existing.removeTimer = null;
936
+ }
937
+ continue;
938
+ }
939
+ this.quoteSubscriptions.set(key, {
940
+ target: this.quoteTargetFromInstrument(instrument),
941
+ refCount: 1,
942
+ unsubscribe: null,
943
+ removeTimer: null,
944
+ });
945
+ this.pendingQuoteSubscriptionKeys.add(key);
946
+ }
947
+ this.flushQuoteSubscriptions();
948
+
949
+ return () => {
950
+ for (const key of subscribedKeys) {
951
+ const existing = this.quoteSubscriptions.get(key);
952
+ if (!existing) continue;
953
+ existing.refCount = Math.max(0, existing.refCount - 1);
954
+ if (existing.refCount > 0 || existing.removeTimer) continue;
955
+ existing.removeTimer = setTimeout(() => {
956
+ const current = this.quoteSubscriptions.get(key);
957
+ if (!current || current.refCount > 0) return;
958
+ current.unsubscribe?.();
959
+ this.quoteSubscriptions.delete(key);
960
+ this.pendingQuoteSubscriptionKeys.delete(key);
961
+ }, QUOTE_SUBSCRIPTION_REMOVE_GRACE_MS);
962
+ }
963
+ };
964
+ }
965
+
966
+ private flushQuoteSubscriptions(): void {
967
+ const keys = this.pendingQuoteSubscriptionKeys;
968
+ this.pendingQuoteSubscriptionKeys = new Set();
969
+ for (const key of keys) {
970
+ const entry = this.quoteSubscriptions.get(key);
971
+ if (!entry || entry.refCount === 0 || entry.unsubscribe) continue;
972
+ entry.unsubscribe = this.dataProvider.subscribeQuotes?.([entry.target], (target, quote) => {
973
+ const instrument: InstrumentRef = {
974
+ symbol: target.symbol,
975
+ exchange: target.exchange ?? "",
976
+ brokerId: target.context?.brokerId,
977
+ brokerInstanceId: target.context?.brokerInstanceId,
978
+ instrument: target.context?.instrument ?? null,
979
+ };
980
+ this.applyStreamQuote(instrument, quote);
981
+ }) ?? null;
982
+ }
983
+ }
984
+
985
+ private applyStreamQuote(instrument: InstrumentRef, quote: Quote): void {
986
+ const key = buildQuoteKey(instrument);
987
+ const current = this.quoteStore.get(key);
988
+ if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
989
+ const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, Date.now(), "success")];
990
+ this.quoteStore.set(key, readyQuoteEntry(current, quote, quote.providerId ?? this.dataProvider.id, attempts));
821
991
  }
822
992
  }
823
993
 
@@ -6,7 +6,7 @@ import type { MarketNewsItem } from "../types/news-source";
6
6
  function makeItem(overrides: Partial<MarketNewsItem> & { url: string }): MarketNewsItem {
7
7
  return {
8
8
  ...overrides,
9
- id: overrides.url,
9
+ id: overrides.id ?? overrides.url,
10
10
  title: "Test headline",
11
11
  url: overrides.url,
12
12
  source: "Test",
@@ -51,6 +51,17 @@ function makeCachedSource(id: string, cachedItems: MarketNewsItem[], fetchItems:
51
51
  });
52
52
  }
53
53
 
54
+ function makeStorySource(id: string, items: MarketNewsItem[], story: MarketNewsItem): NewsCapability {
55
+ return newsProvider({
56
+ id,
57
+ name: id,
58
+ provider: {
59
+ fetchNews: mock(async () => items),
60
+ fetchNewsStory: mock(async (storyId: string) => storyId === story.id ? story : null),
61
+ },
62
+ });
63
+ }
64
+
54
65
  describe("NewsService", () => {
55
66
  let agg: NewsService;
56
67
 
@@ -264,4 +275,41 @@ describe("NewsService", () => {
264
275
  expect(state.articles[0]!.url).toBe(fallbackItem.url);
265
276
  expect(state.sourceIds).toEqual(["fallback"]);
266
277
  });
278
+
279
+ it("loads story detail and merges source items into existing query state", async () => {
280
+ const listArticle = makeItem({
281
+ id: "story-1",
282
+ url: "https://detail.example.com/story",
283
+ items: [],
284
+ });
285
+ const detailArticle = makeItem({
286
+ ...listArticle,
287
+ items: [{
288
+ id: "item-2",
289
+ sourceKey: "wire-b",
290
+ sourceName: "Wire B",
291
+ title: "Follow-up",
292
+ url: "https://detail.example.com/follow-up",
293
+ publishedAt: new Date("2026-04-01T10:05:00.000Z"),
294
+ }, {
295
+ id: "item-1",
296
+ sourceKey: "wire-a",
297
+ sourceName: "Wire A",
298
+ title: "Original",
299
+ url: "https://detail.example.com/original",
300
+ publishedAt: new Date("2026-04-01T10:00:00.000Z"),
301
+ }],
302
+ });
303
+
304
+ agg.register(makeStorySource("cloud", [listArticle], detailArticle));
305
+
306
+ const initial = await agg.load({ feed: "top", limit: 10 });
307
+ expect(initial.articles[0]?.items).toEqual([]);
308
+
309
+ const detail = await agg.loadStory("story-1");
310
+ const state = agg.getQueryState({ feed: "top", limit: 10 });
311
+
312
+ expect(detail?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
313
+ expect(state.articles[0]?.items?.map((item) => item.id)).toEqual(["item-2", "item-1"]);
314
+ });
267
315
  });
@@ -120,6 +120,16 @@ function dedupeArticles(items: NewsArticle[]): NewsArticle[] {
120
120
  return sortByPublishedAt([...byKey.values()]).slice(0, MAX_ARTICLES);
121
121
  }
122
122
 
123
+ function mergeNewsArticle(base: NewsArticle, detail: NewsArticle): NewsArticle {
124
+ const detailItems = detail.items ?? [];
125
+ const baseItems = base.items ?? [];
126
+ return {
127
+ ...base,
128
+ ...detail,
129
+ items: detailItems.length > 0 ? detailItems : baseItems,
130
+ };
131
+ }
132
+
123
133
  function filterArticlesForQuery(items: NewsArticle[], query: NewsQuery): NewsArticle[] {
124
134
  let filtered = items;
125
135
  if (query.since) {
@@ -264,6 +274,24 @@ export class NewsService {
264
274
  return this.refreshQuery(normalizeQuery(query), true);
265
275
  }
266
276
 
277
+ async loadStory(storyId: string): Promise<NewsArticle | null> {
278
+ const sources = this.enabledSources({ feed: "latest" })
279
+ .filter((source) => !!source.provider.fetchNewsStory);
280
+
281
+ for (const source of sources) {
282
+ try {
283
+ const article = await source.provider.fetchNewsStory?.(storyId);
284
+ if (!article) continue;
285
+ this.mergeStoryDetail(article);
286
+ return article;
287
+ } catch {
288
+ // Continue to lower-priority sources.
289
+ }
290
+ }
291
+
292
+ return null;
293
+ }
294
+
267
295
  async poll(query: NewsQuery = DEFAULT_GLOBAL_QUERY): Promise<void> {
268
296
  await this.refreshQuery(normalizeQuery(query), false);
269
297
  }
@@ -413,6 +441,26 @@ export class NewsService {
413
441
  this.articles = dedupeArticles([...this.queryStates.values()].flatMap((state) => state.articles));
414
442
  }
415
443
 
444
+ private mergeStoryDetail(article: NewsArticle): void {
445
+ let changed = false;
446
+ for (const [key, state] of this.queryStates) {
447
+ let stateChanged = false;
448
+ const nextArticles = state.articles.map((existing) => {
449
+ if (existing.id !== article.id) return existing;
450
+ stateChanged = true;
451
+ changed = true;
452
+ return mergeNewsArticle(existing, article);
453
+ });
454
+ if (stateChanged) {
455
+ this.queryStates.set(key, { ...state, articles: nextArticles });
456
+ }
457
+ }
458
+
459
+ if (!changed) return;
460
+ this.rebuildArticlePool();
461
+ this.notify();
462
+ }
463
+
416
464
  getTopStories(count = 20): NewsArticle[] {
417
465
  return [...this.articles]
418
466
  .sort((a, b) => b.importance - a.importance)
package/src/news/hooks.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { useEffect, useSyncExternalStore } from "react";
1
+ import { useCallback, useEffect, useSyncExternalStore } from "react";
2
2
  import { buildNewsQueryKey, type NewsService } from "./aggregator";
3
- import type { NewsQuery, NewsQueryState } from "./types";
3
+ import type { NewsArticle, NewsQuery, NewsQueryState } from "./types";
4
4
 
5
5
  let sharedService: NewsService | null = null;
6
6
 
@@ -37,3 +37,7 @@ export function useNewsArticles(query: NewsQuery | null | undefined): NewsQueryS
37
37
 
38
38
  return query && sharedService ? sharedService.getQueryState(query) : idleState();
39
39
  }
40
+
41
+ export function useLoadNewsStory(): (storyId: string) => Promise<NewsArticle | null> {
42
+ return useCallback(async (storyId: string) => sharedService?.loadStory(storyId) ?? null, []);
43
+ }
@@ -759,11 +759,11 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
759
759
  removeTab(activeTab.id);
760
760
  return;
761
761
  }
762
- if (event.name === "[" || event.name === "left") {
762
+ if (event.name === "[") {
763
763
  cycleTabs(-1);
764
764
  return;
765
765
  }
766
- if (event.name === "]" || event.name === "right") {
766
+ if (event.name === "]") {
767
767
  cycleTabs(1);
768
768
  return;
769
769
  }
@@ -917,6 +917,7 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
917
917
  variant="pill"
918
918
  closeMode="active"
919
919
  onAdd={editorState ? undefined : addTab}
920
+ focused={focused && !editorState}
920
921
  />
921
922
  </Box>
922
923
 
@@ -1,7 +1,6 @@
1
1
  import { Box, Text } from "../../../ui";
2
2
  import { useCallback, useEffect, useMemo, useState } from "react";
3
3
  import { TextAttributes } from "../../../ui";
4
- import { useShortcut } from "../../../react/input";
5
4
  import { DataTableView, Tabs, type DataTableColumn } from "../../../components";
6
5
  import type { GloomPlugin, PaneProps } from "../../../types/plugin";
7
6
  import type { ColumnConfig } from "../../../types/config";
@@ -278,7 +277,6 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
278
277
  () => portfolios.find((portfolio) => portfolio.id === activePortfolioId) ?? null,
279
278
  [activePortfolioId, portfolios],
280
279
  );
281
- const activePortfolioIndex = portfolios.findIndex((portfolio) => portfolio.id === activePortfolioId);
282
280
  const portfolioTabs = useMemo(
283
281
  () => portfolios.map((portfolio) => ({ label: portfolio.name, value: portfolio.id })),
284
282
  [portfolios],
@@ -555,22 +553,6 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
555
553
  setSectorSort((current) => nextSectorSortPreference(current, columnId));
556
554
  }, []);
557
555
 
558
- useShortcut((event) => {
559
- if (!focused) return;
560
-
561
- const key = event.name;
562
- if ((key === "h" || key === "left") && activePortfolioIndex > 0) {
563
- const previousPortfolio = portfolios[activePortfolioIndex - 1];
564
- if (previousPortfolio) handlePortfolioSelect(previousPortfolio.id);
565
- return;
566
- }
567
- if ((key === "l" || key === "right") && activePortfolioIndex >= 0 && activePortfolioIndex < portfolios.length - 1) {
568
- const nextPortfolio = portfolios[activePortfolioIndex + 1];
569
- if (nextPortfolio) handlePortfolioSelect(nextPortfolio.id);
570
- return;
571
- }
572
- });
573
-
574
556
  useEffect(() => {
575
557
  if (activePortfolioId !== currentPortfolioId) {
576
558
  setCurrentPortfolioId(activePortfolioId);
@@ -592,6 +574,7 @@ export function PortfolioAnalyticsPane({ focused, width, height }: PaneProps) {
592
574
  activeValue={activePortfolioId}
593
575
  onSelect={handlePortfolioSelect}
594
576
  compact
577
+ focused={focused}
595
578
  />
596
579
  </Box>
597
580
  </Box>
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import type { AppNotificationRequest, PluginPersistence } from "../../types/plugin";
3
3
  import type { PersistedResourceValue } from "../../types/persistence";
4
- import { apiClient, type ChatMessage } from "../../utils/api-client";
4
+ import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
5
5
  import { ChatController } from "./chat-controller";
6
6
 
7
7
  const TRANSCRIPT_KIND = "channel-transcript";
@@ -11,6 +11,14 @@ const TRANSCRIPT_SCHEMA_VERSION = 2;
11
11
  const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
12
12
  const originalGetSession = apiClient.getSession.bind(apiClient);
13
13
  const originalGetMessages = apiClient.getMessages.bind(apiClient);
14
+ const originalGetChannels = apiClient.getChannels.bind(apiClient);
15
+
16
+ const SERVER_CHAT_CHANNELS: ChatChannel[] = [
17
+ { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
18
+ { id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" },
19
+ { id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" },
20
+ { id: "help", name: "help", created_at: "2026-05-09T00:00:00.000Z" },
21
+ ];
14
22
 
15
23
  async function flushMicrotasks() {
16
24
  await Promise.resolve();
@@ -98,6 +106,7 @@ afterEach(() => {
98
106
  apiClient.connectChannel = originalConnectChannel;
99
107
  apiClient.getSession = originalGetSession;
100
108
  apiClient.getMessages = originalGetMessages;
109
+ apiClient.getChannels = originalGetChannels;
101
110
  });
102
111
 
103
112
  describe("ChatController", () => {
@@ -141,6 +150,27 @@ describe("ChatController", () => {
141
150
  expect(snapshot.messages.map((entry) => entry.id)).toEqual(["m1"]);
142
151
  });
143
152
 
153
+ test("starts without client-seeded channels and hydrates the server channel list", async () => {
154
+ const controller = new ChatController();
155
+ apiClient.getChannels = async () => SERVER_CHAT_CHANNELS;
156
+
157
+ expect(controller.getSnapshot().channels).toEqual([]);
158
+
159
+ await controller.refreshChannels();
160
+
161
+ expect(controller.getSnapshot().channels).toEqual(SERVER_CHAT_CHANNELS);
162
+ });
163
+
164
+ test("rejects unknown shortcut channels after the server list loads", async () => {
165
+ const controller = new ChatController();
166
+ apiClient.getChannels = async () => SERVER_CHAT_CHANNELS;
167
+
168
+ await expect(controller.resolveRequiredChannelId("help")).resolves.toBe("help");
169
+ await expect(controller.resolveRequiredChannelId("made-up")).rejects.toThrow(
170
+ 'Unknown chat channel "#made-up".',
171
+ );
172
+ });
173
+
144
174
  test("hydrates a cached verified user into the api client for offline use", async () => {
145
175
  const persistence = new MemoryPersistence();
146
176
  const controller = new ChatController();
@@ -412,6 +442,58 @@ describe("ChatController", () => {
412
442
  });
413
443
  });
414
444
 
445
+ test("keeps per-channel drafts and transcripts isolated", () => {
446
+ const persistence = new MemoryPersistence();
447
+ const controller = new ChatController();
448
+ const everyoneMessage: ChatMessage = {
449
+ id: "m1",
450
+ channelId: "everyone",
451
+ content: "general",
452
+ replyToId: null,
453
+ createdAt: "2026-03-28T00:00:00.000Z",
454
+ user: { id: "u1", username: "vince", displayName: "Vince" },
455
+ };
456
+ const optionsMessage: ChatMessage = {
457
+ id: "m2",
458
+ channelId: "options",
459
+ content: "options note",
460
+ replyToId: null,
461
+ createdAt: "2026-03-28T00:01:00.000Z",
462
+ user: { id: "u2", username: "bob", displayName: "Bob" },
463
+ };
464
+
465
+ persistence.setState("channel:everyone", {
466
+ draft: "general draft",
467
+ replyToId: null,
468
+ lastCursor: "m1",
469
+ lastViewedMessageId: "m1",
470
+ }, { schemaVersion: 1 });
471
+ persistence.setState("channel:options", {
472
+ draft: "options draft",
473
+ replyToId: null,
474
+ lastCursor: "m2",
475
+ lastViewedMessageId: "m2",
476
+ }, { schemaVersion: 1 });
477
+ persistence.setResource(TRANSCRIPT_KIND, "everyone", { messages: [everyoneMessage] }, {
478
+ sourceKey: TRANSCRIPT_SOURCE,
479
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
480
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
481
+ });
482
+ persistence.setResource(TRANSCRIPT_KIND, "options", { messages: [optionsMessage] }, {
483
+ sourceKey: TRANSCRIPT_SOURCE,
484
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
485
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
486
+ });
487
+
488
+ controller.attachPersistence(persistence);
489
+ controller.setChannelDraft("options", "updated options");
490
+
491
+ expect(controller.getSnapshot().messages.map((entry) => entry.content)).toEqual(["general"]);
492
+ expect(controller.getSnapshot().draft).toBe("general draft");
493
+ expect(controller.getSnapshot("options").messages.map((entry) => entry.content)).toEqual(["options note"]);
494
+ expect(controller.getSnapshot("options").draft).toBe("updated options");
495
+ });
496
+
415
497
  test("stores the latest message id as the incremental cursor", async () => {
416
498
  const persistence = new MemoryPersistence();
417
499
  const controller = new ChatController();