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.
- package/package.json +1 -1
- package/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +85 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +101 -5
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +14 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- 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 =
|
|
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.
|
|
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
|
|
802
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
});
|
package/src/news/aggregator.ts
CHANGED
|
@@ -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 === "["
|
|
762
|
+
if (event.name === "[") {
|
|
763
763
|
cycleTabs(-1);
|
|
764
764
|
return;
|
|
765
765
|
}
|
|
766
|
-
if (event.name === "]"
|
|
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();
|