gloomberb 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -127,7 +127,7 @@ function selectedTextMenuItems(text: string, registry: PluginRegistry | null, co
127
127
  contextMenuDivider("selected-text:ticker-divider"),
128
128
  {
129
129
  id: "selected-text:open-ticker",
130
- label: "Open as Ticker",
130
+ label: "Open Ticker Detail",
131
131
  onSelect: () => registry?.navigateTicker(symbol),
132
132
  },
133
133
  {
@@ -224,13 +224,13 @@ export function tickerContextMenuItems({
224
224
  const items: ContextMenuItem[] = [
225
225
  {
226
226
  id: "ticker:open",
227
- label: `Open ${symbol}`,
227
+ label: `Open ${symbol} Detail`,
228
228
  onSelect: () => (openTicker ? openTicker(symbol) : registry?.navigateTicker(symbol)),
229
229
  },
230
230
  {
231
231
  id: "ticker:pin-floating",
232
- label: `Pin ${symbol} in Floating Detail`,
233
- onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail" }),
232
+ label: `Open ${symbol} in New Floating Detail`,
233
+ onSelect: () => registry?.pinTicker(symbol, { floating: true, paneType: "ticker-detail", forceNewPane: true }),
234
234
  },
235
235
  {
236
236
  id: "ticker:copy-symbol",
@@ -210,6 +210,126 @@ describe("apiClient auth cookies", () => {
210
210
 
211
211
  unsubscribe();
212
212
  });
213
+
214
+ test("opens an anonymous market websocket and sends quote priority hints", () => {
215
+ const sent: unknown[] = [];
216
+ const sockets: Array<{
217
+ url: string;
218
+ readyState: number;
219
+ onopen: ((event: unknown) => void) | null;
220
+ close: () => void;
221
+ }> = [];
222
+
223
+ class FakeWebSocket {
224
+ static OPEN = 1;
225
+ readyState = 1;
226
+ onopen: ((event: unknown) => void) | null = null;
227
+ onmessage: ((event: { data: string }) => void) | null = null;
228
+ onclose: ((event: unknown) => void) | null = null;
229
+ onerror: ((event: unknown) => void) | null = null;
230
+
231
+ constructor(readonly url: string) {
232
+ sockets.push(this);
233
+ }
234
+
235
+ send(payload: string): void {
236
+ sent.push(JSON.parse(payload));
237
+ }
238
+
239
+ close(): void {
240
+ this.readyState = 3;
241
+ }
242
+ }
243
+
244
+ globalThis.WebSocket = FakeWebSocket as any;
245
+
246
+ const unsubscribe = apiClient.subscribeQuotes([{
247
+ symbol: "AAPL",
248
+ exchange: "NASDAQ",
249
+ surface: "portfolio",
250
+ visible: true,
251
+ selected: true,
252
+ weight: 100,
253
+ }], () => {});
254
+ sockets[0]!.onopen?.({});
255
+
256
+ expect(sockets).toHaveLength(1);
257
+ expect(sockets[0]!.url).toBe("wss://api.gloom.sh/cloud/ws");
258
+ expect(sent).toContainEqual({
259
+ type: "market.subscribe",
260
+ symbols: [{
261
+ symbol: "AAPL",
262
+ exchange: "NASDAQ",
263
+ surface: "portfolio",
264
+ visible: true,
265
+ selected: true,
266
+ weight: 100,
267
+ }],
268
+ });
269
+
270
+ unsubscribe();
271
+ });
272
+
273
+ test("keeps an anonymous market websocket open after auth rejection", () => {
274
+ const seenPrices: number[] = [];
275
+ let closeCalls = 0;
276
+ const sockets: Array<{
277
+ readyState: number;
278
+ onopen: ((event: unknown) => void) | null;
279
+ onmessage: ((event: { data: string }) => void) | null;
280
+ close: () => void;
281
+ }> = [];
282
+
283
+ class FakeWebSocket {
284
+ static OPEN = 1;
285
+ readyState = 1;
286
+ onopen: ((event: unknown) => void) | null = null;
287
+ onmessage: ((event: { data: string }) => void) | null = null;
288
+ onclose: ((event: unknown) => void) | null = null;
289
+ onerror: ((event: unknown) => void) | null = null;
290
+
291
+ constructor(readonly _url: string) {
292
+ sockets.push(this);
293
+ }
294
+
295
+ send(): void {}
296
+
297
+ close(): void {
298
+ closeCalls += 1;
299
+ this.readyState = 3;
300
+ }
301
+ }
302
+
303
+ globalThis.WebSocket = FakeWebSocket as any;
304
+
305
+ const unsubscribe = apiClient.subscribeQuotes([{ symbol: "AAPL" }], (_target, quote) => {
306
+ seenPrices.push(quote.price);
307
+ });
308
+ sockets[0]!.onopen?.({});
309
+ sockets[0]!.onmessage?.({ data: JSON.stringify({ type: "auth.unverified" }) });
310
+ sockets[0]!.onmessage?.({
311
+ data: JSON.stringify({
312
+ type: "market.quote",
313
+ symbol: "AAPL",
314
+ exchange: "",
315
+ quote: {
316
+ symbol: "AAPL",
317
+ price: 123,
318
+ currency: "USD",
319
+ change: 0,
320
+ changePercent: 0,
321
+ lastUpdated: 1,
322
+ providerId: "gloomberb-cloud",
323
+ dataSource: "live",
324
+ },
325
+ }),
326
+ });
327
+
328
+ expect(closeCalls).toBe(0);
329
+ expect(seenPrices).toEqual([123]);
330
+
331
+ unsubscribe();
332
+ });
213
333
  });
214
334
 
215
335
  describe("apiClient chat timestamps", () => {
@@ -282,6 +402,134 @@ describe("apiClient chat timestamps", () => {
282
402
  expect(seenCreatedAts).toEqual(["2026-04-08T07:28:27.625Z"]);
283
403
  channel.close();
284
404
  });
405
+
406
+ test("fetches chat state and normalizes pending notification timestamps", async () => {
407
+ let requestedUrl = "";
408
+ globalThis.fetch = mockFetch(async (input: Request | string | URL) => {
409
+ requestedUrl = String(input);
410
+ return createResponse({
411
+ channels: [{ id: "everyone", name: "everyone", created_at: "2026-04-08T07:00:00.000Z" }],
412
+ onlineCount: 2,
413
+ channelStates: [{
414
+ channelId: "everyone",
415
+ notificationsEnabled: true,
416
+ lastReadMessageId: "m1",
417
+ unreadCount: 1,
418
+ }],
419
+ notifications: [{
420
+ id: "n1",
421
+ type: "reply",
422
+ channelId: "everyone",
423
+ messageId: "m2",
424
+ createdAt: "2026-04-08 07:30:00.000",
425
+ message: {
426
+ id: "m2",
427
+ channelId: "everyone",
428
+ content: "reply",
429
+ replyToId: "m1",
430
+ createdAt: "2026-04-08 07:29:00.000",
431
+ user: { id: "u2", username: "bob", displayName: "Bob" },
432
+ replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
433
+ },
434
+ }],
435
+ });
436
+ });
437
+
438
+ const state = await apiClient.getChatState();
439
+
440
+ expect(new URL(requestedUrl).pathname).toBe("/chat/state");
441
+ expect(state.onlineCount).toBe(2);
442
+ expect(state.notifications[0]?.createdAt).toBe("2026-04-08T07:30:00.000Z");
443
+ expect(state.notifications[0]?.message.createdAt).toBe("2026-04-08T07:29:00.000Z");
444
+ });
445
+
446
+ test("updates chat channel state and marks notifications delivered", async () => {
447
+ const requests: Array<{ path: string; method: string; body: unknown }> = [];
448
+ const responses = [
449
+ createResponse({
450
+ channelId: "everyone",
451
+ notificationsEnabled: true,
452
+ lastReadMessageId: "m2",
453
+ unreadCount: 0,
454
+ }),
455
+ createResponse({ delivered: 2 }),
456
+ createResponse({ onlineCount: 4 }),
457
+ ];
458
+ globalThis.fetch = mockFetch(async (input: Request | string | URL, init?: RequestInit) => {
459
+ requests.push({
460
+ path: new URL(String(input)).pathname,
461
+ method: init?.method ?? "GET",
462
+ body: init?.body ? JSON.parse(String(init.body)) : null,
463
+ });
464
+ return responses.shift() as Response;
465
+ });
466
+
467
+ await apiClient.updateChatChannelState("everyone", {
468
+ notificationsEnabled: true,
469
+ readThroughMessageId: "m2",
470
+ });
471
+ await apiClient.markChatNotificationsDelivered(["n1", "n2"]);
472
+ const presence = await apiClient.getChatPresence();
473
+
474
+ expect(requests).toEqual([
475
+ {
476
+ path: "/chat/channels/everyone/state",
477
+ method: "PATCH",
478
+ body: { notificationsEnabled: true, readThroughMessageId: "m2" },
479
+ },
480
+ {
481
+ path: "/chat/notifications/delivered",
482
+ method: "POST",
483
+ body: { notificationIds: ["n1", "n2"] },
484
+ },
485
+ {
486
+ path: "/chat/presence",
487
+ method: "GET",
488
+ body: null,
489
+ },
490
+ ]);
491
+ expect(presence).toEqual({ onlineCount: 4 });
492
+ });
493
+
494
+ test("emits websocket chat presence and notification events", async () => {
495
+ const seenPresence: number[] = [];
496
+ const seenNotifications: string[] = [];
497
+ const unsubscribePresence = apiClient.subscribeChatPresence((onlineCount) => {
498
+ seenPresence.push(onlineCount);
499
+ });
500
+ const unsubscribeNotifications = apiClient.subscribeChatNotifications((notification) => {
501
+ seenNotifications.push(`${notification.id}:${notification.message.createdAt}`);
502
+ });
503
+
504
+ await (apiClient as any).handleSocketMessage(JSON.stringify({
505
+ type: "chat.presence",
506
+ onlineCount: 5,
507
+ }));
508
+ await (apiClient as any).handleSocketMessage(JSON.stringify({
509
+ type: "chat.notification",
510
+ data: {
511
+ id: "n1",
512
+ type: "reply",
513
+ channelId: "everyone",
514
+ messageId: "m2",
515
+ createdAt: "2026-04-08 07:30:00.000",
516
+ message: {
517
+ id: "m2",
518
+ channelId: "everyone",
519
+ content: "reply",
520
+ replyToId: "m1",
521
+ createdAt: "2026-04-08 07:29:00.000",
522
+ user: { id: "u2", username: "bob", displayName: "Bob" },
523
+ replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
524
+ },
525
+ },
526
+ }));
527
+
528
+ expect(seenPresence).toEqual([5]);
529
+ expect(seenNotifications).toEqual(["n1:2026-04-08T07:29:00.000Z"]);
530
+ unsubscribePresence();
531
+ unsubscribeNotifications();
532
+ });
285
533
  });
286
534
 
287
535
  describe("apiClient cloud news", () => {
@@ -5,6 +5,7 @@ import type {
5
5
  Fundamentals,
6
6
  HolderData,
7
7
  HolderRecord,
8
+ OptionsChain,
8
9
  PricePoint,
9
10
  Quote,
10
11
  TickerFinancials,
@@ -48,7 +49,7 @@ export interface ChatMessage {
48
49
  replyToId: string | null;
49
50
  createdAt: string;
50
51
  user: { id: string; username: string; displayName: string };
51
- replyTo?: { content: string; user: { username: string } } | null;
52
+ replyTo?: { content: string; user: { id?: string; username: string } } | null;
52
53
  clientStatus?: "sending" | "failed";
53
54
  clientError?: string | null;
54
55
  }
@@ -59,6 +60,29 @@ export interface ChatChannel {
59
60
  created_at: string;
60
61
  }
61
62
 
63
+ export interface ChatChannelState {
64
+ channelId: string;
65
+ notificationsEnabled: boolean;
66
+ lastReadMessageId: string | null;
67
+ unreadCount: number;
68
+ }
69
+
70
+ export interface ChatNotification {
71
+ id: string;
72
+ type: "reply" | "mention" | "channel";
73
+ channelId: string;
74
+ messageId: string;
75
+ createdAt: string;
76
+ message: ChatMessage;
77
+ }
78
+
79
+ export interface ChatStateResponse {
80
+ channels: ChatChannel[];
81
+ onlineCount: number;
82
+ channelStates: ChatChannelState[];
83
+ notifications: ChatNotification[];
84
+ }
85
+
62
86
  export interface AuthUser {
63
87
  id: string;
64
88
  name: string;
@@ -77,6 +101,10 @@ export interface CloudQuotePayload extends Quote {
77
101
  dataSource: "live" | "delayed";
78
102
  }
79
103
 
104
+ export interface CloudOptionsChainPayload extends OptionsChain {
105
+ providerId: "gloomberb-cloud";
106
+ }
107
+
80
108
  export interface CloudCompanyProfile extends CompanyProfile {}
81
109
 
82
110
  export interface CloudFundamentals extends Fundamentals {}
@@ -294,10 +322,19 @@ export interface CloudMarketResponse<T> {
294
322
  reasonCode?: string;
295
323
  asOf?: string;
296
324
  staleAt?: string;
325
+ stale?: boolean;
326
+ currency?: string;
297
327
  providerMeta?: {
328
+ provider?: string;
298
329
  upstream?: string;
330
+ status?: CloudMarketStatus;
331
+ reasonCode?: string;
299
332
  normalizedSymbol?: string;
300
333
  normalizedExchange?: string;
334
+ stale?: boolean;
335
+ fallbackReason?: string;
336
+ requestedResolution?: string;
337
+ servedResolution?: string;
301
338
  latencyMs?: number;
302
339
  range?: string;
303
340
  granularity?: string;
@@ -333,6 +370,10 @@ export interface CloudVerificationResponse {
333
370
  export interface QuoteStreamTarget {
334
371
  symbol: string;
335
372
  exchange?: string;
373
+ surface?: "portfolio" | "watchlist" | "detail" | "monitor" | "inline" | "screener" | "unknown";
374
+ visible?: boolean;
375
+ selected?: boolean;
376
+ weight?: number;
336
377
  }
337
378
 
338
379
  function marketKey(symbol: string, exchange?: string): string {
@@ -352,6 +393,21 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
352
393
  return messages.map((message) => normalizeChatMessage(message));
353
394
  }
354
395
 
396
+ function normalizeChatNotification(notification: ChatNotification): ChatNotification {
397
+ return {
398
+ ...notification,
399
+ createdAt: normalizeTimestamp(notification.createdAt),
400
+ message: normalizeChatMessage(notification.message),
401
+ };
402
+ }
403
+
404
+ function normalizeChatState(response: ChatStateResponse): ChatStateResponse {
405
+ return {
406
+ ...response,
407
+ notifications: response.notifications.map(normalizeChatNotification),
408
+ };
409
+ }
410
+
355
411
  function normalizeTweet(tweet: CloudTweetPayload): CloudTweetPayload {
356
412
  return {
357
413
  ...tweet,
@@ -370,6 +426,8 @@ function normalizeTweetSearchResponse(response: CloudTweetSearchResponse): Cloud
370
426
  }
371
427
 
372
428
  type ChannelListener = (message: ChatMessage) => void;
429
+ type ChatNotificationListener = (notification: ChatNotification) => void;
430
+ type ChatPresenceListener = (onlineCount: number) => void;
373
431
  type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
374
432
  type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
375
433
 
@@ -408,6 +466,8 @@ class GloomApiClient {
408
466
  private reconnectDelayMs = 1000;
409
467
 
410
468
  private readonly channelListeners = new Map<string, Set<ChannelListener>>();
469
+ private readonly chatNotificationListeners = new Set<ChatNotificationListener>();
470
+ private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
411
471
  private readonly quoteListeners = new Map<string, Set<QuoteListener>>();
412
472
  private readonly quoteTargets = new Map<string, QuoteStreamTarget>();
413
473
  private readonly pendingQuoteSubscribes = new Map<string, QuoteStreamTarget>();
@@ -560,18 +620,20 @@ class GloomApiClient {
560
620
  }
561
621
 
562
622
  private shouldKeepSocketOpen(): boolean {
623
+ if (this.quoteTargets.size > 0) return true;
563
624
  return !!this.getSocketAuthToken()
564
625
  && !!this.currentUser?.emailVerified
565
- && (this.channelListeners.size > 0 || this.quoteTargets.size > 0);
626
+ && this.channelListeners.size > 0;
566
627
  }
567
628
 
568
629
  private ensureSocket(): void {
569
630
  if (!this.shouldKeepSocketOpen() || this.ws || this.reconnectTimer) return;
570
631
 
571
632
  const socketToken = this.getSocketAuthToken();
572
- if (!socketToken) return;
573
633
  const usingWebSocketToken = !!this.websocketToken;
574
- const url = `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`;
634
+ const url = socketToken
635
+ ? `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`
636
+ : `${this.getWebSocketBaseUrl()}/cloud/ws`;
575
637
  cloudApiLog.info("open websocket", {
576
638
  hasToken: !!socketToken,
577
639
  tokenSource: usingWebSocketToken ? "websocket" : "session",
@@ -650,7 +712,12 @@ class GloomApiClient {
650
712
  if (this.ws?.readyState !== WebSocket.OPEN) return;
651
713
  if (payload && typeof payload === "object" && "type" in (payload as Record<string, unknown>)) {
652
714
  const type = (payload as Record<string, unknown>).type;
653
- if (type === "market.subscribe" || type === "market.unsubscribe" || type === "chat.subscribe") {
715
+ if (
716
+ type === "market.subscribe"
717
+ || type === "market.unsubscribe"
718
+ || type === "chat.subscribe"
719
+ || type === "chat.unsubscribe"
720
+ ) {
654
721
  cloudApiLog.info("send websocket message", payload);
655
722
  }
656
723
  }
@@ -667,10 +734,7 @@ class GloomApiClient {
667
734
  if (this.quoteTargets.size > 0) {
668
735
  this.sendSocketMessage({
669
736
  type: "market.subscribe",
670
- symbols: [...this.quoteTargets.values()].map((target) => ({
671
- symbol: target.symbol,
672
- exchange: target.exchange ?? "",
673
- })),
737
+ symbols: [...this.quoteTargets.values()].map((target) => this.serializeQuoteStreamTarget(target)),
674
738
  });
675
739
  }
676
740
  }
@@ -714,23 +778,28 @@ class GloomApiClient {
714
778
  if (subscribes.length > 0) {
715
779
  this.sendSocketMessage({
716
780
  type: "market.subscribe",
717
- symbols: subscribes.map((target) => ({
718
- symbol: target.symbol,
719
- exchange: target.exchange ?? "",
720
- })),
781
+ symbols: subscribes.map((target) => this.serializeQuoteStreamTarget(target)),
721
782
  });
722
783
  }
723
784
  if (unsubscribes.length > 0) {
724
785
  this.sendSocketMessage({
725
786
  type: "market.unsubscribe",
726
- symbols: unsubscribes.map((target) => ({
727
- symbol: target.symbol,
728
- exchange: target.exchange ?? "",
729
- })),
787
+ symbols: unsubscribes.map((target) => this.serializeQuoteStreamTarget(target)),
730
788
  });
731
789
  }
732
790
  }
733
791
 
792
+ private serializeQuoteStreamTarget(target: QuoteStreamTarget): QuoteStreamTarget {
793
+ return {
794
+ symbol: target.symbol,
795
+ exchange: target.exchange ?? "",
796
+ ...(target.surface ? { surface: target.surface } : {}),
797
+ ...(target.visible ? { visible: true } : {}),
798
+ ...(target.selected ? { selected: true } : {}),
799
+ ...(Number.isFinite(target.weight) ? { weight: target.weight } : {}),
800
+ };
801
+ }
802
+
734
803
  private async handleSocketMessage(raw: string): Promise<void> {
735
804
  let parsed: any;
736
805
  try {
@@ -761,6 +830,9 @@ class GloomApiClient {
761
830
  if (this.currentUser) {
762
831
  this.currentUser = { ...this.currentUser, emailVerified: false };
763
832
  }
833
+ if (this.quoteTargets.size > 0) {
834
+ return;
835
+ }
764
836
  this.teardownSocket();
765
837
  return;
766
838
  }
@@ -773,6 +845,21 @@ class GloomApiClient {
773
845
  return;
774
846
  }
775
847
 
848
+ if (parsed?.type === "chat.notification" && parsed.data) {
849
+ const notification = normalizeChatNotification(parsed.data as ChatNotification);
850
+ for (const listener of this.chatNotificationListeners) {
851
+ listener(notification);
852
+ }
853
+ return;
854
+ }
855
+
856
+ if (parsed?.type === "chat.presence" && typeof parsed.onlineCount === "number") {
857
+ for (const listener of this.chatPresenceListeners) {
858
+ listener(parsed.onlineCount);
859
+ }
860
+ return;
861
+ }
862
+
776
863
  if (parsed?.type === "market.quote" && parsed.quote && typeof parsed.symbol === "string") {
777
864
  const key = marketKey(parsed.symbol, parsed.exchange);
778
865
  const target = this.quoteTargets.get(key) ?? {
@@ -853,6 +940,32 @@ class GloomApiClient {
853
940
  return this.request<ChatChannel[]>("/chat/channels");
854
941
  }
855
942
 
943
+ async getChatPresence(): Promise<{ onlineCount: number }> {
944
+ return this.request<{ onlineCount: number }>("/chat/presence");
945
+ }
946
+
947
+ async getChatState(): Promise<ChatStateResponse> {
948
+ const state = await this.request<ChatStateResponse>("/chat/state");
949
+ return normalizeChatState(state);
950
+ }
951
+
952
+ async updateChatChannelState(
953
+ channelId: string,
954
+ body: { notificationsEnabled?: boolean; readThroughMessageId?: string },
955
+ ): Promise<ChatChannelState> {
956
+ return this.request<ChatChannelState>(`/chat/channels/${channelId}/state`, {
957
+ method: "PATCH",
958
+ body: JSON.stringify(body),
959
+ });
960
+ }
961
+
962
+ async markChatNotificationsDelivered(notificationIds: string[]): Promise<{ delivered: number }> {
963
+ return this.request<{ delivered: number }>("/chat/notifications/delivered", {
964
+ method: "POST",
965
+ body: JSON.stringify({ notificationIds }),
966
+ });
967
+ }
968
+
856
969
  async getMessages(
857
970
  channelId: string,
858
971
  opts?: { after?: string; before?: string; limit?: number },
@@ -866,10 +979,10 @@ class GloomApiClient {
866
979
  return normalizeChatMessages(messages);
867
980
  }
868
981
 
869
- async sendMessage(channelId: string, content: string, replyToId?: string): Promise<ChatMessage> {
982
+ async sendMessage(channelId: string, content: string, replyToId?: string, clientMessageId?: string): Promise<ChatMessage> {
870
983
  const message = await this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
871
984
  method: "POST",
872
- body: JSON.stringify({ content, replyToId }),
985
+ body: JSON.stringify({ content, replyToId, clientMessageId }),
873
986
  });
874
987
  return normalizeChatMessage(message);
875
988
  }
@@ -878,7 +991,7 @@ class GloomApiClient {
878
991
  channelId: string,
879
992
  onMessage: (msg: ChatMessage) => void,
880
993
  onError?: (err: string) => void,
881
- ): { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void } {
994
+ ): { send: (content: string, replyToId?: string, clientMessageId?: string) => Promise<ChatMessage>; close: () => void } {
882
995
  if (!channelId) {
883
996
  return {
884
997
  send: async () => {
@@ -898,9 +1011,9 @@ class GloomApiClient {
898
1011
  }
899
1012
 
900
1013
  return {
901
- send: async (content: string, replyToId?: string) => {
1014
+ send: async (content: string, replyToId?: string, clientMessageId?: string) => {
902
1015
  try {
903
- const message = await this.sendMessage(channelId, content, replyToId);
1016
+ const message = await this.sendMessage(channelId, content, replyToId, clientMessageId);
904
1017
  onMessage(message);
905
1018
  return message;
906
1019
  } catch (error) {
@@ -914,6 +1027,7 @@ class GloomApiClient {
914
1027
  current.delete(onMessage);
915
1028
  if (current.size === 0) {
916
1029
  this.channelListeners.delete(channelId);
1030
+ this.sendSocketMessage({ type: "chat.unsubscribe", channelId });
917
1031
  }
918
1032
  if (!this.shouldKeepSocketOpen()) {
919
1033
  this.teardownSocket();
@@ -922,6 +1036,20 @@ class GloomApiClient {
922
1036
  };
923
1037
  }
924
1038
 
1039
+ subscribeChatNotifications(listener: ChatNotificationListener): () => void {
1040
+ this.chatNotificationListeners.add(listener);
1041
+ return () => {
1042
+ this.chatNotificationListeners.delete(listener);
1043
+ };
1044
+ }
1045
+
1046
+ subscribeChatPresence(listener: ChatPresenceListener): () => void {
1047
+ this.chatPresenceListeners.add(listener);
1048
+ return () => {
1049
+ this.chatPresenceListeners.delete(listener);
1050
+ };
1051
+ }
1052
+
925
1053
  subscribeQuotes(
926
1054
  targets: QuoteStreamTarget[],
927
1055
  onQuote: (target: QuoteStreamTarget, quote: CloudQuotePayload) => void,
@@ -933,17 +1061,27 @@ class GloomApiClient {
933
1061
  const normalized = {
934
1062
  symbol: normalizeSymbol(target.symbol),
935
1063
  exchange: canonicalExchange(target.exchange),
1064
+ surface: target.surface,
1065
+ visible: target.visible,
1066
+ selected: target.selected,
1067
+ weight: target.weight,
936
1068
  } satisfies QuoteStreamTarget;
937
1069
  return [marketKey(normalized.symbol, normalized.exchange), normalized] as const;
938
1070
  }),
939
1071
  ).values()];
940
1072
 
941
1073
  const newSubscriptions: QuoteStreamTarget[] = [];
1074
+ const updatedSubscriptions: QuoteStreamTarget[] = [];
942
1075
  for (const target of uniqueTargets) {
943
1076
  const key = marketKey(target.symbol, target.exchange);
944
1077
  const listeners = this.quoteListeners.get(key) ?? new Set<QuoteListener>();
945
1078
  if (listeners.size === 0) {
946
1079
  newSubscriptions.push(target);
1080
+ } else {
1081
+ const existing = this.quoteTargets.get(key);
1082
+ if (JSON.stringify(this.serializeQuoteStreamTarget(existing ?? target)) !== JSON.stringify(this.serializeQuoteStreamTarget(target))) {
1083
+ updatedSubscriptions.push(target);
1084
+ }
947
1085
  }
948
1086
  listeners.add(onQuote);
949
1087
  this.quoteListeners.set(key, listeners);
@@ -951,12 +1089,13 @@ class GloomApiClient {
951
1089
  }
952
1090
 
953
1091
  this.ensureSocket();
954
- if (newSubscriptions.length > 0) {
1092
+ const subscriptionsToSend = [...newSubscriptions, ...updatedSubscriptions];
1093
+ if (subscriptionsToSend.length > 0) {
955
1094
  cloudApiLog.info("register quote listeners", {
956
- count: newSubscriptions.length,
957
- symbols: newSubscriptions.map((target) => marketKey(target.symbol, target.exchange)),
1095
+ count: subscriptionsToSend.length,
1096
+ symbols: subscriptionsToSend.map((target) => marketKey(target.symbol, target.exchange)),
958
1097
  });
959
- this.queueQuoteSubscribes(newSubscriptions);
1098
+ this.queueQuoteSubscribes(subscriptionsToSend);
960
1099
  }
961
1100
 
962
1101
  return () => {
@@ -997,6 +1136,8 @@ class GloomApiClient {
997
1136
  channelTargets: this.channelListeners.size,
998
1137
  });
999
1138
  this.channelListeners.clear();
1139
+ this.chatNotificationListeners.clear();
1140
+ this.chatPresenceListeners.clear();
1000
1141
  this.quoteListeners.clear();
1001
1142
  this.quoteTargets.clear();
1002
1143
  this.pendingQuoteSubscribes.clear();
@@ -1034,6 +1175,17 @@ class GloomApiClient {
1034
1175
  });
1035
1176
  }
1036
1177
 
1178
+ async getCloudOptionsChain(
1179
+ symbol: string,
1180
+ exchange?: string,
1181
+ expirationDate?: number,
1182
+ ): Promise<CloudMarketResponse<CloudOptionsChainPayload>> {
1183
+ const params = new URLSearchParams({ symbol });
1184
+ if (exchange) params.set("exchange", exchange);
1185
+ if (expirationDate != null) params.set("expirationDate", String(expirationDate));
1186
+ return this.request<CloudMarketResponse<CloudOptionsChainPayload>>(`/market/options?${params.toString()}`);
1187
+ }
1188
+
1037
1189
  async getCloudProfile(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudCompanyProfile>> {
1038
1190
  const params = new URLSearchParams({ symbol });
1039
1191
  if (exchange) params.set("exchange", exchange);