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
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { act, useCallback, useMemo, useRef, useState } from "react";
3
3
  import { PaneFooterBar, PaneFooterProvider } from "../../components/layout/pane-footer";
4
4
  import { testRender } from "../../renderers/opentui/test-utils";
@@ -8,7 +8,7 @@ import { colors } from "../../theme/colors";
8
8
  import { createDefaultConfig, findPaneInstance, type PaneInstanceConfig } from "../../types/config";
9
9
  import type { PersistedResourceValue } from "../../types/persistence";
10
10
  import type { PluginPersistence } from "../../types/plugin";
11
- import { Box } from "../../ui";
11
+ import { Box, TextAttributes } from "../../ui";
12
12
  import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
13
13
  import { PluginRenderProvider, type PluginRuntimeAccess } from "../plugin-runtime";
14
14
  import { setSharedMarketDataForTests, setSharedRegistryForTests } from "../registry";
@@ -21,6 +21,8 @@ const TRANSCRIPT_SOURCE = "server";
21
21
  const TRANSCRIPT_SCHEMA_VERSION = 2;
22
22
  const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
23
23
  const originalGetChannels = apiClient.getChannels.bind(apiClient);
24
+ const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient);
25
+ const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient);
24
26
 
25
27
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
26
28
 
@@ -150,6 +152,8 @@ function createController(options: {
150
152
  controller.attachPersistence(persistence);
151
153
  controller.refreshSession = async () => {};
152
154
  controller.refreshMessages = async () => {};
155
+ controller.refreshPresence = async () => {};
156
+ controller.refreshChatState = async () => {};
153
157
  return controller;
154
158
  }
155
159
 
@@ -248,11 +252,23 @@ function hexToRgbaInts(hex: string) {
248
252
  ].join(",");
249
253
  }
250
254
 
255
+ beforeEach(() => {
256
+ apiClient.getChatPresence = async () => ({ onlineCount: 0 });
257
+ apiClient.updateChatChannelState = async (channelId, body) => ({
258
+ channelId,
259
+ notificationsEnabled: body.notificationsEnabled ?? false,
260
+ lastReadMessageId: body.readThroughMessageId ?? null,
261
+ unreadCount: 0,
262
+ });
263
+ });
264
+
251
265
  afterEach(async () => {
252
266
  setSharedRegistryForTests(undefined);
253
267
  setSharedMarketDataForTests(undefined);
254
268
  apiClient.connectChannel = originalConnectChannel;
255
269
  apiClient.getChannels = originalGetChannels;
270
+ apiClient.getChatPresence = originalGetChatPresence;
271
+ apiClient.updateChatChannelState = originalUpdateChatChannelState;
256
272
  apiClient.setSessionToken(null);
257
273
 
258
274
  if (testSetup) {
@@ -468,6 +484,136 @@ describe("ChatContent", () => {
468
484
  expect(testSetup.captureCharFrame()).toContain("#options");
469
485
  });
470
486
 
487
+ test("renders unread sidebar channels in bold and clears them when opened", async () => {
488
+ const controller = createController({ sessionToken: "token-123" });
489
+ installServerChannels(controller);
490
+ controller.refreshChannels = async () => {};
491
+ controller.refreshChannelMessages = async () => {};
492
+ const optionsState = (controller as any).ensureChannelState("options");
493
+ optionsState.unreadCount = 2;
494
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
495
+
496
+ function ChannelPane() {
497
+ const [channelId, setChannelId] = useState("equities");
498
+ return (
499
+ <AppContext value={{ state, dispatch: () => {} }}>
500
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
501
+ <ChatContent
502
+ controller={controller}
503
+ width={90}
504
+ height={12}
505
+ focused
506
+ channelId={channelId}
507
+ onChannelChange={setChannelId}
508
+ />
509
+ </PluginRenderProvider>
510
+ </AppContext>
511
+ );
512
+ }
513
+
514
+ await act(async () => {
515
+ testSetup = await testRender(<ChannelPane />, {
516
+ width: 90,
517
+ height: 12,
518
+ });
519
+ });
520
+
521
+ await flushFrame();
522
+ const unreadLine = testSetup.captureSpans().lines.find((line) => lineText(line).includes("options"));
523
+ const unreadSpan = unreadLine?.spans.find((span) => span.text.includes("options"));
524
+ expect((unreadSpan?.attributes ?? 0) & TextAttributes.BOLD).toBe(TextAttributes.BOLD);
525
+
526
+ const lines = testSetup.captureCharFrame().split("\n");
527
+ const row = lines.findIndex((line) => line.includes("options"));
528
+ const col = lines[row]?.indexOf("options") ?? -1;
529
+
530
+ await act(async () => {
531
+ await testSetup!.mockMouse.click(col + 1, row);
532
+ await testSetup!.renderOnce();
533
+ await testSetup!.renderOnce();
534
+ });
535
+ await flushFrame();
536
+
537
+ const readLine = testSetup.captureSpans().lines.find((line) => lineText(line).includes("#options"));
538
+ const readSpan = readLine?.spans.find((span) => span.text.includes("options"));
539
+ expect((readSpan?.attributes ?? 0) & TextAttributes.BOLD).toBe(0);
540
+ });
541
+
542
+ test("toggles sidebar channel notifications without selecting the channel", async () => {
543
+ const controller = createController({ sessionToken: "token-123" });
544
+ installServerChannels(controller);
545
+ controller.refreshChannels = async () => {};
546
+ controller.refreshChannelMessages = async () => {};
547
+ const toggles: Array<{ channelId: string; enabled: boolean }> = [];
548
+ controller.setChannelNotificationsEnabled = (channelId: string, enabled: boolean) => {
549
+ toggles.push({ channelId, enabled });
550
+ };
551
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
552
+
553
+ function ChannelPane() {
554
+ const [channelId, setChannelId] = useState("equities");
555
+ return (
556
+ <AppContext value={{ state, dispatch: () => {} }}>
557
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
558
+ <ChatContent
559
+ controller={controller}
560
+ width={90}
561
+ height={12}
562
+ focused
563
+ channelId={channelId}
564
+ onChannelChange={setChannelId}
565
+ />
566
+ </PluginRenderProvider>
567
+ </AppContext>
568
+ );
569
+ }
570
+
571
+ await act(async () => {
572
+ testSetup = await testRender(<ChannelPane />, {
573
+ width: 90,
574
+ height: 12,
575
+ });
576
+ });
577
+
578
+ await flushFrame();
579
+ const frame = testSetup.captureCharFrame();
580
+ const lines = frame.split("\n");
581
+ const row = lines.findIndex((line) => line.includes("options"));
582
+ const col = lines[row]?.lastIndexOf("·") ?? -1;
583
+ expect(row).toBeGreaterThanOrEqual(0);
584
+ expect(col).toBeGreaterThanOrEqual(0);
585
+
586
+ await act(async () => {
587
+ await testSetup!.mockMouse.click(col, row);
588
+ await testSetup!.renderOnce();
589
+ await testSetup!.renderOnce();
590
+ });
591
+ await flushFrame();
592
+
593
+ expect(toggles).toEqual([{ channelId: "options", enabled: true }]);
594
+ expect(testSetup.captureCharFrame()).toContain("#equities");
595
+ expect(testSetup.captureCharFrame()).not.toContain("#options");
596
+ });
597
+
598
+ test("shows the sidebar online count footer", async () => {
599
+ const controller = createController({ sessionToken: "token-123" });
600
+ installServerChannels(controller);
601
+ controller.refreshChannels = async () => {};
602
+ controller.refreshChannelMessages = async () => {};
603
+ (controller as any).onlineCount = 6;
604
+
605
+ await act(async () => {
606
+ testSetup = await testRender(createHarness(controller, { width: 90, height: 12 }), {
607
+ width: 90,
608
+ height: 12,
609
+ });
610
+ });
611
+
612
+ await flushFrame();
613
+
614
+ expect(testSetup.captureCharFrame()).toContain("● 6 online");
615
+ });
616
+
471
617
  test("uses arrows to move between channel sidebar and chat content", async () => {
472
618
  const controller = createController({ sessionToken: "token-123" });
473
619
  installServerChannels(controller);
@@ -818,6 +964,34 @@ describe("ChatContent", () => {
818
964
  expect(frame).toContain("Reply to @user2...");
819
965
  });
820
966
 
967
+ test("modified k does not move message selection", async () => {
968
+ const controller = createController({
969
+ messages: [makeMessage(1), makeMessage(2)],
970
+ });
971
+
972
+ await act(async () => {
973
+ testSetup = await testRender(createHarness(controller, {
974
+ width: 72,
975
+ height: 12,
976
+ }), {
977
+ width: 72,
978
+ height: 12,
979
+ });
980
+ });
981
+
982
+ await flushFrame();
983
+
984
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
985
+ await emitKeypress({ name: "k", sequence: "k", meta: true });
986
+ await emitKeypress({ name: "return", sequence: "\r" });
987
+ await flushFrame();
988
+
989
+ const frame = testSetup.captureCharFrame();
990
+ expect(frame).toContain("replying to @user2");
991
+ expect(frame).toContain("Reply to @user2...");
992
+ expect(frame).not.toContain("replying to @user1");
993
+ });
994
+
821
995
  test("shows the reply action next to the selected message timestamp", async () => {
822
996
  const controller = createController({
823
997
  messages: [makeMessage(1), makeMessage(2)],
@@ -1291,6 +1465,61 @@ describe("ChatContent", () => {
1291
1465
  expect(sentMessages).toEqual(["first line\nsecond line"]);
1292
1466
  });
1293
1467
 
1468
+ test("clears the composer locally after an accepted send", async () => {
1469
+ const controller = createController({ sessionToken: "token-123" });
1470
+ const sentMessages: string[] = [];
1471
+ (controller as any).send = (content: string) => {
1472
+ sentMessages.push(content);
1473
+ return true;
1474
+ };
1475
+ (controller as any).setDraft = () => {};
1476
+
1477
+ await act(async () => {
1478
+ testSetup = await testRender(createHarness(controller), {
1479
+ width: 60,
1480
+ height: 12,
1481
+ });
1482
+ });
1483
+
1484
+ await flushFrame();
1485
+
1486
+ const frameBeforeClick = testSetup.captureCharFrame().split("\n");
1487
+ const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
1488
+ const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
1489
+
1490
+ expect(inputRow).toBeGreaterThanOrEqual(0);
1491
+ expect(inputCol).toBeGreaterThanOrEqual(0);
1492
+
1493
+ await act(async () => {
1494
+ await testSetup!.mockMouse.click(inputCol + 1, inputRow);
1495
+ await testSetup!.renderOnce();
1496
+ await testSetup!.renderOnce();
1497
+ });
1498
+
1499
+ await act(async () => {
1500
+ await testSetup!.mockInput.typeText("hello");
1501
+ await testSetup!.renderOnce();
1502
+ await testSetup!.renderOnce();
1503
+ });
1504
+
1505
+ await act(async () => {
1506
+ testSetup!.mockInput.pressEnter();
1507
+ await testSetup!.renderOnce();
1508
+ await testSetup!.renderOnce();
1509
+ });
1510
+
1511
+ expect(sentMessages).toEqual(["hello"]);
1512
+ expect(testSetup.captureCharFrame()).not.toContain("> hello");
1513
+
1514
+ await act(async () => {
1515
+ testSetup!.mockInput.pressEnter();
1516
+ await testSetup!.renderOnce();
1517
+ await testSetup!.renderOnce();
1518
+ });
1519
+
1520
+ expect(sentMessages).toEqual(["hello"]);
1521
+ });
1522
+
1294
1523
  test("keeps typed shortcut letters in the composer instead of moving message selection", async () => {
1295
1524
  const controller = createController({
1296
1525
  messages: Array.from({ length: 18 }, (_, index) => makeMessage(index + 1)),
@@ -1448,7 +1677,7 @@ describe("ChatContent", () => {
1448
1677
  expect(frame).not.toContain("message 1");
1449
1678
  });
1450
1679
 
1451
- test("repins the transcript to the latest message when a following chat pane regains focus", async () => {
1680
+ test("preserves a manually scrolled transcript across focus and snapshot refreshes", async () => {
1452
1681
  const controller = createController({
1453
1682
  messages: [
1454
1683
  makeMessage(1),
@@ -1506,13 +1735,90 @@ describe("ChatContent", () => {
1506
1735
 
1507
1736
  await act(async () => {
1508
1737
  setFocused?.(true);
1738
+ (controller as any).emit("everyone");
1739
+ await testSetup!.renderOnce();
1740
+ });
1741
+ await flushFrame();
1742
+
1743
+ const refreshedFrame = testSetup.captureCharFrame();
1744
+ expect(refreshedFrame).toContain("message 2");
1745
+ expect(refreshedFrame).not.toContain("message 8");
1746
+ });
1747
+
1748
+ test("opens a newly selected channel at the latest message", async () => {
1749
+ const controller = createController({
1750
+ messages: Array.from({ length: 8 }, (_, index) => makeMessage(index + 1)),
1751
+ });
1752
+ installServerChannels(controller, TEST_CHAT_CHANNELS.slice(0, 2));
1753
+ controller.refreshChannels = async () => {};
1754
+ controller.refreshChannelMessages = async () => {};
1755
+
1756
+ const equitiesMessages = Array.from({ length: 8 }, (_, index) => ({
1757
+ ...makeMessage(index + 1),
1758
+ id: `o${index + 1}`,
1759
+ channelId: "equities",
1760
+ content: `equities message ${index + 1}`,
1761
+ }));
1762
+ const equitiesState = (controller as any).ensureChannelState("equities");
1763
+ equitiesState.messages = equitiesMessages;
1764
+ equitiesState.lastCursor = "o8";
1765
+
1766
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat-channel-scroll"));
1767
+
1768
+ function ChannelPane() {
1769
+ const [channelId, setChannelId] = useState("everyone");
1770
+ return (
1771
+ <AppContext value={{ state, dispatch: () => {} }}>
1772
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
1773
+ <ChatContent
1774
+ controller={controller}
1775
+ width={90}
1776
+ height={13}
1777
+ focused
1778
+ channelId={channelId}
1779
+ onChannelChange={setChannelId}
1780
+ />
1781
+ </PluginRenderProvider>
1782
+ </AppContext>
1783
+ );
1784
+ }
1785
+
1786
+ await act(async () => {
1787
+ testSetup = await testRender(<ChannelPane />, {
1788
+ width: 90,
1789
+ height: 13,
1790
+ });
1791
+ });
1792
+
1793
+ await flushFrame();
1794
+ expect(testSetup.captureCharFrame()).toContain("message 8");
1795
+
1796
+ await act(async () => {
1797
+ await testSetup!.mockMouse.scroll(24, 4, "up");
1798
+ await testSetup!.mockMouse.scroll(24, 4, "up");
1799
+ await testSetup!.mockMouse.scroll(24, 4, "up");
1800
+ await testSetup!.renderOnce();
1801
+ await testSetup!.renderOnce();
1802
+ });
1803
+ await flushFrame();
1804
+ expect(testSetup.captureCharFrame()).toContain("message 2");
1805
+
1806
+ const lines = testSetup.captureCharFrame().split("\n");
1807
+ const row = lines.findIndex((line) => line.includes("equities"));
1808
+ const col = lines[row]?.indexOf("equities") ?? -1;
1809
+ expect(row).toBeGreaterThanOrEqual(0);
1810
+ expect(col).toBeGreaterThanOrEqual(0);
1811
+
1812
+ await act(async () => {
1813
+ await testSetup!.mockMouse.click(col + 1, row);
1814
+ await testSetup!.renderOnce();
1509
1815
  await testSetup!.renderOnce();
1510
1816
  });
1511
1817
  await flushFrame();
1512
1818
 
1513
- const refocusedFrame = testSetup.captureCharFrame();
1514
- expect(refocusedFrame).toContain("message 8");
1515
- expect(refocusedFrame).not.toContain("message 2");
1819
+ const channelFrame = testSetup.captureCharFrame();
1820
+ expect(channelFrame).toContain("equities message 8");
1821
+ expect(channelFrame).not.toContain("equities message 1");
1516
1822
  });
1517
1823
 
1518
1824
  test("keeps the bottom reachable after narrowing the chat pane", async () => {