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
@@ -3,7 +3,7 @@ import { memo, useState, useEffect, useRef, useCallback, useMemo } from "react";
3
3
  import { useShortcut } from "../../react/input";
4
4
  import { TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "../../ui";
5
5
  import type { GloomPlugin, PaneProps } from "../../types/plugin";
6
- import { useAppDispatch, useAppSelector, useAppStateRef, usePaneInstance, usePaneInstanceId } from "../../state/app-context";
6
+ import { syncConfigActiveLayoutState, useAppDispatch, useAppSelector, useAppStateRef, usePaneInstance, usePaneInstanceId } from "../../state/app-context";
7
7
  import { useInlineTickers, type InlineTickerCatalogEntry } from "../../state/use-inline-tickers";
8
8
  import { ExternalLinkText, getMessageComposerBlockHeight, MessageComposer } from "../../components/ui";
9
9
  import { TickerBadgeText } from "../../components/ticker-badge-text";
@@ -12,6 +12,7 @@ import { blendHex, colors, hoverBg } from "../../theme/colors";
12
12
  import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
13
13
  import { formatTimeAgo } from "../../utils/format";
14
14
  import { tokenizeInlineContent } from "../../utils/inline-content-tokenizer";
15
+ import { isPlainKey } from "../../utils/keyboard";
15
16
  import { getSharedRegistry } from "../../plugins/registry";
16
17
  import { usePluginAppActions } from "../../plugins/plugin-runtime";
17
18
  import { setPaneSetting } from "../../pane-settings";
@@ -34,6 +35,8 @@ interface ChatContentProps {
34
35
  | "attachChannelView"
35
36
  | "getSnapshot"
36
37
  | "refreshChannels"
38
+ | "refreshChatState"
39
+ | "refreshPresence"
37
40
  | "loadOlderMessages"
38
41
  | "loadOlderChannelMessages"
39
42
  | "refreshMessages"
@@ -43,6 +46,7 @@ interface ChatContentProps {
43
46
  | "sendToChannel"
44
47
  | "setDraft"
45
48
  | "setChannelDraft"
49
+ | "setChannelNotificationsEnabled"
46
50
  | "setReplyToId"
47
51
  | "setChannelReplyToId"
48
52
  | "subscribe"
@@ -61,10 +65,17 @@ const MESSAGE_SELECTION_BOTTOM_INSET = 1;
61
65
  const DESKTOP_MESSAGE_RIGHT_PADDING = 2;
62
66
  const CHANNEL_SIDEBAR_MIN_WIDTH = 18;
63
67
  const CHANNEL_SIDEBAR_MAX_WIDTH = 24;
68
+ const DESKTOP_CHANNEL_SIDEBAR_MIN_WIDTH = 14;
69
+ const DESKTOP_CHANNEL_SIDEBAR_MAX_WIDTH = 19;
70
+ const DESKTOP_CHANNEL_SIDEBAR_WIDTH_RATIO = 0.192;
64
71
  const CHANNEL_SIDEBAR_BREAKPOINT = 72;
72
+ const DESKTOP_NOTIFICATION_ICON_WIDTH = 3;
73
+ const DESKTOP_ONLINE_COUNT_PADDING_X = 1;
74
+ const DESKTOP_CHAT_INPUT_TOP_MARGIN_PX = 6;
65
75
  const DEFAULT_CHAT_CHANNEL_ID = "everyone";
66
76
  const LAST_VISITED_CHAT_CHANNEL_KEY = "lastChatChannelId";
67
77
  const CHAT_CHANNEL_MOUSE_HANDLED = "__gloomberbChatChannelHandled";
78
+ const SCROLL_BOTTOM_THRESHOLD_PX = 2;
68
79
 
69
80
  function isGroupedWithPrevious(messages: ChatMessage[], index: number) {
70
81
  if (index === 0) return false;
@@ -170,6 +181,15 @@ function scrollToBottom(scrollBox: ScrollBoxRenderable | null, preferPixels = fa
170
181
  );
171
182
  }
172
183
 
184
+ function isScrolledToBottom(scrollBox: ScrollBoxRenderable | null, preferPixels = false) {
185
+ if (!scrollBox) return true;
186
+ const exactPixels = preferPixels && hasPixelScrollMetrics(scrollBox);
187
+ const scrollTop = getScrollTop(scrollBox, exactPixels);
188
+ const maxScrollTop = Math.max(0, getScrollHeight(scrollBox, exactPixels) - getViewportHeight(scrollBox, exactPixels));
189
+ const threshold = exactPixels ? SCROLL_BOTTOM_THRESHOLD_PX : 0;
190
+ return maxScrollTop - scrollTop <= threshold;
191
+ }
192
+
173
193
  function runAfterLayout(callback: () => void) {
174
194
  if (typeof requestAnimationFrame === "function") {
175
195
  requestAnimationFrame(callback);
@@ -311,6 +331,73 @@ function CloudStatusIcon() {
311
331
  );
312
332
  }
313
333
 
334
+ function ChannelNotificationIcon({
335
+ enabled,
336
+ onMouseDown,
337
+ }: {
338
+ enabled: boolean;
339
+ onMouseDown?: (event: any) => void;
340
+ }) {
341
+ const { nativePaneChrome } = useUiCapabilities();
342
+ const iconColor = enabled ? colors.positive : colors.textMuted;
343
+
344
+ if (!nativePaneChrome) {
345
+ return (
346
+ <Text fg={iconColor} selectable={false} onMouseDown={onMouseDown}>
347
+ {enabled ? "◖)" : "◖·"}
348
+ </Text>
349
+ );
350
+ }
351
+
352
+ return (
353
+ <Span
354
+ fg={iconColor}
355
+ onMouseDown={onMouseDown}
356
+ style={{
357
+ color: iconColor,
358
+ display: "inline-flex",
359
+ alignItems: "center",
360
+ justifyContent: "center",
361
+ width: 16,
362
+ height: 16,
363
+ }}
364
+ >
365
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" aria-hidden="true">
366
+ <path
367
+ d="M4.5 9.5v5h3.2l4.8 4v-13l-4.8 4H4.5Z"
368
+ stroke="currentColor"
369
+ strokeWidth="1.8"
370
+ strokeLinecap="round"
371
+ strokeLinejoin="round"
372
+ />
373
+ {enabled ? (
374
+ <>
375
+ <path
376
+ d="M16 8.5a5 5 0 0 1 0 7"
377
+ stroke="currentColor"
378
+ strokeWidth="1.8"
379
+ strokeLinecap="round"
380
+ />
381
+ <path
382
+ d="M18.8 5.8a9 9 0 0 1 0 12.4"
383
+ stroke="currentColor"
384
+ strokeWidth="1.8"
385
+ strokeLinecap="round"
386
+ />
387
+ </>
388
+ ) : (
389
+ <path
390
+ d="M19 5 5 19"
391
+ stroke="currentColor"
392
+ strokeWidth="1.8"
393
+ strokeLinecap="round"
394
+ />
395
+ )}
396
+ </svg>
397
+ </Span>
398
+ );
399
+ }
400
+
314
401
  function ChatActionChip({
315
402
  label,
316
403
  width,
@@ -752,35 +839,46 @@ const DesktopChatMessage = memo(function DesktopChatMessage({
752
839
 
753
840
  function ChannelSidebar({
754
841
  channels,
842
+ channelStates,
755
843
  activeChannelId,
844
+ onlineCount,
756
845
  width,
757
846
  height,
758
847
  focused,
759
848
  keyboardFocused,
760
849
  loading,
850
+ canManageNotifications,
761
851
  onSelect,
762
852
  onFocusRequest,
853
+ onToggleNotifications,
763
854
  }: {
764
855
  channels: ChatChannel[];
856
+ channelStates: ReturnType<ChatController["getSnapshot"]>["channelStates"];
765
857
  activeChannelId: string;
858
+ onlineCount: number;
766
859
  width: number;
767
860
  height: number;
768
861
  focused: boolean;
769
862
  keyboardFocused: boolean;
770
863
  loading: boolean;
864
+ canManageNotifications: boolean;
771
865
  onSelect?: (channelId: string) => void;
772
866
  onFocusRequest?: () => void;
867
+ onToggleNotifications?: (channelId: string, enabled: boolean) => void;
773
868
  }) {
774
869
  const { nativePaneChrome } = useUiCapabilities();
775
870
  const borderWidth = nativePaneChrome ? 0 : width > 1 ? 1 : 0;
776
871
  const listWidth = Math.max(width - borderWidth, 1);
777
- const labelWidth = Math.max(listWidth - 3, 1);
872
+ const notificationWidth = canManageNotifications ? (nativePaneChrome ? DESKTOP_NOTIFICATION_ICON_WIDTH : 2) : 0;
873
+ const onlineCountPaddingX = nativePaneChrome ? DESKTOP_ONLINE_COUNT_PADDING_X : 0;
874
+ const labelWidth = Math.max(listWidth - 3 - notificationWidth, 1);
778
875
  const dividerColor = focused ? colors.borderFocused : colors.border;
779
876
  const sidebarBg = keyboardFocused ? blendHex(colors.panel, colors.borderFocused, 0.18) : colors.panel;
780
877
  const activeBg = keyboardFocused
781
878
  ? blendHex(colors.selected, colors.borderFocused, 0.32)
782
879
  : blendHex(colors.panel, colors.selected, 0.35);
783
880
  const [hoveredChannelId, setHoveredChannelId] = useState<string | null>(null);
881
+ const channelStateById = useMemo(() => new Map(channelStates.map((state) => [state.channelId, state])), [channelStates]);
784
882
  const sidebarLayoutHeight = nativePaneChrome ? "100%" : height;
785
883
  const nativeFillStyle = nativePaneChrome ? { minHeight: 0 } : undefined;
786
884
  const sidebarBorder = borderWidth > 0
@@ -810,6 +908,9 @@ function ChannelSidebar({
810
908
  >
811
909
  {channels.map((channel) => {
812
910
  const active = channel.id === activeChannelId;
911
+ const channelState = channelStateById.get(channel.id);
912
+ const notificationsEnabled = channelState?.notificationsEnabled === true;
913
+ const unread = (channelState?.unreadCount ?? 0) > 0;
813
914
  const hovered = hoveredChannelId === channel.id && !active;
814
915
  const fg = active ? colors.selectedText : keyboardFocused ? colors.text : colors.textDim;
815
916
  const bg = active ? activeBg : hovered ? hoverBg() : sidebarBg;
@@ -824,6 +925,14 @@ function ChannelSidebar({
824
925
  onFocusRequest?.();
825
926
  onSelect?.(channel.id);
826
927
  };
928
+ const toggleNotifications = (event: any) => {
929
+ event?.preventDefault?.();
930
+ event?.stopPropagation?.();
931
+ if (event) {
932
+ event[CHAT_CHANNEL_MOUSE_HANDLED] = true;
933
+ }
934
+ onToggleNotifications?.(channel.id, !notificationsEnabled);
935
+ };
827
936
  return (
828
937
  <Box
829
938
  key={channel.id}
@@ -837,9 +946,21 @@ function ChannelSidebar({
837
946
  style={{ cursor: "pointer" }}
838
947
  >
839
948
  <Text fg={fg} selectable={false} onMouseDown={selectChannel}> </Text>
840
- <Text fg={fg} selectable={false} onMouseDown={selectChannel}>{active ? "#" : " "}</Text>
841
- <Text fg={fg} selectable={false} onMouseDown={selectChannel}>{truncateChannelLabel(label, labelWidth)}</Text>
949
+ <Text fg={fg} attributes={unread ? TextAttributes.BOLD : 0} selectable={false} onMouseDown={selectChannel}>{active ? "#" : " "}</Text>
950
+ <Text fg={fg} attributes={unread ? TextAttributes.BOLD : 0} selectable={false} onMouseDown={selectChannel}>{truncateChannelLabel(label, labelWidth)}</Text>
842
951
  <Box flexGrow={1} onMouseDown={selectChannel} />
952
+ {canManageNotifications && (
953
+ <Box
954
+ width={notificationWidth}
955
+ height={1}
956
+ alignItems="center"
957
+ justifyContent="center"
958
+ onMouseDown={toggleNotifications}
959
+ style={{ cursor: "pointer" }}
960
+ >
961
+ <ChannelNotificationIcon enabled={notificationsEnabled} onMouseDown={toggleNotifications} />
962
+ </Box>
963
+ )}
843
964
  </Box>
844
965
  );
845
966
  })}
@@ -849,6 +970,12 @@ function ChannelSidebar({
849
970
  <Text fg={colors.textDim}> syncing</Text>
850
971
  </Box>
851
972
  )}
973
+ <Box height={1} width={listWidth} flexDirection="row" paddingX={onlineCountPaddingX}>
974
+ <Text fg={colors.positive}>●</Text>
975
+ <Text fg={colors.textDim}>
976
+ {` ${truncateChannelLabel(`${onlineCount} online`, Math.max(listWidth - 2 - onlineCountPaddingX * 2, 1))}`}
977
+ </Text>
978
+ </Box>
852
979
  </Box>
853
980
  {sidebarBorder}
854
981
  {nativePaneChrome && (
@@ -888,16 +1015,20 @@ export function ChatContent({
888
1015
  const [channels, setChannels] = useState<ChatChannel[]>(initialSnapshot.channels);
889
1016
  const [channelsLoading, setChannelsLoading] = useState(initialSnapshot.channelsLoading);
890
1017
  const showChannelSidebar = channels.length > 1 && width >= CHANNEL_SIDEBAR_BREAKPOINT && height >= 8;
1018
+ const sidebarMinWidth = nativePaneChrome ? DESKTOP_CHANNEL_SIDEBAR_MIN_WIDTH : CHANNEL_SIDEBAR_MIN_WIDTH;
1019
+ const sidebarMaxWidth = nativePaneChrome ? DESKTOP_CHANNEL_SIDEBAR_MAX_WIDTH : CHANNEL_SIDEBAR_MAX_WIDTH;
1020
+ const sidebarWidthRatio = nativePaneChrome ? DESKTOP_CHANNEL_SIDEBAR_WIDTH_RATIO : 0.24;
891
1021
  const channelSidebarWidth = showChannelSidebar
892
- ? Math.min(CHANNEL_SIDEBAR_MAX_WIDTH, Math.max(CHANNEL_SIDEBAR_MIN_WIDTH, Math.floor(width * 0.24)))
1022
+ ? Math.min(sidebarMaxWidth, Math.max(sidebarMinWidth, Math.floor(width * sidebarWidthRatio)))
893
1023
  : 0;
894
- const compactChannelHeaderHeight = nativePaneChrome && !showChannelSidebar && channels.length > 1 ? 1 : 0;
895
1024
  const chatWidth = Math.max(width - channelSidebarWidth, 1);
896
1025
  const contentWidth = Math.max(chatWidth - 2, 1);
897
1026
  const composerPrefixWidth = nativePaneChrome ? 0 : 3;
898
1027
  const composerTextWidth = Math.max(contentWidth - composerPrefixWidth, 1);
899
1028
  const inputValueRef = useRef(initialSnapshot.draft);
900
1029
  const [messages, setMessages] = useState<ChatMessage[]>(initialSnapshot.messages);
1030
+ const [channelStates, setChannelStates] = useState(initialSnapshot.channelStates);
1031
+ const [onlineCount, setOnlineCount] = useState(initialSnapshot.onlineCount);
901
1032
  const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
902
1033
  const [user, setUser] = useState<{ id: string; username: string; emailVerified: boolean } | null>(initialSnapshot.user);
903
1034
  const [loading, setLoading] = useState(initialSnapshot.loading);
@@ -937,6 +1068,7 @@ export function ChatContent({
937
1068
  : 0;
938
1069
  const selectionActive = selectedIdx >= 0 && selectedIdx < messages.length;
939
1070
  const stickyTranscript = followMessages && !selectionActive;
1071
+ const latestMessageId = messages[messages.length - 1]?.id ?? null;
940
1072
  const updateComposerRows = useCallback((draft: string) => {
941
1073
  const nextRows = estimateComposerHeight(draft, composerTextWidth);
942
1074
  setComposerRows((current) => (current === nextRows ? current : nextRows));
@@ -952,6 +1084,7 @@ export function ChatContent({
952
1084
 
953
1085
  useEffect(() => {
954
1086
  void controller.refreshChannels().catch(() => {});
1087
+ void controller.refreshPresence().catch(() => {});
955
1088
  void controller.refreshSession().catch(() => {});
956
1089
  }, [controller]);
957
1090
 
@@ -962,6 +1095,8 @@ export function ChatContent({
962
1095
  const snapshot = controller.getSnapshot(channelId);
963
1096
  setMessages(snapshot.messages);
964
1097
  setChannels(snapshot.channels);
1098
+ setChannelStates(snapshot.channelStates);
1099
+ setOnlineCount(snapshot.onlineCount);
965
1100
  setChannelsLoading(snapshot.channelsLoading);
966
1101
  setHasSavedSession(snapshot.hasSavedSession);
967
1102
  setUser(snapshot.user);
@@ -987,6 +1122,8 @@ export function ChatContent({
987
1122
  function handleSnapshot(snapshot: ReturnType<ChatController["getSnapshot"]>) {
988
1123
  setMessages(snapshot.messages);
989
1124
  setChannels(snapshot.channels);
1125
+ setChannelStates(snapshot.channelStates);
1126
+ setOnlineCount(snapshot.onlineCount);
990
1127
  setChannelsLoading(snapshot.channelsLoading);
991
1128
  setHasSavedSession(snapshot.hasSavedSession);
992
1129
  setUser(snapshot.user);
@@ -1111,17 +1248,31 @@ export function ChatContent({
1111
1248
  return textarea.visualCursor.visualRow >= visualLineCount - 1;
1112
1249
  }, []);
1113
1250
 
1251
+ const clearLocalComposer = useCallback(() => {
1252
+ inputValueRef.current = "";
1253
+ updateComposerRows("");
1254
+ const textarea = inputRef.current;
1255
+ if (textarea && textarea.editBuffer.getText() !== "") {
1256
+ applyingExternalDraftRef.current = true;
1257
+ try {
1258
+ textarea.setText("");
1259
+ } finally {
1260
+ applyingExternalDraftRef.current = false;
1261
+ }
1262
+ }
1263
+ }, [updateComposerRows]);
1264
+
1114
1265
  const sendMessage = useCallback(() => {
1115
1266
  const content = inputValueRef.current.trim();
1116
1267
  if (!content) return;
1117
- if (useDefaultControllerChannel) {
1118
- controller.send(content, replyToRef.current?.id);
1119
- } else {
1120
- controller.sendToChannel(channelId, content, replyToRef.current?.id);
1121
- }
1268
+ const accepted = useDefaultControllerChannel
1269
+ ? controller.send(content, replyToRef.current?.id)
1270
+ : controller.sendToChannel(channelId, content, replyToRef.current?.id);
1271
+ if (!accepted) return;
1272
+ clearLocalComposer();
1122
1273
  setSelectedIdx(-1);
1123
1274
  setFollowMessages(true);
1124
- }, [channelId, controller, useDefaultControllerChannel]);
1275
+ }, [channelId, clearLocalComposer, controller, useDefaultControllerChannel]);
1125
1276
 
1126
1277
  const requestOlderMessages = useCallback(() => {
1127
1278
  const scrollBox = scrollRef.current;
@@ -1132,7 +1283,7 @@ export function ChatContent({
1132
1283
  oldestMessageId: messages[0]?.id ?? null,
1133
1284
  scrollHeight: getScrollHeight(scrollBox, exactPixels),
1134
1285
  scrollTop: getScrollTop(scrollBox, exactPixels),
1135
- selectedMessageId: selectedIdx >= 0 ? messages[selectedIdx]?.id ?? null : null,
1286
+ selectedMessageId: selectedIdx >= 0 ? messages[selectedIdx]?.id ?? null : messages[0]?.id ?? null,
1136
1287
  };
1137
1288
  setFollowMessages(false);
1138
1289
  const request = useDefaultControllerChannel
@@ -1149,6 +1300,23 @@ export function ChatContent({
1149
1300
  requestOlderMessages();
1150
1301
  }, [requestOlderMessages]);
1151
1302
 
1303
+ const handleTranscriptScrollActivity = useCallback((event?: { scroll?: { direction?: "up" | "down" | "left" | "right" } }) => {
1304
+ const direction = event?.scroll?.direction;
1305
+ runAfterLayout(() => {
1306
+ requestOlderMessagesIfNeeded();
1307
+
1308
+ const scrollBox = scrollRef.current;
1309
+ if (!scrollBox) return;
1310
+ const atBottom = isScrolledToBottom(scrollBox, nativePaneChrome);
1311
+ if (direction === "up" && !atBottom) {
1312
+ setFollowMessages(false);
1313
+ return;
1314
+ }
1315
+
1316
+ setFollowMessages(atBottom);
1317
+ });
1318
+ }, [nativePaneChrome, requestOlderMessagesIfNeeded]);
1319
+
1152
1320
  const scrollToLoadedMessage = useCallback((messageId: string) => {
1153
1321
  const targetIndex = messages.findIndex((message) => message.id === messageId);
1154
1322
  if (targetIndex < 0) return false;
@@ -1307,20 +1475,20 @@ export function ChatContent({
1307
1475
  const isEnterKey = event.name === "return" || event.name === "enter";
1308
1476
 
1309
1477
  if (sidebarFocusedRef.current && showChannelSidebar) {
1310
- if (event.name === "left") {
1478
+ if (isPlainKey(event, "left")) {
1311
1479
  event.preventDefault?.();
1312
1480
  event.stopPropagation?.();
1313
1481
  return;
1314
1482
  }
1315
1483
 
1316
- if (event.name === "right") {
1484
+ if (isPlainKey(event, "right")) {
1317
1485
  event.preventDefault?.();
1318
1486
  event.stopPropagation?.();
1319
1487
  focusChatContent();
1320
1488
  return;
1321
1489
  }
1322
1490
 
1323
- if (event.name === "up" || event.name === "down") {
1491
+ if (isPlainKey(event, "up", "down")) {
1324
1492
  event.preventDefault?.();
1325
1493
  event.stopPropagation?.();
1326
1494
  moveSidebarChannelSelection(event.name);
@@ -1329,7 +1497,7 @@ export function ChatContent({
1329
1497
  }
1330
1498
 
1331
1499
  if (
1332
- event.name === "left" &&
1500
+ isPlainKey(event, "left") &&
1333
1501
  showChannelSidebar &&
1334
1502
  (!inputFocused || inputValueRef.current.length === 0) &&
1335
1503
  focusChannelSidebar()
@@ -1351,7 +1519,7 @@ export function ChatContent({
1351
1519
  return;
1352
1520
  }
1353
1521
 
1354
- if ((event.name === "up" || event.name === "down") && shouldLeaveComposerForSelection(event.name)) {
1522
+ if (isPlainKey(event, "up", "down") && shouldLeaveComposerForSelection(event.name)) {
1355
1523
  const moved = moveMessageSelection(event.name);
1356
1524
  if (moved) {
1357
1525
  event.preventDefault?.();
@@ -1364,12 +1532,12 @@ export function ChatContent({
1364
1532
  return;
1365
1533
  }
1366
1534
 
1367
- if ((event.name === "]" || event.sequence === "]") && cycleChannel(1)) {
1535
+ if (isPlainKey(event, "]") && cycleChannel(1)) {
1368
1536
  event.preventDefault?.();
1369
1537
  event.stopPropagation?.();
1370
1538
  return;
1371
1539
  }
1372
- if ((event.name === "[" || event.sequence === "[") && cycleChannel(-1)) {
1540
+ if (isPlainKey(event, "[") && cycleChannel(-1)) {
1373
1541
  event.preventDefault?.();
1374
1542
  event.stopPropagation?.();
1375
1543
  return;
@@ -1382,7 +1550,7 @@ export function ChatContent({
1382
1550
  return;
1383
1551
  }
1384
1552
 
1385
- if ((isEnterKey || event.name === "i") && canSend) {
1553
+ if ((isEnterKey || isPlainKey(event, "i")) && canSend) {
1386
1554
  event.preventDefault?.();
1387
1555
  event.stopPropagation?.();
1388
1556
  queueMicrotask(() => focusComposer());
@@ -1399,7 +1567,7 @@ export function ChatContent({
1399
1567
  return;
1400
1568
  }
1401
1569
 
1402
- if (event.name === "j" || event.name === "down") {
1570
+ if (isPlainKey(event, "j", "down")) {
1403
1571
  event.preventDefault?.();
1404
1572
  event.stopPropagation?.();
1405
1573
  if (selectedIdx === messages.length - 1) {
@@ -1409,7 +1577,7 @@ export function ChatContent({
1409
1577
  moveMessageSelection("down");
1410
1578
  return;
1411
1579
  }
1412
- if (event.name === "k" || event.name === "up") {
1580
+ if (isPlainKey(event, "k", "up")) {
1413
1581
  event.preventDefault?.();
1414
1582
  event.stopPropagation?.();
1415
1583
  if (selectedIdx === 0 && hasOlderMessages && !loadingOlderMessages) {
@@ -1420,14 +1588,14 @@ export function ChatContent({
1420
1588
  return;
1421
1589
  }
1422
1590
 
1423
- if (canSend && event.name === "r" && selectedIdx >= 0 && selectedIdx < messages.length) {
1591
+ if (canSend && isPlainKey(event, "r") && selectedIdx >= 0 && selectedIdx < messages.length) {
1424
1592
  event.preventDefault?.();
1425
1593
  event.stopPropagation?.();
1426
1594
  beginReplyTo(selectedIdx, { deferFocus: true });
1427
1595
  return;
1428
1596
  }
1429
1597
 
1430
- if (event.name === "g" && !event.shift) {
1598
+ if (isPlainKey(event, "g")) {
1431
1599
  event.preventDefault?.();
1432
1600
  event.stopPropagation?.();
1433
1601
  setSelectedIdx(0);
@@ -1453,7 +1621,7 @@ export function ChatContent({
1453
1621
  const inputAreaHeight = canSend ? composerBlockHeight + inputMetaHeight : 2;
1454
1622
  const topSeparatorHeight = nativePaneChrome ? 0 : 1;
1455
1623
  const footerSeparatorHeight = !nativePaneChrome && !canSend ? 1 : 0;
1456
- const messageAreaHeight = Math.max(1, height - compactChannelHeaderHeight - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
1624
+ const messageAreaHeight = Math.max(1, height - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
1457
1625
  const composerWidth = nativePaneChrome ? chatWidth : contentWidth;
1458
1626
 
1459
1627
  useEffect(() => {
@@ -1464,36 +1632,38 @@ export function ChatContent({
1464
1632
  useEffect(() => {
1465
1633
  if (!stickyTranscript) return;
1466
1634
  queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
1467
- }, [contentWidth, height, messages, messageAreaHeight, nativePaneChrome, stickyTranscript]);
1635
+ }, [channelId, contentWidth, height, latestMessageId, messageAreaHeight, nativePaneChrome, stickyTranscript]);
1468
1636
 
1469
1637
  useEffect(() => {
1470
1638
  const anchor = prependAnchorRef.current;
1471
1639
  if (!anchor || loadingOlderMessages) return;
1472
1640
 
1641
+ const currentAnchor = anchor;
1642
+ const scrollBox = scrollRef.current;
1643
+ if (!scrollBox) return;
1644
+ if (pendingJumpMessageIdRef.current) {
1645
+ prependAnchorRef.current = null;
1646
+ return;
1647
+ }
1648
+
1649
+ const previousOldestIndex = currentAnchor.oldestMessageId
1650
+ ? messages.findIndex((message) => message.id === currentAnchor.oldestMessageId)
1651
+ : -1;
1652
+ const exactPixels = nativePaneChrome === true && hasPixelScrollMetrics(scrollBox);
1653
+ const addedRows = !exactPixels && previousOldestIndex > 0
1654
+ ? getMessageTopOffset(messages, previousOldestIndex, contentWidth)
1655
+ : Math.max(0, getScrollHeight(scrollBox, exactPixels) - currentAnchor.scrollHeight);
1656
+ scrollToPosition(scrollBox, currentAnchor.scrollTop + addedRows, exactPixels);
1657
+ if (currentAnchor.selectedMessageId) {
1658
+ const selectedMessageIndex = messages.findIndex((message) => message.id === currentAnchor.selectedMessageId);
1659
+ if (selectedMessageIndex >= 0) {
1660
+ setSelectedIdx(selectedMessageIndex);
1661
+ }
1662
+ }
1473
1663
  queueMicrotask(() => {
1474
- const currentAnchor = prependAnchorRef.current;
1475
- const scrollBox = scrollRef.current;
1476
- if (!currentAnchor || !scrollBox) return;
1477
- if (pendingJumpMessageIdRef.current) {
1664
+ if (prependAnchorRef.current === currentAnchor) {
1478
1665
  prependAnchorRef.current = null;
1479
- return;
1480
1666
  }
1481
-
1482
- const previousOldestIndex = currentAnchor.oldestMessageId
1483
- ? messages.findIndex((message) => message.id === currentAnchor.oldestMessageId)
1484
- : -1;
1485
- const exactPixels = nativePaneChrome === true && hasPixelScrollMetrics(scrollBox);
1486
- const addedRows = !exactPixels && previousOldestIndex > 0
1487
- ? getMessageTopOffset(messages, previousOldestIndex, contentWidth)
1488
- : Math.max(0, getScrollHeight(scrollBox, exactPixels) - currentAnchor.scrollHeight);
1489
- scrollToPosition(scrollBox, currentAnchor.scrollTop + addedRows, exactPixels);
1490
- if (currentAnchor.selectedMessageId) {
1491
- const selectedMessageIndex = messages.findIndex((message) => message.id === currentAnchor.selectedMessageId);
1492
- if (selectedMessageIndex >= 0) {
1493
- setSelectedIdx(selectedMessageIndex);
1494
- }
1495
- }
1496
- prependAnchorRef.current = null;
1497
1667
  });
1498
1668
  }, [contentWidth, loadingOlderMessages, messages, nativePaneChrome]);
1499
1669
 
@@ -1506,6 +1676,7 @@ export function ChatContent({
1506
1676
  const sb = scrollRef.current;
1507
1677
  if (!sb) return;
1508
1678
  if (!selectionActive) return;
1679
+ if (prependAnchorRef.current) return;
1509
1680
  if (nativePaneChrome) {
1510
1681
  const selectedMessageId = messages[selectedIdx]?.id ?? "";
1511
1682
  runAfterLayout(() => scrollElementIntoScrollBoxView(scrollRef.current, messageElementsRef.current.get(selectedMessageId)));
@@ -1531,8 +1702,6 @@ export function ChatContent({
1531
1702
  )
1532
1703
  : "";
1533
1704
  const inputPlaceholder = replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
1534
- const activeChannel = channels.find((channel) => channel.id === channelId);
1535
- const activeChannelLabel = formatChannelLabel(activeChannel, channelId);
1536
1705
  const chatContentBg = focused && showChannelSidebar && !sidebarFocused
1537
1706
  ? blendHex(colors.bg, colors.borderFocused, 0.08)
1538
1707
  : undefined;
@@ -1550,14 +1719,20 @@ export function ChatContent({
1550
1719
  {showChannelSidebar && (
1551
1720
  <ChannelSidebar
1552
1721
  channels={channels}
1722
+ channelStates={channelStates}
1553
1723
  activeChannelId={channelId}
1724
+ onlineCount={onlineCount}
1554
1725
  width={channelSidebarWidth}
1555
1726
  height={height}
1556
1727
  focused={focused}
1557
1728
  keyboardFocused={sidebarFocused}
1558
1729
  loading={channelsLoading}
1730
+ canManageNotifications={!!user?.emailVerified}
1559
1731
  onSelect={changeChannel}
1560
1732
  onFocusRequest={() => setSidebarFocused(true)}
1733
+ onToggleNotifications={(nextChannelId, enabled) => {
1734
+ controller.setChannelNotificationsEnabled(nextChannelId, enabled);
1735
+ }}
1561
1736
  />
1562
1737
  )}
1563
1738
 
@@ -1570,15 +1745,6 @@ export function ChatContent({
1570
1745
  onMouseDown={() => setSidebarFocused(false)}
1571
1746
  style={nativeFillStyle}
1572
1747
  >
1573
- {compactChannelHeaderHeight > 0 && (
1574
- <Box height={1} width={contentWidth} flexDirection="row">
1575
- <Text fg={colors.textDim}>#</Text>
1576
- <Text fg={colors.positive} attributes={TextAttributes.BOLD}>
1577
- {truncateChannelLabel(activeChannelLabel, Math.max(contentWidth - 1, 1))}
1578
- </Text>
1579
- </Box>
1580
- )}
1581
-
1582
1748
  {!nativePaneChrome && (
1583
1749
  <Box height={1} width={contentWidth}>
1584
1750
  <Text fg={colors.border}>{"-".repeat(contentWidth)}</Text>
@@ -1593,7 +1759,7 @@ export function ChatContent({
1593
1759
  focusable={false}
1594
1760
  stickyScroll={stickyTranscript}
1595
1761
  stickyStart="bottom"
1596
- onMouseScroll={() => queueMicrotask(requestOlderMessagesIfNeeded)}
1762
+ onMouseScroll={handleTranscriptScrollActivity}
1597
1763
  style={nativePaneChrome ? { minHeight: 0 } : undefined}
1598
1764
  >
1599
1765
  {loadingOlderMessages && (
@@ -1655,6 +1821,17 @@ export function ChatContent({
1655
1821
 
1656
1822
  {canSend ? (
1657
1823
  <>
1824
+ {nativePaneChrome && (
1825
+ <Box
1826
+ width={composerWidth}
1827
+ style={{
1828
+ flex: `0 0 ${DESKTOP_CHAT_INPUT_TOP_MARGIN_PX}px`,
1829
+ height: DESKTOP_CHAT_INPUT_TOP_MARGIN_PX,
1830
+ minHeight: DESKTOP_CHAT_INPUT_TOP_MARGIN_PX,
1831
+ }}
1832
+ />
1833
+ )}
1834
+
1658
1835
  {replyTo && (
1659
1836
  <Box height={1} width={contentWidth} flexDirection="row">
1660
1837
  <Text fg={colors.textMuted}> replying to </Text>
@@ -1735,9 +1912,6 @@ function ChatPane({ focused, width, height, close }: PaneProps) {
1735
1912
  const persistChannelId = useCallback((nextChannelId: string) => {
1736
1913
  const currentState = stateRef.current;
1737
1914
  const layout = setPaneSetting(currentState.config.layout, paneId, "channelId", nextChannelId);
1738
- const layouts = currentState.config.layouts.map((savedLayout, index) => (
1739
- index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
1740
- ));
1741
1915
  const pluginConfig = {
1742
1916
  ...currentState.config.pluginConfig,
1743
1917
  "gloomberb-cloud": {
@@ -1748,11 +1922,16 @@ function ChatPane({ focused, width, height, close }: PaneProps) {
1748
1922
  const nextConfig = {
1749
1923
  ...currentState.config,
1750
1924
  layout,
1751
- layouts,
1752
1925
  pluginConfig,
1753
1926
  };
1754
- dispatch({ type: "SET_CONFIG", config: nextConfig });
1755
- scheduleConfigSave(nextConfig);
1927
+ const syncedConfig = syncConfigActiveLayoutState(
1928
+ nextConfig,
1929
+ currentState.paneState,
1930
+ currentState.focusedPaneId,
1931
+ currentState.activePanel,
1932
+ );
1933
+ dispatch({ type: "SET_CONFIG", config: syncedConfig });
1934
+ scheduleConfigSave(syncedConfig);
1756
1935
  }, [dispatch, paneId, stateRef]);
1757
1936
  const [channelId, setLocalChannelId] = useState(initialChannelIdRef.current);
1758
1937
  const pendingChannelIdRef = useRef<string | null>(null);