gloomberb 0.7.2 → 0.7.3

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 (129) 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 +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +21 -3
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/utils/api-client.test.ts +128 -0
  124. package/src/utils/api-client.ts +112 -7
  125. package/src/utils/fuzzy-search.ts +12 -1
  126. package/src/utils/keyboard.ts +31 -0
  127. package/src/utils/ticker-search.test.ts +2 -2
  128. package/src/utils/ticker-search.ts +48 -1
  129. package/src/version.ts +1 -1
@@ -329,6 +329,64 @@ describe("pane state updates", () => {
329
329
 
330
330
  expect(next).toBe(initial);
331
331
  });
332
+
333
+ test("keeps pane runtime state scoped to each saved layout", () => {
334
+ let state = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
335
+ const originalLayoutIndex = state.config.activeLayoutIndex;
336
+
337
+ state = appReducer(state, {
338
+ type: "UPDATE_PANE_STATE",
339
+ paneId: "ticker-detail:main",
340
+ patch: { activeTabId: "chart" },
341
+ });
342
+ state = appReducer(state, { type: "DUPLICATE_LAYOUT", index: originalLayoutIndex });
343
+ const duplicateLayoutIndex = state.config.activeLayoutIndex;
344
+
345
+ state = appReducer(state, {
346
+ type: "UPDATE_PANE_STATE",
347
+ paneId: "ticker-detail:main",
348
+ patch: { activeTabId: "financials" },
349
+ });
350
+
351
+ expect(state.config.layouts[duplicateLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("financials");
352
+
353
+ state = appReducer(state, { type: "SWITCH_LAYOUT", index: originalLayoutIndex });
354
+ expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
355
+ expect(state.config.layouts[originalLayoutIndex]?.paneState?.["ticker-detail:main"]?.activeTabId).toBe("chart");
356
+
357
+ state = appReducer(state, { type: "SWITCH_LAYOUT", index: duplicateLayoutIndex });
358
+ expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("financials");
359
+ });
360
+
361
+ test("hydrates saved layout pane state before legacy session pane state", () => {
362
+ const config = createDefaultConfig("/tmp/gloomberb-test");
363
+ config.layouts[config.activeLayoutIndex] = {
364
+ ...config.layouts[config.activeLayoutIndex]!,
365
+ paneState: {
366
+ "ticker-detail:main": { activeTabId: "chart" },
367
+ },
368
+ focusedPaneId: "ticker-detail:main",
369
+ activePanel: "right",
370
+ };
371
+ const sessionSnapshot: AppSessionSnapshot = {
372
+ paneState: {
373
+ "ticker-detail:main": { activeTabId: "overview" },
374
+ },
375
+ focusedPaneId: "portfolio-list:main",
376
+ activePanel: "left",
377
+ statusBarVisible: true,
378
+ openPaneIds: ["portfolio-list:main", "ticker-detail:main"],
379
+ hydrationTargets: [],
380
+ exchangeCurrencies: ["USD"],
381
+ savedAt: Date.now(),
382
+ };
383
+
384
+ const state = createInitialState(config, sessionSnapshot);
385
+
386
+ expect(state.paneState["ticker-detail:main"]?.activeTabId).toBe("chart");
387
+ expect(state.focusedPaneId).toBe("ticker-detail:main");
388
+ expect(state.activePanel).toBe("right");
389
+ });
332
390
  });
333
391
 
334
392
  describe("quote merging", () => {
@@ -487,6 +545,61 @@ describe("quote merging", () => {
487
545
  });
488
546
 
489
547
  describe("layout focus fallback", () => {
548
+ test("switching to an old layout without focused metadata uses that layout's top floating pane", () => {
549
+ const config = createDefaultConfig("/tmp/gloomberb-test");
550
+ const targetLayout = {
551
+ ...cloneLayout(config.layout),
552
+ dockRoot: null,
553
+ floating: [
554
+ { instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
555
+ { instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
556
+ ],
557
+ };
558
+ config.layouts = [
559
+ { name: "Current", layout: cloneLayout(config.layout), paneState: {} },
560
+ { name: "Floating", layout: cloneLayout(targetLayout), paneState: {} },
561
+ ];
562
+ config.activeLayoutIndex = 0;
563
+
564
+ let state = createInitialState(config);
565
+ state = appReducer(state, { type: "FOCUS_PANE", paneId: "chat:main" });
566
+ state = appReducer(state, { type: "SWITCH_LAYOUT", index: 1 });
567
+
568
+ const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
569
+ const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
570
+ expect(state.focusedPaneId).toBe("ticker-detail:main");
571
+ expect((tickerEntry?.zIndex ?? 0) > (chatEntry?.zIndex ?? 0)).toBe(true);
572
+ });
573
+
574
+ test("switching layouts raises the saved focused floating pane above stale z-order", () => {
575
+ const config = createDefaultConfig("/tmp/gloomberb-test");
576
+ const targetLayout = {
577
+ ...cloneLayout(config.layout),
578
+ dockRoot: null,
579
+ floating: [
580
+ { instanceId: "chat:main", x: 4, y: 2, width: 36, height: 10, zIndex: 70 },
581
+ { instanceId: "ticker-detail:main", x: 12, y: 4, width: 36, height: 10, zIndex: 95 },
582
+ ],
583
+ };
584
+ config.layouts = [
585
+ { name: "Current", layout: cloneLayout(config.layout), paneState: {} },
586
+ {
587
+ name: "Floating",
588
+ layout: cloneLayout(targetLayout),
589
+ paneState: {},
590
+ focusedPaneId: "chat:main",
591
+ },
592
+ ];
593
+ config.activeLayoutIndex = 0;
594
+
595
+ const state = appReducer(createInitialState(config), { type: "SWITCH_LAYOUT", index: 1 });
596
+
597
+ const chatEntry = state.config.layout.floating.find((entry) => entry.instanceId === "chat:main");
598
+ const tickerEntry = state.config.layout.floating.find((entry) => entry.instanceId === "ticker-detail:main");
599
+ expect(state.focusedPaneId).toBe("chat:main");
600
+ expect((chatEntry?.zIndex ?? 0) > (tickerEntry?.zIndex ?? 0)).toBe(true);
601
+ });
602
+
490
603
  test("starts with the top floating pane focused when no session pane is active", () => {
491
604
  const config = createDefaultConfig("/tmp/gloomberb-test");
492
605
  const layout = {
@@ -37,6 +37,7 @@ import {
37
37
  type PaneRuntimeState,
38
38
  resolveCollectionForPane,
39
39
  resolveTickerForPane,
40
+ syncConfigActiveLayoutState,
40
41
  } from "../core/state/app-state";
41
42
  import type { AppAction, AppState } from "../core/state/app-state";
42
43
  import { scheduleConfigSave } from "./config-save-scheduler";
@@ -50,6 +51,7 @@ export {
50
51
  getPaneState,
51
52
  resolveCollectionForPane,
52
53
  resolveFinancialsForPane,
54
+ syncConfigActiveLayoutState,
53
55
  resolveTickerFileForPane,
54
56
  resolveTickerForPane,
55
57
  } from "../core/state/app-state";
@@ -283,10 +285,12 @@ export function usePaneSettingValue<T>(
283
285
  ? (nextValue as (previousValue: T) => T)(currentValue)
284
286
  : nextValue;
285
287
  const layout = setPaneSetting(currentState.config.layout, scopedPaneId, key, resolved);
286
- const layouts = currentState.config.layouts.map((savedLayout, index) => (
287
- index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
288
- ));
289
- const nextConfig = { ...currentState.config, layout, layouts };
288
+ const nextConfig = syncConfigActiveLayoutState(
289
+ { ...currentState.config, layout },
290
+ currentState.paneState,
291
+ currentState.focusedPaneId,
292
+ currentState.activePanel,
293
+ );
290
294
  dispatch({ type: "SET_CONFIG", config: nextConfig });
291
295
  scheduleConfigSave(nextConfig);
292
296
  }, [dispatch, fallback, key, scopedPaneId, stateRef]);
@@ -73,21 +73,20 @@ export function syncTheme(id: string): void {
73
73
 
74
74
  export { blendHex } from "./color-utils";
75
75
 
76
- const COMPARISON_SERIES_COLORS = [
77
- "#5bc0eb",
78
- "#f6ae2d",
79
- "#f26419",
80
- "#6f2dbd",
81
- "#00a896",
82
- "#ef476f",
83
- "#118ab2",
84
- "#8ac926",
85
- "#ff7f51",
86
- "#4361ee",
87
- ] as const;
88
-
89
76
  export function getComparisonSeriesColor(index: number): string {
90
- return COMPARISON_SERIES_COLORS[((index % COMPARISON_SERIES_COLORS.length) + COMPARISON_SERIES_COLORS.length) % COMPARISON_SERIES_COLORS.length]!;
77
+ const seriesColors = [
78
+ colors.borderFocused,
79
+ colors.warning,
80
+ colors.positive,
81
+ colors.negative,
82
+ colors.textBright,
83
+ colors.neutral,
84
+ blendHex(colors.borderFocused, colors.positive, 0.45),
85
+ blendHex(colors.warning, colors.negative, 0.35),
86
+ blendHex(colors.textBright, colors.positive, 0.35),
87
+ blendHex(colors.borderFocused, colors.negative, 0.35),
88
+ ];
89
+ return seriesColors[((index % seriesColors.length) + seriesColors.length) % seriesColors.length]!;
91
90
  }
92
91
 
93
92
  /** Returns a hover background color derived from bg and selected */
@@ -1,8 +1,8 @@
1
1
  import type { Portfolio, Watchlist } from "./ticker";
2
2
 
3
- export const CURRENT_CONFIG_VERSION = 15;
3
+ export const CURRENT_CONFIG_VERSION = 16;
4
4
 
5
- export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc";
5
+ export type DefaultChartRenderMode = "area" | "line" | "candles" | "ohlc" | "hlc";
6
6
  export type ChartRendererPreference = "auto" | "kitty" | "braille";
7
7
 
8
8
  export interface ChartPreferences {
@@ -108,8 +108,12 @@ export interface LayoutConfig {
108
108
  }
109
109
 
110
110
  export interface SavedLayout {
111
+ id?: string;
111
112
  name: string;
112
113
  layout: LayoutConfig;
114
+ paneState?: Record<string, Record<string, unknown>>;
115
+ focusedPaneId?: string | null;
116
+ activePanel?: "left" | "right";
113
117
  }
114
118
 
115
119
  export interface AppConfig {
@@ -154,6 +158,7 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
154
158
 
155
159
  export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
156
160
  ...DEFAULT_COLUMNS.map((column) => column.id),
161
+ "sparkline",
157
162
  "shares",
158
163
  "avg_cost",
159
164
  "cost_basis",
@@ -585,8 +590,8 @@ export function createDefaultConfig(dataDir: string): AppConfig {
585
590
  watchlists: [{ id: "watchlist", name: "Watchlist" }],
586
591
  layout,
587
592
  layouts: [
588
- { name: "Home", layout: cloneLayout(layout) },
589
- { name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT) },
593
+ { name: "Home", layout: cloneLayout(layout), paneState: {} },
594
+ { name: "Monitor", layout: cloneLayout(DEFAULT_MONITOR_LAYOUT), paneState: {} },
590
595
  ],
591
596
  activeLayoutIndex: 0,
592
597
  brokerInstances: [],
@@ -110,6 +110,7 @@ export type PaneSettingField =
110
110
 
111
111
  export interface PaneSettingsDef {
112
112
  title?: string;
113
+ values?: Record<string, unknown>;
113
114
  fields: PaneSettingField[];
114
115
  }
115
116
 
@@ -406,6 +407,12 @@ export interface BrokerInstanceUpdateOptions {
406
407
  replaceConfig?: boolean;
407
408
  }
408
409
 
410
+ export interface PinTickerOptions {
411
+ floating?: boolean;
412
+ paneType?: string;
413
+ forceNewPane?: boolean;
414
+ }
415
+
409
416
  export interface GloomPluginContext {
410
417
  registerPane(pane: PaneDef): void;
411
418
  registerPaneTemplate(template: PaneTemplateDef): void;
@@ -448,7 +455,7 @@ export interface GloomPluginContext {
448
455
  createPaneFromTemplate(templateId: string, options?: PaneTemplateCreateOptions): void;
449
456
  hidePane(paneId: string): void;
450
457
  focusPane(paneId: string): void;
451
- pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
458
+ pinTicker(symbol: string, options?: PinTickerOptions): void;
452
459
  navigateTicker(symbol: string, options?: { sourcePaneId?: string | null }): void;
453
460
  openPaneSettings(paneId?: string): void;
454
461
 
@@ -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",
@@ -282,6 +282,134 @@ describe("apiClient chat timestamps", () => {
282
282
  expect(seenCreatedAts).toEqual(["2026-04-08T07:28:27.625Z"]);
283
283
  channel.close();
284
284
  });
285
+
286
+ test("fetches chat state and normalizes pending notification timestamps", async () => {
287
+ let requestedUrl = "";
288
+ globalThis.fetch = mockFetch(async (input: Request | string | URL) => {
289
+ requestedUrl = String(input);
290
+ return createResponse({
291
+ channels: [{ id: "everyone", name: "everyone", created_at: "2026-04-08T07:00:00.000Z" }],
292
+ onlineCount: 2,
293
+ channelStates: [{
294
+ channelId: "everyone",
295
+ notificationsEnabled: true,
296
+ lastReadMessageId: "m1",
297
+ unreadCount: 1,
298
+ }],
299
+ notifications: [{
300
+ id: "n1",
301
+ type: "reply",
302
+ channelId: "everyone",
303
+ messageId: "m2",
304
+ createdAt: "2026-04-08 07:30:00.000",
305
+ message: {
306
+ id: "m2",
307
+ channelId: "everyone",
308
+ content: "reply",
309
+ replyToId: "m1",
310
+ createdAt: "2026-04-08 07:29:00.000",
311
+ user: { id: "u2", username: "bob", displayName: "Bob" },
312
+ replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
313
+ },
314
+ }],
315
+ });
316
+ });
317
+
318
+ const state = await apiClient.getChatState();
319
+
320
+ expect(new URL(requestedUrl).pathname).toBe("/chat/state");
321
+ expect(state.onlineCount).toBe(2);
322
+ expect(state.notifications[0]?.createdAt).toBe("2026-04-08T07:30:00.000Z");
323
+ expect(state.notifications[0]?.message.createdAt).toBe("2026-04-08T07:29:00.000Z");
324
+ });
325
+
326
+ test("updates chat channel state and marks notifications delivered", async () => {
327
+ const requests: Array<{ path: string; method: string; body: unknown }> = [];
328
+ const responses = [
329
+ createResponse({
330
+ channelId: "everyone",
331
+ notificationsEnabled: true,
332
+ lastReadMessageId: "m2",
333
+ unreadCount: 0,
334
+ }),
335
+ createResponse({ delivered: 2 }),
336
+ createResponse({ onlineCount: 4 }),
337
+ ];
338
+ globalThis.fetch = mockFetch(async (input: Request | string | URL, init?: RequestInit) => {
339
+ requests.push({
340
+ path: new URL(String(input)).pathname,
341
+ method: init?.method ?? "GET",
342
+ body: init?.body ? JSON.parse(String(init.body)) : null,
343
+ });
344
+ return responses.shift() as Response;
345
+ });
346
+
347
+ await apiClient.updateChatChannelState("everyone", {
348
+ notificationsEnabled: true,
349
+ readThroughMessageId: "m2",
350
+ });
351
+ await apiClient.markChatNotificationsDelivered(["n1", "n2"]);
352
+ const presence = await apiClient.getChatPresence();
353
+
354
+ expect(requests).toEqual([
355
+ {
356
+ path: "/chat/channels/everyone/state",
357
+ method: "PATCH",
358
+ body: { notificationsEnabled: true, readThroughMessageId: "m2" },
359
+ },
360
+ {
361
+ path: "/chat/notifications/delivered",
362
+ method: "POST",
363
+ body: { notificationIds: ["n1", "n2"] },
364
+ },
365
+ {
366
+ path: "/chat/presence",
367
+ method: "GET",
368
+ body: null,
369
+ },
370
+ ]);
371
+ expect(presence).toEqual({ onlineCount: 4 });
372
+ });
373
+
374
+ test("emits websocket chat presence and notification events", async () => {
375
+ const seenPresence: number[] = [];
376
+ const seenNotifications: string[] = [];
377
+ const unsubscribePresence = apiClient.subscribeChatPresence((onlineCount) => {
378
+ seenPresence.push(onlineCount);
379
+ });
380
+ const unsubscribeNotifications = apiClient.subscribeChatNotifications((notification) => {
381
+ seenNotifications.push(`${notification.id}:${notification.message.createdAt}`);
382
+ });
383
+
384
+ await (apiClient as any).handleSocketMessage(JSON.stringify({
385
+ type: "chat.presence",
386
+ onlineCount: 5,
387
+ }));
388
+ await (apiClient as any).handleSocketMessage(JSON.stringify({
389
+ type: "chat.notification",
390
+ data: {
391
+ id: "n1",
392
+ type: "reply",
393
+ channelId: "everyone",
394
+ messageId: "m2",
395
+ createdAt: "2026-04-08 07:30:00.000",
396
+ message: {
397
+ id: "m2",
398
+ channelId: "everyone",
399
+ content: "reply",
400
+ replyToId: "m1",
401
+ createdAt: "2026-04-08 07:29:00.000",
402
+ user: { id: "u2", username: "bob", displayName: "Bob" },
403
+ replyTo: { content: "parent", user: { id: "u1", username: "vince" } },
404
+ },
405
+ },
406
+ }));
407
+
408
+ expect(seenPresence).toEqual([5]);
409
+ expect(seenNotifications).toEqual(["n1:2026-04-08T07:29:00.000Z"]);
410
+ unsubscribePresence();
411
+ unsubscribeNotifications();
412
+ });
285
413
  });
286
414
 
287
415
  describe("apiClient cloud news", () => {
@@ -48,7 +48,7 @@ export interface ChatMessage {
48
48
  replyToId: string | null;
49
49
  createdAt: string;
50
50
  user: { id: string; username: string; displayName: string };
51
- replyTo?: { content: string; user: { username: string } } | null;
51
+ replyTo?: { content: string; user: { id?: string; username: string } } | null;
52
52
  clientStatus?: "sending" | "failed";
53
53
  clientError?: string | null;
54
54
  }
@@ -59,6 +59,29 @@ export interface ChatChannel {
59
59
  created_at: string;
60
60
  }
61
61
 
62
+ export interface ChatChannelState {
63
+ channelId: string;
64
+ notificationsEnabled: boolean;
65
+ lastReadMessageId: string | null;
66
+ unreadCount: number;
67
+ }
68
+
69
+ export interface ChatNotification {
70
+ id: string;
71
+ type: "reply" | "mention" | "channel";
72
+ channelId: string;
73
+ messageId: string;
74
+ createdAt: string;
75
+ message: ChatMessage;
76
+ }
77
+
78
+ export interface ChatStateResponse {
79
+ channels: ChatChannel[];
80
+ onlineCount: number;
81
+ channelStates: ChatChannelState[];
82
+ notifications: ChatNotification[];
83
+ }
84
+
62
85
  export interface AuthUser {
63
86
  id: string;
64
87
  name: string;
@@ -352,6 +375,21 @@ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
352
375
  return messages.map((message) => normalizeChatMessage(message));
353
376
  }
354
377
 
378
+ function normalizeChatNotification(notification: ChatNotification): ChatNotification {
379
+ return {
380
+ ...notification,
381
+ createdAt: normalizeTimestamp(notification.createdAt),
382
+ message: normalizeChatMessage(notification.message),
383
+ };
384
+ }
385
+
386
+ function normalizeChatState(response: ChatStateResponse): ChatStateResponse {
387
+ return {
388
+ ...response,
389
+ notifications: response.notifications.map(normalizeChatNotification),
390
+ };
391
+ }
392
+
355
393
  function normalizeTweet(tweet: CloudTweetPayload): CloudTweetPayload {
356
394
  return {
357
395
  ...tweet,
@@ -370,6 +408,8 @@ function normalizeTweetSearchResponse(response: CloudTweetSearchResponse): Cloud
370
408
  }
371
409
 
372
410
  type ChannelListener = (message: ChatMessage) => void;
411
+ type ChatNotificationListener = (notification: ChatNotification) => void;
412
+ type ChatPresenceListener = (onlineCount: number) => void;
373
413
  type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
374
414
  type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
375
415
 
@@ -408,6 +448,8 @@ class GloomApiClient {
408
448
  private reconnectDelayMs = 1000;
409
449
 
410
450
  private readonly channelListeners = new Map<string, Set<ChannelListener>>();
451
+ private readonly chatNotificationListeners = new Set<ChatNotificationListener>();
452
+ private readonly chatPresenceListeners = new Set<ChatPresenceListener>();
411
453
  private readonly quoteListeners = new Map<string, Set<QuoteListener>>();
412
454
  private readonly quoteTargets = new Map<string, QuoteStreamTarget>();
413
455
  private readonly pendingQuoteSubscribes = new Map<string, QuoteStreamTarget>();
@@ -650,7 +692,12 @@ class GloomApiClient {
650
692
  if (this.ws?.readyState !== WebSocket.OPEN) return;
651
693
  if (payload && typeof payload === "object" && "type" in (payload as Record<string, unknown>)) {
652
694
  const type = (payload as Record<string, unknown>).type;
653
- if (type === "market.subscribe" || type === "market.unsubscribe" || type === "chat.subscribe") {
695
+ if (
696
+ type === "market.subscribe"
697
+ || type === "market.unsubscribe"
698
+ || type === "chat.subscribe"
699
+ || type === "chat.unsubscribe"
700
+ ) {
654
701
  cloudApiLog.info("send websocket message", payload);
655
702
  }
656
703
  }
@@ -773,6 +820,21 @@ class GloomApiClient {
773
820
  return;
774
821
  }
775
822
 
823
+ if (parsed?.type === "chat.notification" && parsed.data) {
824
+ const notification = normalizeChatNotification(parsed.data as ChatNotification);
825
+ for (const listener of this.chatNotificationListeners) {
826
+ listener(notification);
827
+ }
828
+ return;
829
+ }
830
+
831
+ if (parsed?.type === "chat.presence" && typeof parsed.onlineCount === "number") {
832
+ for (const listener of this.chatPresenceListeners) {
833
+ listener(parsed.onlineCount);
834
+ }
835
+ return;
836
+ }
837
+
776
838
  if (parsed?.type === "market.quote" && parsed.quote && typeof parsed.symbol === "string") {
777
839
  const key = marketKey(parsed.symbol, parsed.exchange);
778
840
  const target = this.quoteTargets.get(key) ?? {
@@ -853,6 +915,32 @@ class GloomApiClient {
853
915
  return this.request<ChatChannel[]>("/chat/channels");
854
916
  }
855
917
 
918
+ async getChatPresence(): Promise<{ onlineCount: number }> {
919
+ return this.request<{ onlineCount: number }>("/chat/presence");
920
+ }
921
+
922
+ async getChatState(): Promise<ChatStateResponse> {
923
+ const state = await this.request<ChatStateResponse>("/chat/state");
924
+ return normalizeChatState(state);
925
+ }
926
+
927
+ async updateChatChannelState(
928
+ channelId: string,
929
+ body: { notificationsEnabled?: boolean; readThroughMessageId?: string },
930
+ ): Promise<ChatChannelState> {
931
+ return this.request<ChatChannelState>(`/chat/channels/${channelId}/state`, {
932
+ method: "PATCH",
933
+ body: JSON.stringify(body),
934
+ });
935
+ }
936
+
937
+ async markChatNotificationsDelivered(notificationIds: string[]): Promise<{ delivered: number }> {
938
+ return this.request<{ delivered: number }>("/chat/notifications/delivered", {
939
+ method: "POST",
940
+ body: JSON.stringify({ notificationIds }),
941
+ });
942
+ }
943
+
856
944
  async getMessages(
857
945
  channelId: string,
858
946
  opts?: { after?: string; before?: string; limit?: number },
@@ -866,10 +954,10 @@ class GloomApiClient {
866
954
  return normalizeChatMessages(messages);
867
955
  }
868
956
 
869
- async sendMessage(channelId: string, content: string, replyToId?: string): Promise<ChatMessage> {
957
+ async sendMessage(channelId: string, content: string, replyToId?: string, clientMessageId?: string): Promise<ChatMessage> {
870
958
  const message = await this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
871
959
  method: "POST",
872
- body: JSON.stringify({ content, replyToId }),
960
+ body: JSON.stringify({ content, replyToId, clientMessageId }),
873
961
  });
874
962
  return normalizeChatMessage(message);
875
963
  }
@@ -878,7 +966,7 @@ class GloomApiClient {
878
966
  channelId: string,
879
967
  onMessage: (msg: ChatMessage) => void,
880
968
  onError?: (err: string) => void,
881
- ): { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void } {
969
+ ): { send: (content: string, replyToId?: string, clientMessageId?: string) => Promise<ChatMessage>; close: () => void } {
882
970
  if (!channelId) {
883
971
  return {
884
972
  send: async () => {
@@ -898,9 +986,9 @@ class GloomApiClient {
898
986
  }
899
987
 
900
988
  return {
901
- send: async (content: string, replyToId?: string) => {
989
+ send: async (content: string, replyToId?: string, clientMessageId?: string) => {
902
990
  try {
903
- const message = await this.sendMessage(channelId, content, replyToId);
991
+ const message = await this.sendMessage(channelId, content, replyToId, clientMessageId);
904
992
  onMessage(message);
905
993
  return message;
906
994
  } catch (error) {
@@ -914,6 +1002,7 @@ class GloomApiClient {
914
1002
  current.delete(onMessage);
915
1003
  if (current.size === 0) {
916
1004
  this.channelListeners.delete(channelId);
1005
+ this.sendSocketMessage({ type: "chat.unsubscribe", channelId });
917
1006
  }
918
1007
  if (!this.shouldKeepSocketOpen()) {
919
1008
  this.teardownSocket();
@@ -922,6 +1011,20 @@ class GloomApiClient {
922
1011
  };
923
1012
  }
924
1013
 
1014
+ subscribeChatNotifications(listener: ChatNotificationListener): () => void {
1015
+ this.chatNotificationListeners.add(listener);
1016
+ return () => {
1017
+ this.chatNotificationListeners.delete(listener);
1018
+ };
1019
+ }
1020
+
1021
+ subscribeChatPresence(listener: ChatPresenceListener): () => void {
1022
+ this.chatPresenceListeners.add(listener);
1023
+ return () => {
1024
+ this.chatPresenceListeners.delete(listener);
1025
+ };
1026
+ }
1027
+
925
1028
  subscribeQuotes(
926
1029
  targets: QuoteStreamTarget[],
927
1030
  onQuote: (target: QuoteStreamTarget, quote: CloudQuotePayload) => void,
@@ -997,6 +1100,8 @@ class GloomApiClient {
997
1100
  channelTargets: this.channelListeners.size,
998
1101
  });
999
1102
  this.channelListeners.clear();
1103
+ this.chatNotificationListeners.clear();
1104
+ this.chatPresenceListeners.clear();
1000
1105
  this.quoteListeners.clear();
1001
1106
  this.quoteTargets.clear();
1002
1107
  this.pendingQuoteSubscribes.clear();
@@ -5,7 +5,18 @@ export function fuzzyMatch(query: string, target: string): { match: boolean; sco
5
5
  const t = target.toLowerCase();
6
6
 
7
7
  if (q.length === 0) return { match: true, score: 0 };
8
- if (t.startsWith(q)) return { match: true, score: 1000 - q.length }; // exact prefix match
8
+ if (t === q) return { match: true, score: 2500 - q.length };
9
+
10
+ const tokens = t.split(/\s+/).filter(Boolean);
11
+ const exactTokenIndex = tokens.findIndex((token) => token === q);
12
+ if (exactTokenIndex >= 0) return { match: true, score: 2200 - q.length - exactTokenIndex };
13
+
14
+ const prefixTokenIndex = tokens.findIndex((token) => token.startsWith(q));
15
+ if (prefixTokenIndex >= 0) {
16
+ const token = tokens[prefixTokenIndex]!;
17
+ return { match: true, score: 1000 - q.length - prefixTokenIndex - (token.length - q.length) };
18
+ }
19
+
9
20
  if (t.includes(q)) return { match: true, score: 500 - q.length }; // substring match
10
21
 
11
22
  let qi = 0;