gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -1,28 +1,43 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { act, useState } from "react";
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";
5
- import { AppContext, createInitialState } from "../../state/app-context";
6
- import { createTestPluginRuntime } from "../../test-support/plugin-runtime";
5
+ import { AppContext, appReducer, createInitialState, PaneInstanceProvider } from "../../state/app-context";
6
+ import { createConfigBackedTestPluginRuntime, createTestPluginRuntime } from "../../test-support/plugin-runtime";
7
7
  import { colors } from "../../theme/colors";
8
- import { createDefaultConfig } from "../../types/config";
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
11
  import { Box } from "../../ui";
12
- import { apiClient, type ChatMessage } from "../../utils/api-client";
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";
15
- import { ChatContent, ChatStatusWidget, getSelectedMessageScrollTop, gloomberbCloudPlugin } from "./chat";
16
- import { ChatController } from "./chat-controller";
15
+ import { ChatContent, ChatStatusWidget, getScrollTopForElementIntoView, getSelectedMessageScrollTop, gloomberbCloudPlugin } from "./chat";
16
+ import { chatController, ChatController } from "./chat-controller";
17
17
 
18
18
  const TRANSCRIPT_KIND = "channel-transcript";
19
19
  const TRANSCRIPT_KEY = "everyone";
20
20
  const TRANSCRIPT_SOURCE = "server";
21
21
  const TRANSCRIPT_SCHEMA_VERSION = 2;
22
22
  const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
23
+ const originalGetChannels = apiClient.getChannels.bind(apiClient);
23
24
 
24
25
  let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
25
26
 
27
+ const TEST_CHAT_CHANNELS: ChatChannel[] = [
28
+ { id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
29
+ { id: "equities", name: "equities", created_at: "2026-05-09T00:00:00.000Z" },
30
+ { id: "options", name: "options", created_at: "2026-05-09T00:00:00.000Z" },
31
+ { id: "macro", name: "macro", created_at: "2026-05-09T00:00:00.000Z" },
32
+ { id: "crypto", name: "crypto", created_at: "2026-05-09T00:00:00.000Z" },
33
+ { id: "energy", name: "energy", created_at: "2026-05-09T00:00:00.000Z" },
34
+ { id: "help", name: "help", created_at: "2026-05-09T00:00:00.000Z" },
35
+ ];
36
+
37
+ function installServerChannels(controller: ChatController, channels = TEST_CHAT_CHANNELS): void {
38
+ (controller as any).channels = channels;
39
+ }
40
+
26
41
  class MemoryPersistence implements PluginPersistence {
27
42
  private readonly state = new Map<string, { schemaVersion: number; value: unknown }>();
28
43
  private readonly resources = new Map<string, PersistedResourceValue<unknown>>();
@@ -237,6 +252,7 @@ afterEach(async () => {
237
252
  setSharedRegistryForTests(undefined);
238
253
  setSharedMarketDataForTests(undefined);
239
254
  apiClient.connectChannel = originalConnectChannel;
255
+ apiClient.getChannels = originalGetChannels;
240
256
  apiClient.setSessionToken(null);
241
257
 
242
258
  if (testSetup) {
@@ -356,6 +372,341 @@ describe("ChatContent", () => {
356
372
  expect(frame).not.toContain("click a message");
357
373
  });
358
374
 
375
+ test("shows the channel sidebar on wide panes and hides it on narrow panes", async () => {
376
+ const controller = createController({ sessionToken: "token-123" });
377
+ installServerChannels(controller);
378
+ controller.refreshChannels = async () => {};
379
+ controller.refreshChannelMessages = async () => {};
380
+
381
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
382
+ const renderChannelPane = (width: number) => (
383
+ <AppContext value={{ state, dispatch: () => {} }}>
384
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
385
+ <ChatContent
386
+ controller={controller}
387
+ width={width}
388
+ height={12}
389
+ focused
390
+ channelId="options"
391
+ onChannelChange={() => {}}
392
+ />
393
+ </PluginRenderProvider>
394
+ </AppContext>
395
+ );
396
+
397
+ await act(async () => {
398
+ testSetup = await testRender(renderChannelPane(90), {
399
+ width: 90,
400
+ height: 12,
401
+ });
402
+ });
403
+
404
+ await flushFrame();
405
+ expect(testSetup.captureCharFrame()).toContain("options");
406
+
407
+ await act(async () => {
408
+ testSetup?.renderer.destroy();
409
+ testSetup = await testRender(renderChannelPane(60), {
410
+ width: 60,
411
+ height: 12,
412
+ });
413
+ });
414
+
415
+ await flushFrame();
416
+ expect(testSetup.captureCharFrame()).not.toContain("options");
417
+ });
418
+
419
+ test("selects a sidebar channel from a single text click", async () => {
420
+ const controller = createController({ sessionToken: "token-123" });
421
+ installServerChannels(controller);
422
+ controller.refreshChannels = async () => {};
423
+ controller.refreshChannelMessages = async () => {};
424
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
425
+
426
+ function ChannelPane() {
427
+ const [channelId, setChannelId] = useState("equities");
428
+ return (
429
+ <AppContext value={{ state, dispatch: () => {} }}>
430
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
431
+ <ChatContent
432
+ controller={controller}
433
+ width={90}
434
+ height={12}
435
+ focused
436
+ channelId={channelId}
437
+ onChannelChange={setChannelId}
438
+ />
439
+ </PluginRenderProvider>
440
+ </AppContext>
441
+ );
442
+ }
443
+
444
+ await act(async () => {
445
+ testSetup = await testRender(<ChannelPane />, {
446
+ width: 90,
447
+ height: 12,
448
+ });
449
+ });
450
+
451
+ await flushFrame();
452
+ expect(testSetup.captureCharFrame()).toContain("#equities");
453
+
454
+ const lines = testSetup.captureCharFrame().split("\n");
455
+ const row = lines.findIndex((line) => line.includes("options"));
456
+ const col = lines[row]?.indexOf("options") ?? -1;
457
+
458
+ expect(row).toBeGreaterThanOrEqual(0);
459
+ expect(col).toBeGreaterThanOrEqual(0);
460
+
461
+ await act(async () => {
462
+ await testSetup!.mockMouse.click(col + 1, row);
463
+ await testSetup!.renderOnce();
464
+ await testSetup!.renderOnce();
465
+ });
466
+ await flushFrame();
467
+
468
+ expect(testSetup.captureCharFrame()).toContain("#options");
469
+ });
470
+
471
+ test("uses arrows to move between channel sidebar and chat content", async () => {
472
+ const controller = createController({ sessionToken: "token-123" });
473
+ installServerChannels(controller);
474
+ controller.refreshChannels = async () => {};
475
+ controller.refreshChannelMessages = async () => {};
476
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
477
+
478
+ function ChannelPane() {
479
+ const [channelId, setChannelId] = useState("equities");
480
+ return (
481
+ <AppContext value={{ state, dispatch: () => {} }}>
482
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
483
+ <ChatContent
484
+ controller={controller}
485
+ width={90}
486
+ height={12}
487
+ focused
488
+ channelId={channelId}
489
+ onChannelChange={setChannelId}
490
+ />
491
+ </PluginRenderProvider>
492
+ </AppContext>
493
+ );
494
+ }
495
+
496
+ await act(async () => {
497
+ testSetup = await testRender(<ChannelPane />, {
498
+ width: 90,
499
+ height: 12,
500
+ });
501
+ });
502
+
503
+ await flushFrame();
504
+ expect(testSetup.captureCharFrame()).toContain("#equities");
505
+ const getActiveChannelBackgrounds = () => {
506
+ const activeLine = testSetup!.captureSpans().lines.find((line) => lineText(line).includes("#equities"));
507
+ expect(activeLine).toBeDefined();
508
+ return activeLine!.spans.map((span) => span.bg.toInts().join(","));
509
+ };
510
+ const getContentBackgrounds = () => {
511
+ const contentLine = testSetup!.captureSpans().lines.find((line) => lineText(line).includes("No messages yet"));
512
+ expect(contentLine).toBeDefined();
513
+ return contentLine!.spans.map((span) => span.bg.toInts().join(","));
514
+ };
515
+ const contentFocusedBackgrounds = getActiveChannelBackgrounds();
516
+ const rightFocusedBackgrounds = getContentBackgrounds();
517
+
518
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
519
+ expect(testSetup.captureCharFrame()).toContain("#equities");
520
+
521
+ await emitKeypress({ name: "left", sequence: "\u001b[D" });
522
+ await flushFrame();
523
+ expect(getActiveChannelBackgrounds()).not.toEqual(contentFocusedBackgrounds);
524
+ expect(getContentBackgrounds()).not.toEqual(rightFocusedBackgrounds);
525
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
526
+ await flushFrame();
527
+ expect(testSetup.captureCharFrame()).toContain("#options");
528
+
529
+ await emitKeypress({ name: "up", sequence: "\u001b[A" });
530
+ await flushFrame();
531
+ expect(testSetup.captureCharFrame()).toContain("#equities");
532
+
533
+ await emitKeypress({ name: "right", sequence: "\u001b[C" });
534
+ await flushFrame();
535
+ expect(getActiveChannelBackgrounds()).toEqual(contentFocusedBackgrounds);
536
+ expect(getContentBackgrounds()).toEqual(rightFocusedBackgrounds);
537
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
538
+ await flushFrame();
539
+ expect(testSetup.captureCharFrame()).toContain("#equities");
540
+ });
541
+
542
+ test("keeps the channel sidebar visible while a channel loads", async () => {
543
+ const controller = createController({ sessionToken: "token-123" });
544
+ installServerChannels(controller);
545
+ const getSnapshot = controller.getSnapshot.bind(controller);
546
+ controller.getSnapshot = ((channelId?: string) => ({
547
+ ...getSnapshot(channelId),
548
+ loading: true,
549
+ messages: [],
550
+ })) as typeof controller.getSnapshot;
551
+ controller.refreshChannels = async () => {};
552
+ controller.refreshChannelMessages = async () => {};
553
+
554
+ const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
555
+
556
+ await act(async () => {
557
+ testSetup = await testRender(
558
+ <AppContext value={{ state, dispatch: () => {} }}>
559
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={createTestPluginRuntime()}>
560
+ <ChatContent
561
+ controller={controller}
562
+ width={90}
563
+ height={12}
564
+ focused
565
+ channelId="options"
566
+ onChannelChange={() => {}}
567
+ />
568
+ </PluginRenderProvider>
569
+ </AppContext>,
570
+ {
571
+ width: 90,
572
+ height: 12,
573
+ },
574
+ );
575
+ });
576
+
577
+ await flushFrame();
578
+
579
+ const frame = testSetup.captureCharFrame();
580
+ expect(frame).toContain("#options");
581
+ expect(frame).toContain("Loading...");
582
+ });
583
+
584
+ test("persists a pane channel selection without the last-visited write reverting it", async () => {
585
+ const registeredPanes: Array<{ id: string; component: (props: any) => JSX.Element }> = [];
586
+ gloomberbCloudPlugin.setup({
587
+ persistence: new MemoryPersistence(),
588
+ resume: {
589
+ getState: () => null,
590
+ setState: () => {},
591
+ deleteState: () => {},
592
+ },
593
+ registerPane: (pane: { id: string; component: (props: any) => JSX.Element }) => {
594
+ registeredPanes.push(pane);
595
+ },
596
+ registerPaneTemplate: () => {},
597
+ registerDetailTab: () => {},
598
+ registerShortcut: () => {},
599
+ registerCommand: () => {},
600
+ showPane: () => {},
601
+ hidePane: () => {},
602
+ notify: () => {},
603
+ } as any);
604
+ const ChatPaneComponent = registeredPanes.find((pane) => pane.id === "chat")?.component;
605
+ expect(ChatPaneComponent).toBeDefined();
606
+ const ResolvedChatPaneComponent = ChatPaneComponent!;
607
+
608
+ const originalRefreshChannels = chatController.refreshChannels;
609
+ const originalRefreshSession = chatController.refreshSession;
610
+ const originalRefreshChannelMessages = chatController.refreshChannelMessages;
611
+ installServerChannels(chatController);
612
+ chatController.refreshChannels = async () => {};
613
+ chatController.refreshSession = async () => {};
614
+ chatController.refreshChannelMessages = async () => {};
615
+
616
+ const paneInstanceId = "chat:test";
617
+ const chatInstance: PaneInstanceConfig = {
618
+ instanceId: paneInstanceId,
619
+ paneId: "chat",
620
+ settings: { channelId: "equities" },
621
+ };
622
+ const layout = {
623
+ dockRoot: { kind: "pane" as const, instanceId: paneInstanceId },
624
+ floating: [],
625
+ detached: [],
626
+ instances: [chatInstance],
627
+ };
628
+ const initialState = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
629
+ initialState.focusedPaneId = paneInstanceId;
630
+ initialState.config = {
631
+ ...initialState.config,
632
+ layout,
633
+ layouts: [{ name: "Test", layout }],
634
+ pluginConfig: {
635
+ "gloomberb-cloud": {
636
+ lastChatChannelId: "equities",
637
+ },
638
+ },
639
+ };
640
+ let latestState = initialState;
641
+
642
+ function ChatPaneHarness() {
643
+ const [state, setState] = useState(initialState);
644
+ const stateRef = useRef(state);
645
+ stateRef.current = state;
646
+ latestState = state;
647
+ const dispatch = useCallback((action: any) => {
648
+ setState((current) => {
649
+ const next = appReducer(current, action);
650
+ latestState = next;
651
+ return next;
652
+ });
653
+ }, []);
654
+ const runtime = useMemo(() => createConfigBackedTestPluginRuntime({
655
+ getConfig: () => stateRef.current.config,
656
+ setConfig: (config) => dispatch({ type: "SET_CONFIG", config }),
657
+ }), [dispatch]);
658
+
659
+ return (
660
+ <AppContext value={{ state, dispatch }}>
661
+ <PaneInstanceProvider paneId={paneInstanceId}>
662
+ <PluginRenderProvider pluginId="gloomberb-cloud" runtime={runtime}>
663
+ <ResolvedChatPaneComponent
664
+ width={90}
665
+ height={12}
666
+ focused
667
+ close={() => {}}
668
+ />
669
+ </PluginRenderProvider>
670
+ </PaneInstanceProvider>
671
+ </AppContext>
672
+ );
673
+ }
674
+
675
+ try {
676
+ await act(async () => {
677
+ testSetup = await testRender(<ChatPaneHarness />, {
678
+ width: 90,
679
+ height: 12,
680
+ });
681
+ });
682
+
683
+ await flushFrame();
684
+ const lines = testSetup.captureCharFrame().split("\n");
685
+ const row = lines.findIndex((line) => line.includes("options"));
686
+ const col = lines[row]?.indexOf("options") ?? -1;
687
+
688
+ expect(row).toBeGreaterThanOrEqual(0);
689
+ expect(col).toBeGreaterThanOrEqual(0);
690
+
691
+ await act(async () => {
692
+ await testSetup!.mockMouse.click(col + 1, row);
693
+ await testSetup!.renderOnce();
694
+ await testSetup!.renderOnce();
695
+ });
696
+ await flushFrame();
697
+
698
+ expect(findPaneInstance(latestState.config.layout, paneInstanceId)?.settings?.channelId).toBe("options");
699
+ expect(latestState.config.pluginConfig["gloomberb-cloud"]?.lastChatChannelId).toBe("options");
700
+ expect(testSetup.captureCharFrame()).toContain("#options");
701
+ } finally {
702
+ chatController.refreshChannels = originalRefreshChannels;
703
+ chatController.refreshSession = originalRefreshSession;
704
+ chatController.refreshChannelMessages = originalRefreshChannelMessages;
705
+ installServerChannels(chatController, []);
706
+ chatController.dispose();
707
+ }
708
+ });
709
+
359
710
  test("uses the full message row width when the reply action is hidden", async () => {
360
711
  const controller = createController({
361
712
  messages: [{
@@ -637,6 +988,29 @@ describe("ChatContent", () => {
637
988
  expect(nextScrollTop).toBe(13);
638
989
  });
639
990
 
991
+ test("keeps a selected desktop message inside the scroll viewport", () => {
992
+ expect(getScrollTopForElementIntoView({
993
+ scrollTop: 120,
994
+ viewportHeight: 60,
995
+ elementTop: 170,
996
+ elementHeight: 24,
997
+ })).toBe(134);
998
+
999
+ expect(getScrollTopForElementIntoView({
1000
+ scrollTop: 120,
1001
+ viewportHeight: 60,
1002
+ elementTop: 92,
1003
+ elementHeight: 18,
1004
+ })).toBe(92);
1005
+
1006
+ expect(getScrollTopForElementIntoView({
1007
+ scrollTop: 120,
1008
+ viewportHeight: 60,
1009
+ elementTop: 132,
1010
+ elementHeight: 18,
1011
+ })).toBe(120);
1012
+ });
1013
+
640
1014
  test("up arrow still moves within a multi-line draft before handing off to message selection", async () => {
641
1015
  const controller = createController({ sessionToken: "token-123", messages: [makeMessage(1)] });
642
1016
  const sentMessages: string[] = [];
@@ -1518,4 +1892,48 @@ describe("ChatContent", () => {
1518
1892
  "_validate",
1519
1893
  ]);
1520
1894
  });
1895
+
1896
+ test("new chat pane opens directly on the last visited channel", async () => {
1897
+ const originalRefreshChannels = chatController.refreshChannels;
1898
+ installServerChannels(chatController);
1899
+ chatController.refreshChannels = async () => {};
1900
+ const chatTemplate = gloomberbCloudPlugin.paneTemplates?.find((template) => template.id === "new-chat-pane");
1901
+ try {
1902
+ expect(chatTemplate?.wizard).toBeUndefined();
1903
+ expect(chatTemplate?.shortcut).toMatchObject({
1904
+ prefix: "CHAT",
1905
+ argPlaceholder: "channel",
1906
+ argKind: "text",
1907
+ });
1908
+ const created = await Promise.resolve(chatTemplate?.createInstance?.({
1909
+ config: {
1910
+ pluginConfig: {
1911
+ "gloomberb-cloud": {
1912
+ lastChatChannelId: "options",
1913
+ },
1914
+ },
1915
+ },
1916
+ }));
1917
+ expect(created).toMatchObject({
1918
+ settings: { channelId: "options" },
1919
+ placement: "floating",
1920
+ });
1921
+ const createdFromShortcut = await Promise.resolve(chatTemplate?.createInstance?.({
1922
+ config: {
1923
+ pluginConfig: {
1924
+ "gloomberb-cloud": {
1925
+ lastChatChannelId: "options",
1926
+ },
1927
+ },
1928
+ },
1929
+ }, { arg: "#HELP" }));
1930
+ expect(createdFromShortcut).toMatchObject({
1931
+ settings: { channelId: "help" },
1932
+ placement: "floating",
1933
+ });
1934
+ } finally {
1935
+ chatController.refreshChannels = originalRefreshChannels;
1936
+ installServerChannels(chatController, []);
1937
+ }
1938
+ });
1521
1939
  });