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
@@ -0,0 +1,118 @@
1
+ /// <reference lib="dom" />
2
+ /** @jsxImportSource react */
3
+ import { Component, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
4
+ import { backendRequest, requestElectrobunRestart } from "./backend-rpc";
5
+
6
+ declare global {
7
+ interface Window {
8
+ __gloomRenderFatalError?: (error: unknown, details?: string) => void;
9
+ }
10
+ }
11
+
12
+ interface DesktopFatalScreenProps {
13
+ title?: string;
14
+ error: unknown;
15
+ details?: string;
16
+ source: string;
17
+ }
18
+
19
+ interface ElectrobunErrorBoundaryState {
20
+ hasError: boolean;
21
+ error: unknown;
22
+ details?: string;
23
+ }
24
+
25
+ export function formatFatalError(error: unknown, details?: string): string {
26
+ const message = error instanceof Error ? error.stack ?? error.message : String(error);
27
+ return [message, details].filter((value): value is string => Boolean(value)).join("\n");
28
+ }
29
+
30
+ export function DesktopFatalScreen({
31
+ title = "Gloomberb crashed",
32
+ error,
33
+ details,
34
+ source,
35
+ }: DesktopFatalScreenProps) {
36
+ const [status, setStatus] = useState<string | null>(null);
37
+ const errorText = useMemo(() => formatFatalError(error, details), [details, error]);
38
+
39
+ const reloadWindow = () => {
40
+ window.location.reload();
41
+ };
42
+
43
+ const restartApp = () => {
44
+ setStatus("Restart requested...");
45
+ try {
46
+ requestElectrobunRestart({
47
+ source,
48
+ reason: errorText.slice(0, 240),
49
+ });
50
+ window.setTimeout(() => {
51
+ setStatus("If the app stays open, quit it and open it again.");
52
+ }, 2500);
53
+ } catch (restartError) {
54
+ setStatus(`Restart request failed: ${formatFatalError(restartError)}`);
55
+ }
56
+ };
57
+
58
+ const copyError = async () => {
59
+ try {
60
+ if (navigator.clipboard?.writeText) {
61
+ await navigator.clipboard.writeText(errorText);
62
+ } else {
63
+ await backendRequest("host.copyText", { text: errorText });
64
+ }
65
+ setStatus("Error copied.");
66
+ } catch (copyError) {
67
+ setStatus(`Copy failed: ${formatFatalError(copyError)}`);
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div className="gloom-fatal">
73
+ <h1>{title}</h1>
74
+ <div className="gloom-fatal-actions">
75
+ <button type="button" data-variant="primary" onClick={restartApp}>Restart app</button>
76
+ <button type="button" onClick={reloadWindow}>Reload window</button>
77
+ <button type="button" onClick={copyError}>Copy error</button>
78
+ </div>
79
+ {status && <div className="gloom-fatal-status" aria-live="polite">{status}</div>}
80
+ <pre>{errorText}</pre>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ export class ElectrobunErrorBoundary extends Component<
86
+ { children: ReactNode },
87
+ ElectrobunErrorBoundaryState
88
+ > {
89
+ override state: ElectrobunErrorBoundaryState = { hasError: false, error: null };
90
+
91
+ static getDerivedStateFromError(error: unknown): ElectrobunErrorBoundaryState {
92
+ return { hasError: true, error };
93
+ }
94
+
95
+ override componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
96
+ console.error("[desktop-recovery] renderer error boundary", error, errorInfo.componentStack);
97
+ this.setState({
98
+ hasError: true,
99
+ error,
100
+ details: errorInfo.componentStack ?? undefined,
101
+ });
102
+ }
103
+
104
+ override render(): ReactNode {
105
+ if (this.state.hasError) {
106
+ return (
107
+ <DesktopFatalScreen
108
+ title="Gloomberb crashed"
109
+ error={this.state.error}
110
+ details={this.state.details}
111
+ source="react-error-boundary"
112
+ />
113
+ );
114
+ }
115
+
116
+ return this.props.children;
117
+ }
118
+ }
@@ -15,6 +15,7 @@ import {
15
15
  installElectrobunHttpFetchTransport,
16
16
  } from "./http-fetch";
17
17
  import { installElectrobunUpdateHost } from "./update-host";
18
+ import { DesktopFatalScreen, ElectrobunErrorBoundary } from "./fatal-screen";
18
19
  import { WebInputHostProvider } from "./input-host";
19
20
  import { webNativeRenderer } from "./native-renderer";
20
21
  import { WebToastHostProvider } from "./toast-host";
@@ -27,16 +28,32 @@ const rootElement = document.getElementById("root");
27
28
  if (!rootElement) {
28
29
  throw new Error("Missing root element");
29
30
  }
31
+ const appRootElement = rootElement;
30
32
 
31
- const root = createRoot(rootElement);
33
+ const root = createRoot(appRootElement);
32
34
  const bootLog = debugLog.createLogger("electrobun-web-boot");
33
35
 
34
- rootElement.tabIndex = -1;
36
+ appRootElement.tabIndex = -1;
35
37
  root.render(<div className="gloom-loading">Starting Gloomberb...</div>);
36
38
 
39
+ function renderFatalError(error: unknown, details?: string, title = "Gloomberb failed to start"): void {
40
+ root.render(
41
+ <DesktopFatalScreen
42
+ title={title}
43
+ error={error}
44
+ details={details}
45
+ source="renderer-fatal"
46
+ />,
47
+ );
48
+ }
49
+
50
+ window.__gloomRenderFatalError = (error, details) => {
51
+ renderFatalError(error, details, "Gloomberb crashed");
52
+ };
53
+
37
54
  function focusWebSurface(): void {
38
55
  window.focus();
39
- rootElement.focus({ preventScroll: true });
56
+ appRootElement.focus({ preventScroll: true });
40
57
  }
41
58
 
42
59
  function requestStartupFocus(): void {
@@ -69,21 +86,23 @@ async function boot() {
69
86
  const desktopApplicationMenuBridge = createApplicationMenuBridge();
70
87
  measurePerfAsync("startup.electrobun.root-render", async () => {
71
88
  root.render(
72
- <UiHostProvider ui={webUiHost} renderer={webRendererHost} nativeRenderer={webNativeRenderer}>
73
- <WebInputHostProvider>
74
- <WebToastHostProvider>
75
- <WebDialogHostProvider>
76
- <App
77
- config={config}
78
- desktopWindowBridge={desktopWindowBridge}
79
- desktopApplicationMenuBridge={desktopApplicationMenuBridge}
80
- desktopSnapshot={desktopSnapshot}
81
- desktopThemePreview={init.desktopThemePreview}
82
- />
83
- </WebDialogHostProvider>
84
- </WebToastHostProvider>
85
- </WebInputHostProvider>
86
- </UiHostProvider>,
89
+ <ElectrobunErrorBoundary>
90
+ <UiHostProvider ui={webUiHost} renderer={webRendererHost} nativeRenderer={webNativeRenderer}>
91
+ <WebInputHostProvider>
92
+ <WebToastHostProvider>
93
+ <WebDialogHostProvider>
94
+ <App
95
+ config={config}
96
+ desktopWindowBridge={desktopWindowBridge}
97
+ desktopApplicationMenuBridge={desktopApplicationMenuBridge}
98
+ desktopSnapshot={desktopSnapshot}
99
+ desktopThemePreview={init.desktopThemePreview}
100
+ />
101
+ </WebDialogHostProvider>
102
+ </WebToastHostProvider>
103
+ </WebInputHostProvider>
104
+ </UiHostProvider>
105
+ </ElectrobunErrorBoundary>,
87
106
  );
88
107
  });
89
108
  requestStartupFocus();
@@ -96,10 +115,5 @@ async function boot() {
96
115
  }
97
116
 
98
117
  boot().catch((error) => {
99
- root.render(
100
- <div className="gloom-fatal">
101
- <h1>Gloomberb failed to start</h1>
102
- <pre>{error instanceof Error ? error.stack ?? error.message : String(error)}</pre>
103
- </div>,
104
- );
118
+ renderFatalError(error);
105
119
  });
@@ -1,5 +1,5 @@
1
1
  @keyframes gloom-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
2
- html, body, #root { width: 100%; height: 100%; min-height: 100%; overflow: hidden; background: var(--gloom-bg); }
2
+ html, body, #root { width: 100%; height: 100%; min-height: 100%; overflow: hidden; background: var(--gloom-bg, #272a34); }
3
3
  #root:focus { outline: none; }
4
4
  body {
5
5
  --cell-w: 8px;
@@ -8,7 +8,7 @@ body {
8
8
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
9
9
  font-size: 12px;
10
10
  line-height: var(--cell-h);
11
- color: var(--gloom-text);
11
+ color: var(--gloom-text, #f2f2f2);
12
12
  cursor: default;
13
13
  user-select: none;
14
14
  color-scheme: dark;
@@ -284,8 +284,52 @@ body.gloom-dragging {
284
284
  top: 50%;
285
285
  height: 1px;
286
286
  }
287
- .gloom-loading, .gloom-fatal { padding: 24px; color: var(--gloom-text); }
288
- .gloom-fatal pre { white-space: pre-wrap; color: var(--gloom-negative); }
287
+ .gloom-loading, .gloom-fatal {
288
+ padding: 24px;
289
+ color: var(--gloom-text, #f2f2f2);
290
+ background: var(--gloom-bg, #272a34);
291
+ }
292
+ .gloom-fatal {
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 14px;
296
+ overflow: auto;
297
+ }
298
+ .gloom-fatal h1 {
299
+ margin: 0 0 6px;
300
+ font-size: 28px;
301
+ line-height: 1.25;
302
+ }
303
+ .gloom-fatal-actions {
304
+ display: flex;
305
+ flex-wrap: wrap;
306
+ gap: 8px;
307
+ }
308
+ .gloom-fatal button {
309
+ border: 1px solid var(--gloom-border, #5b6070);
310
+ border-radius: 6px;
311
+ padding: 5px 10px;
312
+ color: var(--gloom-text, #f2f2f2);
313
+ background: var(--gloom-panel, #1f222b);
314
+ }
315
+ .gloom-fatal button:hover {
316
+ background: color-mix(in srgb, var(--gloom-text-bright, #fff) 10%, var(--gloom-panel, #1f222b));
317
+ }
318
+ .gloom-fatal button[data-variant="primary"] {
319
+ border-color: var(--gloom-border-focused, #8cb4ff);
320
+ color: var(--gloom-bg, #111318);
321
+ background: var(--gloom-border-focused, #8cb4ff);
322
+ }
323
+ .gloom-fatal-status {
324
+ min-height: var(--cell-h);
325
+ color: var(--gloom-text-dim, #a7acba);
326
+ }
327
+ .gloom-fatal pre {
328
+ margin: 0;
329
+ white-space: pre-wrap;
330
+ color: var(--gloom-negative, #ff5f5f);
331
+ user-select: text;
332
+ }
289
333
  .gloom-toast-viewport {
290
334
  position: fixed;
291
335
  right: 16px;
@@ -20,6 +20,7 @@ const originalGetCloudQuote = apiClient.getCloudQuote.bind(apiClient);
20
20
  const originalGetCloudHolders = apiClient.getCloudHolders.bind(apiClient);
21
21
  const originalGetCloudAnalystResearch = apiClient.getCloudAnalystResearch.bind(apiClient);
22
22
  const originalGetCloudCorporateActions = apiClient.getCloudCorporateActions.bind(apiClient);
23
+ const originalGetCloudOptionsChain = apiClient.getCloudOptionsChain.bind(apiClient);
23
24
  const originalGetCloudNews = apiClient.getCloudNews.bind(apiClient);
24
25
  const originalGetCloudNewsStory = apiClient.getCloudNewsStory.bind(apiClient);
25
26
  const originalSubscribeQuotes = apiClient.subscribeQuotes.bind(apiClient);
@@ -78,6 +79,7 @@ afterEach(() => {
78
79
  apiClient.getCloudHolders = originalGetCloudHolders;
79
80
  apiClient.getCloudAnalystResearch = originalGetCloudAnalystResearch;
80
81
  apiClient.getCloudCorporateActions = originalGetCloudCorporateActions;
82
+ apiClient.getCloudOptionsChain = originalGetCloudOptionsChain;
81
83
  apiClient.getCloudNews = originalGetCloudNews;
82
84
  apiClient.getCloudNewsStory = originalGetCloudNewsStory;
83
85
  apiClient.subscribeQuotes = originalSubscribeQuotes;
@@ -103,8 +105,8 @@ describe("GloomberbCloudProvider", () => {
103
105
  const history = await provider.getDetailedPriceHistory(
104
106
  "AAPL",
105
107
  "NASDAQ",
106
- new Date(2026, 2, 27, 10, 0, 0),
107
- new Date(2026, 2, 27, 12, 0, 0),
108
+ new Date("2026-03-27T14:00:00Z"),
109
+ new Date("2026-03-27T16:00:00Z"),
108
110
  "15m",
109
111
  );
110
112
 
@@ -119,6 +121,7 @@ describe("GloomberbCloudProvider", () => {
119
121
  });
120
122
  expect(history).toHaveLength(1);
121
123
  expect(history[0]?.close).toBe(250.12);
124
+ expect(history[0]?.date.toISOString()).toBe("2026-03-27T14:15:00.000Z");
122
125
  });
123
126
 
124
127
  test("normalizes daily detailed history requests to 1day", async () => {
@@ -173,6 +176,11 @@ describe("GloomberbCloudProvider", () => {
173
176
  expect(history[0]?.close).toBe(250.12);
174
177
  });
175
178
 
179
+ test("does not advertise unsupported 45 minute chart resolution", () => {
180
+ const provider = new GloomberbCloudProvider();
181
+ expect(provider.getChartResolutionCapabilities()).not.toContain("45m");
182
+ });
183
+
176
184
  test("normalizes sub-unit cloud quotes to their main currency", async () => {
177
185
  apiClient.ensureVerifiedSession = async () => verifiedUser;
178
186
  apiClient.getCloudQuote = async () => ({
@@ -319,6 +327,51 @@ describe("GloomberbCloudProvider", () => {
319
327
  expect(actions.earnings[0]?.surprisePercent).toBe(6.37);
320
328
  });
321
329
 
330
+ test("fetches options chains from the cloud market endpoint", async () => {
331
+ apiClient.ensureVerifiedSession = async () => verifiedUser;
332
+
333
+ const requestArgs: { current: { symbol: string; exchange?: string; expirationDate?: number } | null } = { current: null };
334
+ apiClient.getCloudOptionsChain = async (symbol, exchange, expirationDate) => {
335
+ requestArgs.current = { symbol, exchange, expirationDate };
336
+ return {
337
+ status: "success",
338
+ data: {
339
+ providerId: "gloomberb-cloud",
340
+ underlyingSymbol: "AMD",
341
+ expirationDates: [1_800_000_000],
342
+ calls: [{
343
+ contractSymbol: "AMD270917C00230000",
344
+ strike: 230,
345
+ currency: "USD",
346
+ lastPrice: 11,
347
+ change: 1,
348
+ percentChange: 10,
349
+ volume: 10,
350
+ openInterest: 20,
351
+ bid: 10,
352
+ ask: 12,
353
+ impliedVolatility: 0.4,
354
+ inTheMoney: false,
355
+ expiration: 1_800_000_000,
356
+ lastTradeDate: 1_799_000_000,
357
+ }],
358
+ puts: [],
359
+ },
360
+ };
361
+ };
362
+
363
+ const provider = new GloomberbCloudProvider();
364
+ const chain = await provider.getOptionsChain("AMD", "NASDAQ", 1_800_000_000);
365
+
366
+ expect(requestArgs.current).toEqual({
367
+ symbol: "AMD",
368
+ exchange: "NASDAQ",
369
+ expirationDate: 1_800_000_000,
370
+ });
371
+ expect(chain.underlyingSymbol).toBe("AMD");
372
+ expect(chain.calls[0]?.contractSymbol).toBe("AMD270917C00230000");
373
+ });
374
+
322
375
  test("preserves original target context when streaming quotes", () => {
323
376
  let unsubscribeCalled = false;
324
377
  const seenQuotes: Array<{ price: number; currency: string }> = [];