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
@@ -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);
@@ -1132,7 +1269,7 @@ export function ChatContent({
1132
1269
  oldestMessageId: messages[0]?.id ?? null,
1133
1270
  scrollHeight: getScrollHeight(scrollBox, exactPixels),
1134
1271
  scrollTop: getScrollTop(scrollBox, exactPixels),
1135
- selectedMessageId: selectedIdx >= 0 ? messages[selectedIdx]?.id ?? null : null,
1272
+ selectedMessageId: selectedIdx >= 0 ? messages[selectedIdx]?.id ?? null : messages[0]?.id ?? null,
1136
1273
  };
1137
1274
  setFollowMessages(false);
1138
1275
  const request = useDefaultControllerChannel
@@ -1149,6 +1286,23 @@ export function ChatContent({
1149
1286
  requestOlderMessages();
1150
1287
  }, [requestOlderMessages]);
1151
1288
 
1289
+ const handleTranscriptScrollActivity = useCallback((event?: { scroll?: { direction?: "up" | "down" | "left" | "right" } }) => {
1290
+ const direction = event?.scroll?.direction;
1291
+ runAfterLayout(() => {
1292
+ requestOlderMessagesIfNeeded();
1293
+
1294
+ const scrollBox = scrollRef.current;
1295
+ if (!scrollBox) return;
1296
+ const atBottom = isScrolledToBottom(scrollBox, nativePaneChrome);
1297
+ if (direction === "up" && !atBottom) {
1298
+ setFollowMessages(false);
1299
+ return;
1300
+ }
1301
+
1302
+ setFollowMessages(atBottom);
1303
+ });
1304
+ }, [nativePaneChrome, requestOlderMessagesIfNeeded]);
1305
+
1152
1306
  const scrollToLoadedMessage = useCallback((messageId: string) => {
1153
1307
  const targetIndex = messages.findIndex((message) => message.id === messageId);
1154
1308
  if (targetIndex < 0) return false;
@@ -1307,20 +1461,20 @@ export function ChatContent({
1307
1461
  const isEnterKey = event.name === "return" || event.name === "enter";
1308
1462
 
1309
1463
  if (sidebarFocusedRef.current && showChannelSidebar) {
1310
- if (event.name === "left") {
1464
+ if (isPlainKey(event, "left")) {
1311
1465
  event.preventDefault?.();
1312
1466
  event.stopPropagation?.();
1313
1467
  return;
1314
1468
  }
1315
1469
 
1316
- if (event.name === "right") {
1470
+ if (isPlainKey(event, "right")) {
1317
1471
  event.preventDefault?.();
1318
1472
  event.stopPropagation?.();
1319
1473
  focusChatContent();
1320
1474
  return;
1321
1475
  }
1322
1476
 
1323
- if (event.name === "up" || event.name === "down") {
1477
+ if (isPlainKey(event, "up", "down")) {
1324
1478
  event.preventDefault?.();
1325
1479
  event.stopPropagation?.();
1326
1480
  moveSidebarChannelSelection(event.name);
@@ -1329,7 +1483,7 @@ export function ChatContent({
1329
1483
  }
1330
1484
 
1331
1485
  if (
1332
- event.name === "left" &&
1486
+ isPlainKey(event, "left") &&
1333
1487
  showChannelSidebar &&
1334
1488
  (!inputFocused || inputValueRef.current.length === 0) &&
1335
1489
  focusChannelSidebar()
@@ -1351,7 +1505,7 @@ export function ChatContent({
1351
1505
  return;
1352
1506
  }
1353
1507
 
1354
- if ((event.name === "up" || event.name === "down") && shouldLeaveComposerForSelection(event.name)) {
1508
+ if (isPlainKey(event, "up", "down") && shouldLeaveComposerForSelection(event.name)) {
1355
1509
  const moved = moveMessageSelection(event.name);
1356
1510
  if (moved) {
1357
1511
  event.preventDefault?.();
@@ -1364,12 +1518,12 @@ export function ChatContent({
1364
1518
  return;
1365
1519
  }
1366
1520
 
1367
- if ((event.name === "]" || event.sequence === "]") && cycleChannel(1)) {
1521
+ if (isPlainKey(event, "]") && cycleChannel(1)) {
1368
1522
  event.preventDefault?.();
1369
1523
  event.stopPropagation?.();
1370
1524
  return;
1371
1525
  }
1372
- if ((event.name === "[" || event.sequence === "[") && cycleChannel(-1)) {
1526
+ if (isPlainKey(event, "[") && cycleChannel(-1)) {
1373
1527
  event.preventDefault?.();
1374
1528
  event.stopPropagation?.();
1375
1529
  return;
@@ -1382,7 +1536,7 @@ export function ChatContent({
1382
1536
  return;
1383
1537
  }
1384
1538
 
1385
- if ((isEnterKey || event.name === "i") && canSend) {
1539
+ if ((isEnterKey || isPlainKey(event, "i")) && canSend) {
1386
1540
  event.preventDefault?.();
1387
1541
  event.stopPropagation?.();
1388
1542
  queueMicrotask(() => focusComposer());
@@ -1399,7 +1553,7 @@ export function ChatContent({
1399
1553
  return;
1400
1554
  }
1401
1555
 
1402
- if (event.name === "j" || event.name === "down") {
1556
+ if (isPlainKey(event, "j", "down")) {
1403
1557
  event.preventDefault?.();
1404
1558
  event.stopPropagation?.();
1405
1559
  if (selectedIdx === messages.length - 1) {
@@ -1409,7 +1563,7 @@ export function ChatContent({
1409
1563
  moveMessageSelection("down");
1410
1564
  return;
1411
1565
  }
1412
- if (event.name === "k" || event.name === "up") {
1566
+ if (isPlainKey(event, "k", "up")) {
1413
1567
  event.preventDefault?.();
1414
1568
  event.stopPropagation?.();
1415
1569
  if (selectedIdx === 0 && hasOlderMessages && !loadingOlderMessages) {
@@ -1420,14 +1574,14 @@ export function ChatContent({
1420
1574
  return;
1421
1575
  }
1422
1576
 
1423
- if (canSend && event.name === "r" && selectedIdx >= 0 && selectedIdx < messages.length) {
1577
+ if (canSend && isPlainKey(event, "r") && selectedIdx >= 0 && selectedIdx < messages.length) {
1424
1578
  event.preventDefault?.();
1425
1579
  event.stopPropagation?.();
1426
1580
  beginReplyTo(selectedIdx, { deferFocus: true });
1427
1581
  return;
1428
1582
  }
1429
1583
 
1430
- if (event.name === "g" && !event.shift) {
1584
+ if (isPlainKey(event, "g")) {
1431
1585
  event.preventDefault?.();
1432
1586
  event.stopPropagation?.();
1433
1587
  setSelectedIdx(0);
@@ -1453,7 +1607,7 @@ export function ChatContent({
1453
1607
  const inputAreaHeight = canSend ? composerBlockHeight + inputMetaHeight : 2;
1454
1608
  const topSeparatorHeight = nativePaneChrome ? 0 : 1;
1455
1609
  const footerSeparatorHeight = !nativePaneChrome && !canSend ? 1 : 0;
1456
- const messageAreaHeight = Math.max(1, height - compactChannelHeaderHeight - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
1610
+ const messageAreaHeight = Math.max(1, height - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
1457
1611
  const composerWidth = nativePaneChrome ? chatWidth : contentWidth;
1458
1612
 
1459
1613
  useEffect(() => {
@@ -1464,36 +1618,38 @@ export function ChatContent({
1464
1618
  useEffect(() => {
1465
1619
  if (!stickyTranscript) return;
1466
1620
  queueMicrotask(() => scrollToBottom(scrollRef.current, nativePaneChrome));
1467
- }, [contentWidth, height, messages, messageAreaHeight, nativePaneChrome, stickyTranscript]);
1621
+ }, [channelId, contentWidth, height, latestMessageId, messageAreaHeight, nativePaneChrome, stickyTranscript]);
1468
1622
 
1469
1623
  useEffect(() => {
1470
1624
  const anchor = prependAnchorRef.current;
1471
1625
  if (!anchor || loadingOlderMessages) return;
1472
1626
 
1627
+ const currentAnchor = anchor;
1628
+ const scrollBox = scrollRef.current;
1629
+ if (!scrollBox) return;
1630
+ if (pendingJumpMessageIdRef.current) {
1631
+ prependAnchorRef.current = null;
1632
+ return;
1633
+ }
1634
+
1635
+ const previousOldestIndex = currentAnchor.oldestMessageId
1636
+ ? messages.findIndex((message) => message.id === currentAnchor.oldestMessageId)
1637
+ : -1;
1638
+ const exactPixels = nativePaneChrome === true && hasPixelScrollMetrics(scrollBox);
1639
+ const addedRows = !exactPixels && previousOldestIndex > 0
1640
+ ? getMessageTopOffset(messages, previousOldestIndex, contentWidth)
1641
+ : Math.max(0, getScrollHeight(scrollBox, exactPixels) - currentAnchor.scrollHeight);
1642
+ scrollToPosition(scrollBox, currentAnchor.scrollTop + addedRows, exactPixels);
1643
+ if (currentAnchor.selectedMessageId) {
1644
+ const selectedMessageIndex = messages.findIndex((message) => message.id === currentAnchor.selectedMessageId);
1645
+ if (selectedMessageIndex >= 0) {
1646
+ setSelectedIdx(selectedMessageIndex);
1647
+ }
1648
+ }
1473
1649
  queueMicrotask(() => {
1474
- const currentAnchor = prependAnchorRef.current;
1475
- const scrollBox = scrollRef.current;
1476
- if (!currentAnchor || !scrollBox) return;
1477
- if (pendingJumpMessageIdRef.current) {
1650
+ if (prependAnchorRef.current === currentAnchor) {
1478
1651
  prependAnchorRef.current = null;
1479
- return;
1480
1652
  }
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
1653
  });
1498
1654
  }, [contentWidth, loadingOlderMessages, messages, nativePaneChrome]);
1499
1655
 
@@ -1506,6 +1662,7 @@ export function ChatContent({
1506
1662
  const sb = scrollRef.current;
1507
1663
  if (!sb) return;
1508
1664
  if (!selectionActive) return;
1665
+ if (prependAnchorRef.current) return;
1509
1666
  if (nativePaneChrome) {
1510
1667
  const selectedMessageId = messages[selectedIdx]?.id ?? "";
1511
1668
  runAfterLayout(() => scrollElementIntoScrollBoxView(scrollRef.current, messageElementsRef.current.get(selectedMessageId)));
@@ -1531,8 +1688,6 @@ export function ChatContent({
1531
1688
  )
1532
1689
  : "";
1533
1690
  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
1691
  const chatContentBg = focused && showChannelSidebar && !sidebarFocused
1537
1692
  ? blendHex(colors.bg, colors.borderFocused, 0.08)
1538
1693
  : undefined;
@@ -1550,14 +1705,20 @@ export function ChatContent({
1550
1705
  {showChannelSidebar && (
1551
1706
  <ChannelSidebar
1552
1707
  channels={channels}
1708
+ channelStates={channelStates}
1553
1709
  activeChannelId={channelId}
1710
+ onlineCount={onlineCount}
1554
1711
  width={channelSidebarWidth}
1555
1712
  height={height}
1556
1713
  focused={focused}
1557
1714
  keyboardFocused={sidebarFocused}
1558
1715
  loading={channelsLoading}
1716
+ canManageNotifications={!!user?.emailVerified}
1559
1717
  onSelect={changeChannel}
1560
1718
  onFocusRequest={() => setSidebarFocused(true)}
1719
+ onToggleNotifications={(nextChannelId, enabled) => {
1720
+ controller.setChannelNotificationsEnabled(nextChannelId, enabled);
1721
+ }}
1561
1722
  />
1562
1723
  )}
1563
1724
 
@@ -1570,15 +1731,6 @@ export function ChatContent({
1570
1731
  onMouseDown={() => setSidebarFocused(false)}
1571
1732
  style={nativeFillStyle}
1572
1733
  >
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
1734
  {!nativePaneChrome && (
1583
1735
  <Box height={1} width={contentWidth}>
1584
1736
  <Text fg={colors.border}>{"-".repeat(contentWidth)}</Text>
@@ -1593,7 +1745,7 @@ export function ChatContent({
1593
1745
  focusable={false}
1594
1746
  stickyScroll={stickyTranscript}
1595
1747
  stickyStart="bottom"
1596
- onMouseScroll={() => queueMicrotask(requestOlderMessagesIfNeeded)}
1748
+ onMouseScroll={handleTranscriptScrollActivity}
1597
1749
  style={nativePaneChrome ? { minHeight: 0 } : undefined}
1598
1750
  >
1599
1751
  {loadingOlderMessages && (
@@ -1655,6 +1807,17 @@ export function ChatContent({
1655
1807
 
1656
1808
  {canSend ? (
1657
1809
  <>
1810
+ {nativePaneChrome && (
1811
+ <Box
1812
+ width={composerWidth}
1813
+ style={{
1814
+ flex: `0 0 ${DESKTOP_CHAT_INPUT_TOP_MARGIN_PX}px`,
1815
+ height: DESKTOP_CHAT_INPUT_TOP_MARGIN_PX,
1816
+ minHeight: DESKTOP_CHAT_INPUT_TOP_MARGIN_PX,
1817
+ }}
1818
+ />
1819
+ )}
1820
+
1658
1821
  {replyTo && (
1659
1822
  <Box height={1} width={contentWidth} flexDirection="row">
1660
1823
  <Text fg={colors.textMuted}> replying to </Text>
@@ -1735,9 +1898,6 @@ function ChatPane({ focused, width, height, close }: PaneProps) {
1735
1898
  const persistChannelId = useCallback((nextChannelId: string) => {
1736
1899
  const currentState = stateRef.current;
1737
1900
  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
1901
  const pluginConfig = {
1742
1902
  ...currentState.config.pluginConfig,
1743
1903
  "gloomberb-cloud": {
@@ -1748,11 +1908,16 @@ function ChatPane({ focused, width, height, close }: PaneProps) {
1748
1908
  const nextConfig = {
1749
1909
  ...currentState.config,
1750
1910
  layout,
1751
- layouts,
1752
1911
  pluginConfig,
1753
1912
  };
1754
- dispatch({ type: "SET_CONFIG", config: nextConfig });
1755
- scheduleConfigSave(nextConfig);
1913
+ const syncedConfig = syncConfigActiveLayoutState(
1914
+ nextConfig,
1915
+ currentState.paneState,
1916
+ currentState.focusedPaneId,
1917
+ currentState.activePanel,
1918
+ );
1919
+ dispatch({ type: "SET_CONFIG", config: syncedConfig });
1920
+ scheduleConfigSave(syncedConfig);
1756
1921
  }, [dispatch, paneId, stateRef]);
1757
1922
  const [channelId, setLocalChannelId] = useState(initialChannelIdRef.current);
1758
1923
  const pendingChannelIdRef = useRef<string | null>(null);