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
@@ -32,6 +32,7 @@ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
32
32
  import type { BrokerAccount } from "../../../types/trading";
33
33
  import { useDialog, type PromptContext } from "../../../ui/dialog";
34
34
  import { formatCurrency } from "../../../utils/format";
35
+ import { isPlainKey } from "../../../utils/keyboard";
35
36
  import { usePluginAppActions, usePluginBrokerActions } from "../../plugin-runtime";
36
37
  import {
37
38
  buildBrokerProfileRows,
@@ -47,7 +48,7 @@ type BrokerColumn = DataTableColumn & { id: BrokerColumnId };
47
48
  function stateColor(state: BrokerDisplayState): string {
48
49
  switch (state) {
49
50
  case "connected": return colors.positive;
50
- case "connecting": return "#e5c07b";
51
+ case "connecting": return colors.warning;
51
52
  case "error": return colors.negative;
52
53
  case "disabled": return colors.textMuted;
53
54
  case "unavailable": return colors.negative;
@@ -442,13 +443,13 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
442
443
  saveEdit().catch(() => {});
443
444
  return;
444
445
  }
445
- if (event.name === "up" || event.name === "k") {
446
+ if (isPlainKey(event, "up", "k")) {
446
447
  event.stopPropagation();
447
448
  const index = editKeys.indexOf(activeEditKey);
448
449
  setActiveEditKey(editKeys[Math.max(0, index - 1)] ?? "label");
449
450
  return;
450
451
  }
451
- if (event.name === "down" || event.name === "j" || event.name === "tab") {
452
+ if (isPlainKey(event, "down", "j", "tab")) {
452
453
  event.stopPropagation();
453
454
  const index = editKeys.indexOf(activeEditKey);
454
455
  setActiveEditKey(editKeys[Math.min(editKeys.length - 1, index + 1)] ?? "label");
@@ -1,7 +1,7 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, 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 ChatChannel, type ChatMessage } from "../../utils/api-client";
4
+ import { apiClient, type ChatChannel, type ChatMessage, type ChatNotification } from "../../utils/api-client";
5
5
  import { ChatController } from "./chat-controller";
6
6
 
7
7
  const TRANSCRIPT_KIND = "channel-transcript";
@@ -12,6 +12,12 @@ const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
12
12
  const originalGetSession = apiClient.getSession.bind(apiClient);
13
13
  const originalGetMessages = apiClient.getMessages.bind(apiClient);
14
14
  const originalGetChannels = apiClient.getChannels.bind(apiClient);
15
+ const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient);
16
+ const originalGetChatState = apiClient.getChatState.bind(apiClient);
17
+ const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient);
18
+ const originalMarkChatNotificationsDelivered = apiClient.markChatNotificationsDelivered.bind(apiClient);
19
+ const originalSubscribeChatNotifications = apiClient.subscribeChatNotifications.bind(apiClient);
20
+ const originalSubscribeChatPresence = apiClient.subscribeChatPresence.bind(apiClient);
15
21
 
16
22
  const SERVER_CHAT_CHANNELS: ChatChannel[] = [
17
23
  { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
@@ -100,6 +106,30 @@ class TrackingPersistence extends MemoryPersistence {
100
106
  }
101
107
  }
102
108
 
109
+ beforeEach(() => {
110
+ apiClient.getChatPresence = async () => ({ onlineCount: 0 });
111
+ apiClient.getChatState = async () => ({
112
+ channels: SERVER_CHAT_CHANNELS,
113
+ onlineCount: 0,
114
+ channelStates: SERVER_CHAT_CHANNELS.map((channel) => ({
115
+ channelId: channel.id,
116
+ notificationsEnabled: false,
117
+ lastReadMessageId: null,
118
+ unreadCount: 0,
119
+ })),
120
+ notifications: [],
121
+ });
122
+ apiClient.updateChatChannelState = async (channelId, body) => ({
123
+ channelId,
124
+ notificationsEnabled: body.notificationsEnabled ?? false,
125
+ lastReadMessageId: body.readThroughMessageId ?? null,
126
+ unreadCount: 0,
127
+ });
128
+ apiClient.markChatNotificationsDelivered = async () => ({ delivered: 1 });
129
+ apiClient.subscribeChatNotifications = () => () => {};
130
+ apiClient.subscribeChatPresence = () => () => {};
131
+ });
132
+
103
133
  afterEach(() => {
104
134
  apiClient.dispose();
105
135
  apiClient.setSessionToken(null);
@@ -107,6 +137,12 @@ afterEach(() => {
107
137
  apiClient.getSession = originalGetSession;
108
138
  apiClient.getMessages = originalGetMessages;
109
139
  apiClient.getChannels = originalGetChannels;
140
+ apiClient.getChatPresence = originalGetChatPresence;
141
+ apiClient.getChatState = originalGetChatState;
142
+ apiClient.updateChatChannelState = originalUpdateChatChannelState;
143
+ apiClient.markChatNotificationsDelivered = originalMarkChatNotificationsDelivered;
144
+ apiClient.subscribeChatNotifications = originalSubscribeChatNotifications;
145
+ apiClient.subscribeChatPresence = originalSubscribeChatPresence;
110
146
  });
111
147
 
112
148
  describe("ChatController", () => {
@@ -280,6 +316,7 @@ describe("ChatController", () => {
280
316
  const controller = new ChatController();
281
317
 
282
318
  apiClient.setSessionToken("token-123");
319
+ (controller as any).sessionToken = "token-123";
283
320
  (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
284
321
 
285
322
  controller.setAppActive(false);
@@ -297,6 +334,7 @@ describe("ChatController", () => {
297
334
  let closed = false;
298
335
 
299
336
  apiClient.setSessionToken("token-123");
337
+ (controller as any).sessionToken = "token-123";
300
338
  (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
301
339
  (controller as any).syncVerificationPolling();
302
340
  expect((controller as any).verificationPollTimer).not.toBeNull();
@@ -548,6 +586,54 @@ describe("ChatController", () => {
548
586
  });
549
587
  });
550
588
 
589
+ test("hydrates missing transcript cache without marking history unread", async () => {
590
+ const persistence = new MemoryPersistence();
591
+ const controller = new ChatController();
592
+ const notifications: AppNotificationRequest[] = [];
593
+ const history: ChatMessage = {
594
+ id: "m1",
595
+ channelId: "everyone",
596
+ content: "already seen",
597
+ replyToId: null,
598
+ createdAt: "2026-03-28T00:00:00.000Z",
599
+ user: { id: "u2", username: "bob", displayName: "Bob" },
600
+ };
601
+
602
+ persistence.setState("session", {
603
+ sessionToken: "token-123",
604
+ user: { id: "u1", username: "vince", emailVerified: true },
605
+ }, { schemaVersion: 1 });
606
+ persistence.setState("channel:everyone", {
607
+ draft: "",
608
+ replyToId: null,
609
+ lastCursor: "m1",
610
+ lastViewedMessageId: "m1",
611
+ }, { schemaVersion: 1 });
612
+
613
+ controller.setNotifier((notification) => {
614
+ notifications.push(notification);
615
+ });
616
+ controller.attachPersistence(persistence);
617
+
618
+ const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
619
+ apiClient.getMessages = async (channelId, opts) => {
620
+ calls.push({ channelId, opts });
621
+ return opts?.after ? [] : [history];
622
+ };
623
+
624
+ await controller.refreshMessages();
625
+
626
+ expect(calls).toEqual([{
627
+ channelId: "everyone",
628
+ opts: { limit: 50, after: undefined },
629
+ }]);
630
+ expect(controller.getSnapshot().messages).toEqual([history]);
631
+ expect(controller.getSnapshot().channelStates.find((entry) => entry.channelId === "everyone")).toMatchObject({
632
+ unreadCount: 0,
633
+ });
634
+ expect(notifications).toEqual([]);
635
+ });
636
+
551
637
  test("backfills from cached transcript when persisted cursor is ahead of the cache", async () => {
552
638
  const persistence = new MemoryPersistence();
553
639
  const controller = new ChatController();
@@ -671,6 +757,40 @@ describe("ChatController", () => {
671
757
  expect(snapshot.messages).toEqual([replyTarget, sentMessage]);
672
758
  });
673
759
 
760
+ test("sends one idempotency key while the same message is pending", () => {
761
+ const persistence = new MemoryPersistence();
762
+ const controller = new ChatController();
763
+ const clientMessageIds: Array<string | undefined> = [];
764
+
765
+ persistence.setState("session", {
766
+ sessionToken: "token-123",
767
+ user: { id: "u1", username: "vince", emailVerified: true },
768
+ }, { schemaVersion: 1 });
769
+
770
+ controller.attachPersistence(persistence);
771
+ apiClient.getMessages = async () => [];
772
+ apiClient.connectChannel = () => ({
773
+ send: (_content, _replyToId, clientMessageId) => {
774
+ clientMessageIds.push(clientMessageId);
775
+ return new Promise<ChatMessage>(() => {});
776
+ },
777
+ close: () => {},
778
+ });
779
+
780
+ const detachFirstView = controller.attachChannelView("everyone");
781
+ const detachSecondView = controller.attachChannelView("everyone");
782
+
783
+ controller.sendToChannel("everyone", " hello ");
784
+ controller.sendToChannel("everyone", "hello");
785
+
786
+ expect(clientMessageIds).toHaveLength(1);
787
+ expect(clientMessageIds[0]).toBeTruthy();
788
+ expect(controller.getSnapshot("everyone").messages).toHaveLength(1);
789
+
790
+ detachFirstView();
791
+ detachSecondView();
792
+ });
793
+
674
794
  test("marks a pending message as failed when sending errors", async () => {
675
795
  const persistence = new MemoryPersistence();
676
796
  const controller = new ChatController();
@@ -706,7 +826,7 @@ describe("ChatController", () => {
706
826
  expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
707
827
  });
708
828
 
709
- test("tracks unread mentions and emits an app notification while chat is closed", () => {
829
+ test("tracks unread mentions from fetched messages without issuing local notifications", () => {
710
830
  const persistence = new MemoryPersistence();
711
831
  const controller = new ChatController();
712
832
  const notifications: AppNotificationRequest[] = [];
@@ -732,19 +852,14 @@ describe("ChatController", () => {
732
852
  (controller as any).mergeMessages([message]);
733
853
 
734
854
  expect(controller.getSnapshot().unreadMentionCount).toBe(1);
735
- expect(notifications).toEqual([{
736
- title: "Gloomberb chat",
737
- body: "@bob mentioned you: hey @Vince can you take a look?",
738
- type: "info",
739
- desktop: "when-inactive",
740
- }]);
855
+ expect(notifications).toEqual([]);
741
856
  expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
742
857
  lastCursor: "m1",
743
858
  lastViewedMessageId: null,
744
859
  });
745
860
  });
746
861
 
747
- test("keeps emitting mention notifications while the app is backgrounded", () => {
862
+ test("displays server-issued mention notifications while the app is backgrounded", () => {
748
863
  const persistence = new MemoryPersistence();
749
864
  const controller = new ChatController();
750
865
  const notifications: AppNotificationRequest[] = [];
@@ -768,10 +883,18 @@ describe("ChatController", () => {
768
883
  controller.attachPersistence(persistence);
769
884
  controller.setAppActive(false);
770
885
 
771
- (controller as any).mergeMessages([message]);
886
+ (controller as any).handleChatNotification({
887
+ id: "n1",
888
+ type: "mention",
889
+ channelId: "everyone",
890
+ messageId: "m1",
891
+ createdAt: "2026-03-28T00:00:00.000Z",
892
+ message,
893
+ } satisfies ChatNotification);
772
894
 
773
895
  expect(notifications).toEqual([{
774
896
  title: "Gloomberb chat",
897
+ subtitle: "#everyone",
775
898
  body: "@bob mentioned you: hey @vince",
776
899
  type: "info",
777
900
  desktop: "when-inactive",
@@ -808,12 +931,7 @@ describe("ChatController", () => {
808
931
  const detachView = controller.attachView();
809
932
 
810
933
  expect(controller.getSnapshot().unreadMentionCount).toBe(0);
811
- expect(notifications).toEqual([{
812
- title: "Gloomberb chat",
813
- body: "@bob mentioned you: hey @vince",
814
- type: "info",
815
- desktop: "when-inactive",
816
- }]);
934
+ expect(notifications).toEqual([]);
817
935
  expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
818
936
  lastViewedMessageId: "m1",
819
937
  });
@@ -855,6 +973,269 @@ describe("ChatController", () => {
855
973
  detachView();
856
974
  });
857
975
 
976
+ test("loads server chat state, pending reply notifications, and acks delivery", async () => {
977
+ const persistence = new MemoryPersistence();
978
+ const controller = new ChatController();
979
+ const notifications: AppNotificationRequest[] = [];
980
+ const deliveredIds: string[][] = [];
981
+
982
+ persistence.setState("session", {
983
+ sessionToken: "token-123",
984
+ user: { id: "u1", username: "vince", emailVerified: true },
985
+ }, { schemaVersion: 1 });
986
+ apiClient.getSession = async () => ({
987
+ id: "u1",
988
+ name: "Vince",
989
+ email: "vince@example.com",
990
+ username: "vince",
991
+ emailVerified: true,
992
+ image: null,
993
+ createdAt: "2026-01-01T00:00:00.000Z",
994
+ updatedAt: "2026-01-01T00:00:00.000Z",
995
+ });
996
+ apiClient.getChatState = async () => ({
997
+ channels: SERVER_CHAT_CHANNELS,
998
+ onlineCount: 7,
999
+ channelStates: [{
1000
+ channelId: "options",
1001
+ notificationsEnabled: true,
1002
+ lastReadMessageId: "m1",
1003
+ unreadCount: 3,
1004
+ }],
1005
+ notifications: [{
1006
+ id: "n1",
1007
+ type: "reply",
1008
+ channelId: "options",
1009
+ messageId: "m2",
1010
+ createdAt: "2026-03-28T00:02:00.000Z",
1011
+ message: {
1012
+ id: "m2",
1013
+ channelId: "options",
1014
+ content: "answering you",
1015
+ replyToId: "m1",
1016
+ createdAt: "2026-03-28T00:02:00.000Z",
1017
+ user: { id: "u2", username: "bob", displayName: "Bob" },
1018
+ replyTo: { content: "question", user: { id: "u1", username: "vince" } },
1019
+ },
1020
+ }],
1021
+ });
1022
+ apiClient.markChatNotificationsDelivered = async (ids) => {
1023
+ deliveredIds.push(ids);
1024
+ return { delivered: ids.length };
1025
+ };
1026
+ apiClient.connectChannel = () => ({
1027
+ send: async () => {
1028
+ throw new Error("not implemented");
1029
+ },
1030
+ close: () => {},
1031
+ });
1032
+
1033
+ controller.setNotifier((notification) => {
1034
+ notifications.push(notification);
1035
+ });
1036
+ controller.attachPersistence(persistence);
1037
+
1038
+ await controller.refreshSession();
1039
+
1040
+ expect(controller.getSnapshot("options").onlineCount).toBe(7);
1041
+ expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
1042
+ notificationsEnabled: true,
1043
+ unreadCount: 3,
1044
+ });
1045
+ expect(notifications).toEqual([{
1046
+ title: "Gloomberb chat",
1047
+ subtitle: "#options",
1048
+ body: "@bob replied to you: answering you",
1049
+ type: "info",
1050
+ desktop: "when-inactive",
1051
+ }]);
1052
+ expect(deliveredIds).toEqual([["n1"]]);
1053
+ });
1054
+
1055
+ test("toggles channel notifications optimistically and keeps the channel connected", async () => {
1056
+ const persistence = new MemoryPersistence();
1057
+ const controller = new ChatController();
1058
+ const connectedChannels: string[] = [];
1059
+
1060
+ persistence.setState("session", {
1061
+ sessionToken: "token-123",
1062
+ user: { id: "u1", username: "vince", emailVerified: true },
1063
+ }, { schemaVersion: 1 });
1064
+ apiClient.updateChatChannelState = async (channelId, body) => ({
1065
+ channelId,
1066
+ notificationsEnabled: body.notificationsEnabled === true,
1067
+ lastReadMessageId: null,
1068
+ unreadCount: 0,
1069
+ });
1070
+ apiClient.connectChannel = (channelId) => {
1071
+ connectedChannels.push(channelId);
1072
+ return {
1073
+ send: async () => {
1074
+ throw new Error("not implemented");
1075
+ },
1076
+ close: () => {},
1077
+ };
1078
+ };
1079
+
1080
+ controller.attachPersistence(persistence);
1081
+ controller.setChannelNotificationsEnabled("options", true);
1082
+ await flushMicrotasks();
1083
+
1084
+ expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
1085
+ notificationsEnabled: true,
1086
+ });
1087
+ expect(connectedChannels).toContain("options");
1088
+ });
1089
+
1090
+ test("dedupes reply notifications by message id", () => {
1091
+ const persistence = new MemoryPersistence();
1092
+ const controller = new ChatController();
1093
+ const notifications: AppNotificationRequest[] = [];
1094
+ const notification: ChatNotification = {
1095
+ id: "n1",
1096
+ type: "reply",
1097
+ channelId: "everyone",
1098
+ messageId: "m2",
1099
+ createdAt: "2026-03-28T00:02:00.000Z",
1100
+ message: {
1101
+ id: "m2",
1102
+ channelId: "everyone",
1103
+ content: "same reply",
1104
+ replyToId: "m1",
1105
+ createdAt: "2026-03-28T00:02:00.000Z",
1106
+ user: { id: "u2", username: "bob", displayName: "Bob" },
1107
+ replyTo: { content: "question", user: { id: "u1", username: "vince" } },
1108
+ },
1109
+ };
1110
+
1111
+ persistence.setState("session", {
1112
+ sessionToken: "token-123",
1113
+ user: { id: "u1", username: "vince", emailVerified: true },
1114
+ }, { schemaVersion: 1 });
1115
+ controller.setNotifier((entry) => {
1116
+ notifications.push(entry);
1117
+ });
1118
+ controller.attachPersistence(persistence);
1119
+
1120
+ (controller as any).handleChatNotification(notification);
1121
+ (controller as any).handleChatNotification({ ...notification, id: "n2" });
1122
+
1123
+ expect(notifications).toHaveLength(1);
1124
+ });
1125
+
1126
+ test("displays server-issued channel notifications", () => {
1127
+ const persistence = new MemoryPersistence();
1128
+ const controller = new ChatController();
1129
+ const notifications: AppNotificationRequest[] = [];
1130
+ const message: ChatMessage = {
1131
+ id: "m1",
1132
+ channelId: "options",
1133
+ content: "new option flow",
1134
+ replyToId: null,
1135
+ createdAt: "2026-03-28T00:00:00.000Z",
1136
+ user: { id: "u2", username: "bob", displayName: "Bob" },
1137
+ };
1138
+
1139
+ persistence.setState("session", {
1140
+ sessionToken: "token-123",
1141
+ user: { id: "u1", username: "vince", emailVerified: true },
1142
+ }, { schemaVersion: 1 });
1143
+ controller.setNotifier((entry) => {
1144
+ notifications.push(entry);
1145
+ });
1146
+ controller.attachPersistence(persistence);
1147
+
1148
+ (controller as any).handleChatNotification({
1149
+ id: "n1",
1150
+ type: "channel",
1151
+ channelId: "options",
1152
+ messageId: "m1",
1153
+ createdAt: "2026-03-28T00:00:00.000Z",
1154
+ message,
1155
+ } satisfies ChatNotification);
1156
+
1157
+ expect(notifications).toEqual([{
1158
+ title: "Gloomberb chat",
1159
+ subtitle: "#options",
1160
+ body: "#options @bob: new option flow",
1161
+ type: "info",
1162
+ desktop: "when-inactive",
1163
+ }]);
1164
+ });
1165
+
1166
+ test("tracks unread channel messages and clears them when the channel opens", () => {
1167
+ const persistence = new MemoryPersistence();
1168
+ const controller = new ChatController();
1169
+ const message: ChatMessage = {
1170
+ id: "m1",
1171
+ channelId: "options",
1172
+ content: "new option flow",
1173
+ replyToId: null,
1174
+ createdAt: "2026-03-28T00:00:00.000Z",
1175
+ user: { id: "u2", username: "bob", displayName: "Bob" },
1176
+ };
1177
+
1178
+ persistence.setState("session", {
1179
+ sessionToken: "token-123",
1180
+ user: { id: "u1", username: "vince", emailVerified: true },
1181
+ }, { schemaVersion: 1 });
1182
+ controller.attachPersistence(persistence);
1183
+
1184
+ (controller as any).mergeMessages("options", [message]);
1185
+
1186
+ expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
1187
+ unreadCount: 1,
1188
+ });
1189
+
1190
+ const detachView = controller.attachChannelView("options");
1191
+
1192
+ expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
1193
+ unreadCount: 0,
1194
+ });
1195
+ detachView();
1196
+ });
1197
+
1198
+ test("server reply notifications fire even when channel notifications are disabled", () => {
1199
+ const persistence = new MemoryPersistence();
1200
+ const controller = new ChatController();
1201
+ const notifications: AppNotificationRequest[] = [];
1202
+ const message: ChatMessage = {
1203
+ id: "m2",
1204
+ channelId: "options",
1205
+ content: "reply without channel notify",
1206
+ replyToId: "m1",
1207
+ createdAt: "2026-03-28T00:00:00.000Z",
1208
+ user: { id: "u2", username: "bob", displayName: "Bob" },
1209
+ replyTo: { content: "question", user: { id: "u1", username: "vince" } },
1210
+ };
1211
+
1212
+ persistence.setState("session", {
1213
+ sessionToken: "token-123",
1214
+ user: { id: "u1", username: "vince", emailVerified: true },
1215
+ }, { schemaVersion: 1 });
1216
+ controller.setNotifier((notification) => {
1217
+ notifications.push(notification);
1218
+ });
1219
+ controller.attachPersistence(persistence);
1220
+
1221
+ (controller as any).handleChatNotification({
1222
+ id: "n1",
1223
+ type: "reply",
1224
+ channelId: "options",
1225
+ messageId: "m2",
1226
+ createdAt: "2026-03-28T00:00:00.000Z",
1227
+ message,
1228
+ } satisfies ChatNotification);
1229
+
1230
+ expect(notifications).toEqual([{
1231
+ title: "Gloomberb chat",
1232
+ subtitle: "#options",
1233
+ body: "@bob replied to you: reply without channel notify",
1234
+ type: "info",
1235
+ desktop: "when-inactive",
1236
+ }]);
1237
+ });
1238
+
858
1239
  test("recovers from a legacy timestamp cursor by falling back to a full transcript fetch", async () => {
859
1240
  const persistence = new MemoryPersistence();
860
1241
  const controller = new ChatController();