gloomberb 0.8.0 → 0.8.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 (122) hide show
  1. package/README.md +102 -52
  2. package/package.json +1 -1
  3. package/src/api-client/chat.ts +8 -0
  4. package/src/api-client/index.test.ts +30 -0
  5. package/src/api-client/index.ts +4 -0
  6. package/src/api-client/normalizers.ts +1 -0
  7. package/src/api-client/types.ts +1 -0
  8. package/src/capabilities/factories.ts +4 -0
  9. package/src/components/chart/comparison/data.test.ts +54 -0
  10. package/src/components/chart/comparison/data.ts +47 -24
  11. package/src/components/chart/core/projection.ts +51 -27
  12. package/src/components/data-table/stack-view.test.tsx +14 -11
  13. package/src/components/data-table/stack-view.tsx +1 -2
  14. package/src/components/data-table/view.test.tsx +24 -18
  15. package/src/components/data-table/view.tsx +313 -83
  16. package/src/components/feed-data-table/stack-view.tsx +6 -8
  17. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  18. package/src/components/layout/pane/footer/index.tsx +14 -2
  19. package/src/components/layout/pane/title.ts +5 -1
  20. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  21. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  22. package/src/components/ticker/list-table-view.tsx +11 -25
  23. package/src/components/ui/choice-dialog.tsx +12 -2
  24. package/src/components/ui/multi-select/dialog.tsx +17 -5
  25. package/src/components/ui/shortcut-hint.tsx +14 -2
  26. package/src/components/ui/table-layout.test.ts +2 -2
  27. package/src/components/ui/table-layout.ts +8 -2
  28. package/src/components/ui/tabs.tsx +20 -6
  29. package/src/components/ui/ui.test.tsx +30 -5
  30. package/src/market-data/coordinator/auxiliary.ts +29 -1
  31. package/src/market-data/coordinator/index.ts +17 -1
  32. package/src/market-data/hooks.tsx +17 -1
  33. package/src/market-data/selectors.ts +4 -0
  34. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  35. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  36. package/src/plugins/builtin/account-management/model.ts +13 -3
  37. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  38. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  39. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  40. package/src/plugins/builtin/analytics/index.tsx +0 -3
  41. package/src/plugins/builtin/analytics/view.tsx +6 -5
  42. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  43. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  44. package/src/plugins/builtin/changelog/index.tsx +40 -27
  45. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  46. package/src/plugins/builtin/changelog/model.ts +98 -0
  47. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  48. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  49. package/src/plugins/builtin/chat/channels.ts +36 -19
  50. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  51. package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
  52. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  53. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  54. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  55. package/src/plugins/builtin/chat/content/index.tsx +61 -0
  56. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
  58. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  59. package/src/plugins/builtin/chat/content/types.ts +1 -0
  60. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  61. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  62. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  63. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  64. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  65. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  66. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  67. package/src/plugins/builtin/chat/layout.ts +94 -31
  68. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  69. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  70. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  71. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  72. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  73. package/src/plugins/builtin/chat/message/types.ts +2 -0
  74. package/src/plugins/builtin/chat/pane.tsx +35 -7
  75. package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
  76. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  77. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  78. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  79. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  80. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  81. package/src/plugins/builtin/earnings/index.tsx +8 -7
  82. package/src/plugins/builtin/econ/index.tsx +6 -8
  83. package/src/plugins/builtin/help/index.tsx +6 -0
  84. package/src/plugins/builtin/holders/pane.tsx +7 -6
  85. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  86. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  87. package/src/plugins/builtin/options/view.tsx +16 -8
  88. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  89. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  90. package/src/plugins/builtin/research/index.test.ts +30 -1
  91. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  92. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  93. package/src/plugins/builtin/sec/index.tsx +132 -20
  94. package/src/plugins/builtin/sectors/index.tsx +13 -23
  95. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  96. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  97. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  98. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  99. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  100. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  101. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  102. package/src/plugins/prediction-markets/pane.tsx +10 -9
  103. package/src/plugins/registry/index.ts +3 -0
  104. package/src/plugins/runtime/context.tsx +1 -0
  105. package/src/plugins/runtime/index.tsx +4 -0
  106. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  107. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  108. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  109. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  110. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  111. package/src/sources/gloomberb-cloud/index.ts +4 -0
  112. package/src/sources/provider-router/cache.ts +1 -0
  113. package/src/sources/provider-router/document-routes.ts +45 -0
  114. package/src/sources/provider-router/index.ts +4 -0
  115. package/src/sources/sec-edgar/content.ts +9 -3
  116. package/src/sources/sec-edgar.test.ts +108 -1
  117. package/src/sources/sec-edgar.ts +105 -1
  118. package/src/sources/yahoo-finance.ts +4 -0
  119. package/src/test-support/plugin-runtime.ts +1 -0
  120. package/src/types/data-provider.ts +11 -0
  121. package/src/ui/host.tsx +1 -0
  122. package/src/version.ts +1 -1
@@ -22,14 +22,20 @@ export const DesktopChatMessage = memo(function DesktopChatMessage({
22
22
  onUserHover,
23
23
  onUserHoverEnd,
24
24
  beginReplyTo,
25
+ beginEditMessage,
25
26
  jumpToMessage,
27
+ latestEditableMessageId,
26
28
  registerMessageElement,
27
29
  }: ChatMessageBaseProps & {
28
30
  registerMessageElement: (messageId: string, node: unknown | null) => void;
29
31
  }) {
30
32
  const state = getChatMessageRenderState({ msg, index, messages, selectedIdx, hoveredIdx, canSend });
33
+ const canEditMessage = msg.id === latestEditableMessageId;
31
34
  const showInlineReplyAction = !state.grouped && canSend;
35
+ const showInlineEditAction = !state.grouped && canSend && canEditMessage;
32
36
  const showGroupedReplyAction = state.grouped && canSend;
37
+ const showGroupedEditAction = state.grouped && canSend && canEditMessage;
38
+ const groupedActionWidth = MESSAGE_ACTION_WIDTH * Number(showGroupedReplyAction) + MESSAGE_ACTION_WIDTH * Number(showGroupedEditAction);
33
39
  const rowProps = {
34
40
  width: "100%",
35
41
  paddingRight: DESKTOP_MESSAGE_RIGHT_PADDING,
@@ -98,21 +104,37 @@ export const DesktopChatMessage = memo(function DesktopChatMessage({
98
104
  {msg.user.username ?? "anon"}
99
105
  </Text>
100
106
  <Text fg={state.headerStatusColor}> {state.headerStatus}</Text>
101
- {showInlineReplyAction && (
107
+ {(showInlineReplyAction || showInlineEditAction) && (
102
108
  <>
103
109
  <Text fg={state.headerStatusColor}> </Text>
104
- <Box
105
- width={MESSAGE_ACTION_WIDTH}
106
- height={1}
107
- data-gloom-role="chat-message-reply-action"
108
- >
109
- <ChatActionChip
110
- label="Reply"
110
+ {showInlineReplyAction && (
111
+ <Box
112
+ width={MESSAGE_ACTION_WIDTH}
113
+ height={1}
114
+ data-gloom-role="chat-message-reply-action"
115
+ >
116
+ <ChatActionChip
117
+ label="Reply"
118
+ width={MESSAGE_ACTION_WIDTH}
119
+ emphasized={state.isSelected}
120
+ onPress={() => beginReplyTo(index)}
121
+ />
122
+ </Box>
123
+ )}
124
+ {showInlineEditAction && (
125
+ <Box
111
126
  width={MESSAGE_ACTION_WIDTH}
112
- emphasized={state.isSelected}
113
- onPress={() => beginReplyTo(index)}
114
- />
115
- </Box>
127
+ height={1}
128
+ data-gloom-role="chat-message-reply-action"
129
+ >
130
+ <ChatActionChip
131
+ label="Edit"
132
+ width={MESSAGE_ACTION_WIDTH}
133
+ emphasized={state.isSelected}
134
+ onPress={() => beginEditMessage(index)}
135
+ />
136
+ </Box>
137
+ )}
116
138
  </>
117
139
  )}
118
140
  </Box>
@@ -135,21 +157,32 @@ export const DesktopChatMessage = memo(function DesktopChatMessage({
135
157
  onUserHoverEnd={onUserHoverEnd}
136
158
  />
137
159
  </Box>
138
- {showGroupedReplyAction && (
160
+ {(showGroupedReplyAction || showGroupedEditAction) && (
139
161
  <Box
140
162
  position="absolute"
141
163
  top={0}
142
164
  right={0}
143
- width={MESSAGE_ACTION_WIDTH}
165
+ width={groupedActionWidth}
144
166
  height={1}
167
+ flexDirection="row"
145
168
  data-gloom-role="chat-message-reply-action"
146
169
  >
147
- <ChatActionChip
148
- label="Reply"
149
- width={MESSAGE_ACTION_WIDTH}
150
- emphasized={state.isSelected}
151
- onPress={() => beginReplyTo(index)}
152
- />
170
+ {showGroupedReplyAction && (
171
+ <ChatActionChip
172
+ label="Reply"
173
+ width={MESSAGE_ACTION_WIDTH}
174
+ emphasized={state.isSelected}
175
+ onPress={() => beginReplyTo(index)}
176
+ />
177
+ )}
178
+ {showGroupedEditAction && (
179
+ <ChatActionChip
180
+ label="Edit"
181
+ width={MESSAGE_ACTION_WIDTH}
182
+ emphasized={state.isSelected}
183
+ onPress={() => beginEditMessage(index)}
184
+ />
185
+ )}
153
186
  </Box>
154
187
  )}
155
188
  </Box>
@@ -6,10 +6,12 @@ import { TickerBadge } from "../../../../components/ticker/badge";
6
6
  import type { InlineTickerCatalogEntry } from "../../../../state/hooks/inline-tickers";
7
7
  import { blendHex, colors } from "../../../../theme/colors";
8
8
  import type { ChatUserSummary } from "../../../../api-client";
9
- import { tokenizeInlineContent } from "../../../../utils/inline-content-tokenizer";
9
+ import { tokenizeInlineContent, type InlineContentToken } from "../../../../utils/inline-content-tokenizer";
10
10
 
11
11
  export function ResponsiveTickerBadgeText({
12
- text,
12
+ text = "",
13
+ tokens: providedTokens,
14
+ prewrapped = false,
13
15
  catalog,
14
16
  textColor,
15
17
  openTicker,
@@ -17,7 +19,9 @@ export function ResponsiveTickerBadgeText({
17
19
  onUserHover,
18
20
  onUserHoverEnd,
19
21
  }: {
20
- text: string;
22
+ text?: string;
23
+ tokens?: readonly InlineContentToken[];
24
+ prewrapped?: boolean;
21
25
  catalog: Record<string, InlineTickerCatalogEntry>;
22
26
  textColor: string;
23
27
  openTicker: (symbol: string) => void;
@@ -26,19 +30,21 @@ export function ResponsiveTickerBadgeText({
26
30
  onUserHoverEnd?: () => void;
27
31
  }) {
28
32
  const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
29
- const tokens = useMemo(() => tokenizeInlineContent(text), [text]);
33
+ const tokens = useMemo(() => providedTokens ?? tokenizeInlineContent(text), [providedTokens, text]);
30
34
  const renderTextToken = (value: string, tokenIndex: number) => {
31
35
  if (!value) return null;
32
36
  return (
33
37
  <Text
34
38
  key={`text:${tokenIndex}`}
35
39
  fg={textColor}
36
- wrapText
37
- style={{
38
- minWidth: 0,
39
- whiteSpace: "pre-wrap",
40
- overflowWrap: "anywhere",
41
- }}
40
+ wrapText={!prewrapped}
41
+ style={prewrapped
42
+ ? undefined
43
+ : {
44
+ minWidth: 0,
45
+ whiteSpace: "pre-wrap",
46
+ overflowWrap: "anywhere",
47
+ }}
42
48
  >
43
49
  {value}
44
50
  </Text>
@@ -41,7 +41,11 @@ export function getChatMessageRenderState({
41
41
  const isSending = msg.clientStatus === "sending";
42
42
  const hasFailed = msg.clientStatus === "failed";
43
43
  const selectedTextColor = hasFailed ? colors.negative : colors.selectedText;
44
- const headerStatus = isSending ? "sending..." : hasFailed ? "failed" : formatTimeAgo(msg.createdAt);
44
+ const headerStatus = isSending
45
+ ? "sending..."
46
+ : hasFailed
47
+ ? "failed"
48
+ : `${formatTimeAgo(msg.createdAt)}${msg.editedAt ? " edited" : ""}`;
45
49
 
46
50
  return {
47
51
  isSelected,
@@ -1,5 +1,5 @@
1
1
  import { Box, Text } from "../../../../ui";
2
- import { MESSAGE_ACTION_WIDTH, formatInlinePreview, getMessageBodyLines } from "../layout";
2
+ import { MESSAGE_ACTION_WIDTH, formatInlinePreview, getMessageBodyTokenLines } from "../layout";
3
3
  import { ChatActionChip } from "./action-chip";
4
4
  import { ResponsiveTickerBadgeText } from "./inline-tokens";
5
5
  import { getChatMessageRenderState } from "./render-state";
@@ -26,13 +26,19 @@ export function TerminalChatMessage({
26
26
  onUserHover,
27
27
  onUserHoverEnd,
28
28
  beginReplyTo,
29
+ beginEditMessage,
29
30
  jumpToMessage,
31
+ latestEditableMessageId,
30
32
  setHoveredIdx,
31
33
  }: TerminalChatMessageProps) {
32
34
  const state = getChatMessageRenderState({ msg, index, messages, selectedIdx, hoveredIdx, canSend });
33
- const bodyLines = getMessageBodyLines(msg, contentWidth, catalog);
35
+ const bodyLines = getMessageBodyTokenLines(msg.content, messageBodyWidth, catalog);
36
+ const canEditMessage = msg.id === latestEditableMessageId;
34
37
  const showInlineReplyAction = !state.grouped && state.showReplyAction;
38
+ const showInlineEditAction = !state.grouped && state.showReplyAction && canEditMessage;
35
39
  const showGroupedReplyAction = state.grouped && state.showReplyAction;
40
+ const showGroupedEditAction = state.grouped && state.showReplyAction && canEditMessage;
41
+ const groupedActionWidth = MESSAGE_ACTION_WIDTH * Number(showGroupedReplyAction) + MESSAGE_ACTION_WIDTH * Number(showGroupedEditAction);
36
42
  const setHovered = () => setHoveredIdx((current) => (current === index ? current : index));
37
43
  const clearHovered = () => setHoveredIdx((current) => (current === index ? null : current));
38
44
  const messageRowProps = {
@@ -80,17 +86,29 @@ export function TerminalChatMessage({
80
86
  {msg.user.username ?? "anon"}
81
87
  </Text>
82
88
  <Text fg={state.headerStatusColor}> {state.headerStatus}</Text>
83
- {showInlineReplyAction && (
89
+ {(showInlineReplyAction || showInlineEditAction) && (
84
90
  <>
85
91
  <Text fg={state.headerStatusColor}> </Text>
86
- <Box width={MESSAGE_ACTION_WIDTH} height={1}>
87
- <ChatActionChip
88
- label="Reply"
89
- width={MESSAGE_ACTION_WIDTH}
90
- emphasized={state.isSelected}
91
- onPress={() => beginReplyTo(index)}
92
- />
93
- </Box>
92
+ {showInlineReplyAction && (
93
+ <Box width={MESSAGE_ACTION_WIDTH} height={1}>
94
+ <ChatActionChip
95
+ label="Reply"
96
+ width={MESSAGE_ACTION_WIDTH}
97
+ emphasized={state.isSelected}
98
+ onPress={() => beginReplyTo(index)}
99
+ />
100
+ </Box>
101
+ )}
102
+ {showInlineEditAction && (
103
+ <Box width={MESSAGE_ACTION_WIDTH} height={1}>
104
+ <ChatActionChip
105
+ label="Edit"
106
+ width={MESSAGE_ACTION_WIDTH}
107
+ emphasized={state.isSelected}
108
+ onPress={() => beginEditMessage(index)}
109
+ />
110
+ </Box>
111
+ )}
94
112
  </>
95
113
  )}
96
114
  </Box>
@@ -106,7 +124,8 @@ export function TerminalChatMessage({
106
124
  >
107
125
  <Box width={messageBodyWidth} height={1}>
108
126
  <ResponsiveTickerBadgeText
109
- text={line}
127
+ tokens={line}
128
+ prewrapped
110
129
  catalog={catalog}
111
130
  textColor={state.bodyColor}
112
131
  openTicker={openTicker}
@@ -115,14 +134,24 @@ export function TerminalChatMessage({
115
134
  onUserHoverEnd={onUserHoverEnd}
116
135
  />
117
136
  </Box>
118
- {lineIndex === 0 && showGroupedReplyAction && (
119
- <Box position="absolute" top={0} right={0} width={MESSAGE_ACTION_WIDTH} height={1}>
120
- <ChatActionChip
121
- label="Reply"
122
- width={MESSAGE_ACTION_WIDTH}
123
- emphasized={state.isSelected}
124
- onPress={() => beginReplyTo(index)}
125
- />
137
+ {lineIndex === 0 && (showGroupedReplyAction || showGroupedEditAction) && (
138
+ <Box position="absolute" top={0} right={0} width={groupedActionWidth} height={1} flexDirection="row">
139
+ {showGroupedReplyAction && (
140
+ <ChatActionChip
141
+ label="Reply"
142
+ width={MESSAGE_ACTION_WIDTH}
143
+ emphasized={state.isSelected}
144
+ onPress={() => beginReplyTo(index)}
145
+ />
146
+ )}
147
+ {showGroupedEditAction && (
148
+ <ChatActionChip
149
+ label="Edit"
150
+ width={MESSAGE_ACTION_WIDTH}
151
+ emphasized={state.isSelected}
152
+ onPress={() => beginEditMessage(index)}
153
+ />
154
+ )}
126
155
  </Box>
127
156
  )}
128
157
  </Box>
@@ -14,5 +14,7 @@ export interface ChatMessageBaseProps {
14
14
  onUserHover: (user: ChatUserSummary) => void;
15
15
  onUserHoverEnd: () => void;
16
16
  beginReplyTo: (index: number, options?: { deferFocus?: boolean }) => void;
17
+ beginEditMessage: (index: number, options?: { deferFocus?: boolean }) => boolean;
17
18
  jumpToMessage: (messageId: string) => void;
19
+ latestEditableMessageId: string | null;
18
20
  }
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2
- import { setPaneSetting } from "../../../pane-settings";
2
+ import { updatePaneInstance } from "../../../pane-settings";
3
3
  import {
4
4
  syncConfigActiveLayoutState,
5
5
  useAppDispatch,
@@ -12,6 +12,7 @@ import { scheduleConfigSave } from "../../../state/config-save-scheduler";
12
12
  import type { PaneProps } from "../../../types/plugin";
13
13
  import {
14
14
  DEFAULT_CHAT_CHANNEL_ID,
15
+ formatChatPaneTitle,
15
16
  LAST_VISITED_CHAT_CHANNEL_KEY,
16
17
  normalizeChannelId,
17
18
  } from "./channels";
@@ -22,6 +23,7 @@ interface ChatPaneContentProps {
22
23
  focused: boolean;
23
24
  channelId?: string;
24
25
  onChannelChange?: (channelId: string) => void;
26
+ onChannelTitleChange?: (title: string) => void;
25
27
  }
26
28
 
27
29
  export function createChatPane(ChatContent: (props: ChatPaneContentProps) => ReactNode) {
@@ -37,9 +39,30 @@ export function createChatPane(ChatContent: (props: ChatPaneContentProps) => Rea
37
39
  ));
38
40
  const initialChannelIdRef = useRef(normalizeChannelId(rawPaneChannelId ?? lastVisitedChannelId));
39
41
  const persistedChannelId = normalizeChannelId(rawPaneChannelId ?? initialChannelIdRef.current);
40
- const persistChannelId = useCallback((nextChannelId: string) => {
42
+ const persistChatPaneState = useCallback((nextChannelId: string, nextTitle?: string) => {
41
43
  const currentState = stateRef.current;
42
- const layout = setPaneSetting(currentState.config.layout, paneId, "channelId", nextChannelId);
44
+ const currentPane = currentState.config.layout.instances.find((instance) => instance.instanceId === paneId);
45
+ const nextTitleValue = nextTitle?.trim() || undefined;
46
+ const currentPaneChannelId = typeof currentPane?.settings?.channelId === "string"
47
+ ? normalizeChannelId(currentPane.settings.channelId)
48
+ : null;
49
+ const currentLastVisitedChannelId = currentState.config.pluginConfig["gloomberb-cloud"]?.[LAST_VISITED_CHAT_CHANNEL_KEY];
50
+ if (
51
+ currentPaneChannelId === nextChannelId
52
+ && (!nextTitleValue || currentPane?.title === nextTitleValue)
53
+ && currentLastVisitedChannelId === nextChannelId
54
+ ) {
55
+ return;
56
+ }
57
+
58
+ const layout = updatePaneInstance(currentState.config.layout, paneId, (instance) => ({
59
+ ...instance,
60
+ title: nextTitleValue ?? instance.title,
61
+ settings: {
62
+ ...(instance.settings ?? {}),
63
+ channelId: nextChannelId,
64
+ },
65
+ }));
43
66
  const pluginConfig = {
44
67
  ...currentState.config.pluginConfig,
45
68
  "gloomberb-cloud": {
@@ -66,8 +89,8 @@ export function createChatPane(ChatContent: (props: ChatPaneContentProps) => Rea
66
89
 
67
90
  useEffect(() => {
68
91
  if (rawPaneChannelId) return;
69
- persistChannelId(initialChannelIdRef.current);
70
- }, [persistChannelId, rawPaneChannelId]);
92
+ persistChatPaneState(initialChannelIdRef.current, formatChatPaneTitle(undefined, initialChannelIdRef.current));
93
+ }, [persistChatPaneState, rawPaneChannelId]);
71
94
 
72
95
  useEffect(() => {
73
96
  const normalizedPersisted = normalizeChannelId(persistedChannelId);
@@ -84,8 +107,12 @@ export function createChatPane(ChatContent: (props: ChatPaneContentProps) => Rea
84
107
  const normalized = normalizeChannelId(nextChannelId);
85
108
  pendingChannelIdRef.current = normalized;
86
109
  setLocalChannelId((current) => (current === normalized ? current : normalized));
87
- persistChannelId(normalized);
88
- }, [persistChannelId]);
110
+ persistChatPaneState(normalized, formatChatPaneTitle(undefined, normalized));
111
+ }, [persistChatPaneState]);
112
+
113
+ const setChannelTitle = useCallback((nextTitle: string) => {
114
+ persistChatPaneState(channelId, nextTitle);
115
+ }, [channelId, persistChatPaneState]);
89
116
 
90
117
  return (
91
118
  <ChatContent
@@ -94,6 +121,7 @@ export function createChatPane(ChatContent: (props: ChatPaneContentProps) => Rea
94
121
  focused={focused}
95
122
  channelId={channelId}
96
123
  onChannelChange={setChannelId}
124
+ onChannelTitleChange={setChannelTitle}
97
125
  />
98
126
  );
99
127
  };
@@ -442,6 +442,7 @@ describe("ChatContent channel sidebar", () => {
442
442
  await flushFrame();
443
443
 
444
444
  expect(findPaneInstance(latestState.config.layout, paneInstanceId)?.settings?.channelId).toBe("options");
445
+ expect(findPaneInstance(latestState.config.layout, paneInstanceId)?.title).toBe("#options");
445
446
  expect(latestState.config.pluginConfig["gloomberb-cloud"]?.lastChatChannelId).toBe("options");
446
447
  expect(setup().captureCharFrame()).toContain("#options");
447
448
  } finally {
@@ -4,12 +4,22 @@ import { colors, hoverBg } from "../../../theme/colors";
4
4
  import { Box, Span, Text, TextAttributes, useUiCapabilities } from "../../../ui";
5
5
  import { usePluginAppActions } from "../../runtime";
6
6
  import { InlineAuthActions } from "../cloud/auth-actions";
7
+ import {
8
+ findChatPaneInstanceForChannel,
9
+ getPreferredChatOpenChannelId,
10
+ } from "./channels";
7
11
  import { chatController, type ChatController } from "./controller";
8
12
 
9
13
  interface ChatStatusWidgetProps {
10
14
  controller?: Pick<ChatController, "getSnapshot" | "refreshSession" | "subscribe">;
11
15
  }
12
16
 
17
+ type ChatStatusSnapshot = ReturnType<ChatController["getSnapshot"]>;
18
+
19
+ function getTotalUnreadCount(snapshot: ChatStatusSnapshot) {
20
+ return snapshot.channelStates.reduce((total, state) => total + Math.max(0, state.unreadCount), 0);
21
+ }
22
+
13
23
  function CloudStatusIcon() {
14
24
  const { nativePaneChrome } = useUiCapabilities();
15
25
  if (!nativePaneChrome) {
@@ -43,25 +53,33 @@ function CloudStatusIcon() {
43
53
  }
44
54
 
45
55
  export function ChatStatusWidget({ controller = chatController }: ChatStatusWidgetProps) {
46
- const { showPane } = usePluginAppActions();
47
- const cloudPluginDisabled = useAppSelector((state) => state.config.disabledPlugins.includes("gloomberb-cloud"));
56
+ const { createPaneFromTemplate, focusPane } = usePluginAppActions();
57
+ const config = useAppSelector((state) => state.config);
58
+ const cloudPluginDisabled = config.disabledPlugins.includes("gloomberb-cloud");
48
59
  const initialSnapshot = controller.getSnapshot();
49
60
  const [username, setUsername] = useState<string | null>(initialSnapshot.user?.username ?? null);
50
61
  const [hasSavedSession, setHasSavedSession] = useState(initialSnapshot.hasSavedSession);
51
- const [unreadMentionCount, setUnreadMentionCount] = useState(initialSnapshot.unreadMentionCount);
62
+ const [snapshot, setSnapshot] = useState(initialSnapshot);
63
+ const unreadCount = getTotalUnreadCount(snapshot);
52
64
  const [hovered, setHovered] = useState(false);
53
65
 
54
66
  const openChat = (event?: { preventDefault?: () => void; stopPropagation?: () => void }) => {
55
67
  event?.preventDefault?.();
56
68
  event?.stopPropagation?.();
57
- showPane("chat");
69
+ const channelId = getPreferredChatOpenChannelId(config, snapshot);
70
+ const existingInstance = findChatPaneInstanceForChannel(config, channelId);
71
+ if (existingInstance) {
72
+ focusPane(existingInstance.instanceId);
73
+ return;
74
+ }
75
+ createPaneFromTemplate("new-chat-pane", { arg: channelId });
58
76
  };
59
77
 
60
78
  useEffect(() => {
61
- const unsubscribe = controller.subscribe((snapshot) => {
62
- setUsername(snapshot.user?.username ?? null);
63
- setHasSavedSession(snapshot.hasSavedSession);
64
- setUnreadMentionCount(snapshot.unreadMentionCount);
79
+ const unsubscribe = controller.subscribe((nextSnapshot) => {
80
+ setSnapshot(nextSnapshot);
81
+ setUsername(nextSnapshot.user?.username ?? null);
82
+ setHasSavedSession(nextSnapshot.hasSavedSession);
65
83
  });
66
84
  void controller.refreshSession().catch(() => {});
67
85
  return unsubscribe;
@@ -84,7 +102,7 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
84
102
  onMouseOut={() => setHovered((current) => (current ? false : current))}
85
103
  onMouseDown={openChat}
86
104
  >
87
- <Text fg={unreadMentionCount > 0 ? colors.text : colors.textDim}>
105
+ <Text fg={unreadCount > 0 ? colors.text : colors.textDim}>
88
106
  <Span fg={colors.positive}>@</Span>
89
107
  {username ? (
90
108
  <>
@@ -93,8 +111,8 @@ export function ChatStatusWidget({ controller = chatController }: ChatStatusWidg
93
111
  </>
94
112
  ) : null}
95
113
  </Text>
96
- {unreadMentionCount > 0 ? (
97
- <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{` [${unreadMentionCount}]`}</Text>
114
+ {unreadCount > 0 ? (
115
+ <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{` [${unreadCount}]`}</Text>
98
116
  ) : null}
99
117
  </Box>
100
118
  )}
@@ -20,6 +20,7 @@ const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
20
20
  const originalGetChannels = apiClient.getChannels.bind(apiClient);
21
21
  const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient);
22
22
  const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient);
23
+ const originalEditMessage = apiClient.editMessage.bind(apiClient);
23
24
 
24
25
  export type ChatTestSetup = Awaited<ReturnType<typeof testRender>>;
25
26
 
@@ -50,6 +51,7 @@ export async function cleanupChatTest(testSetup: ChatTestSetup | undefined): Pro
50
51
  apiClient.getChannels = originalGetChannels;
51
52
  apiClient.getChatPresence = originalGetChatPresence;
52
53
  apiClient.updateChatChannelState = originalUpdateChatChannelState;
54
+ apiClient.editMessage = originalEditMessage;
53
55
  apiClient.setSessionToken(null);
54
56
 
55
57
  if (testSetup) {
@@ -13,6 +13,7 @@ import {
13
13
  import { registerTwitterFeedFeature } from "../cloud-tweets";
14
14
  import {
15
15
  buildDmCommandResults,
16
+ formatChatPaneTitle,
16
17
  getLastVisitedChatChannelId,
17
18
  hasOnlyDmUsernameArgs,
18
19
  normalizeShortcutChannelId,
@@ -50,8 +51,10 @@ export function createGloomberbCloudPlugin({
50
51
  const channelId = options?.arg
51
52
  ? await chatController.resolveRequiredChannelId(normalizeShortcutChannelId(options.arg))
52
53
  : await chatController.resolvePreferredChannelId(getLastVisitedChatChannelId(context.config));
54
+ const channel = chatController.getChannels().find((entry) => entry.id === channelId);
53
55
  return {
54
56
  placement: "floating",
57
+ title: formatChatPaneTitle(channel, channelId),
55
58
  settings: { channelId },
56
59
  };
57
60
  },
@@ -297,11 +297,13 @@ export function TweetSearchTable({
297
297
  onBack={() => setDetailOpen(false)}
298
298
  detailTitle={selectedTweet ? `@${selectedTweet.author.userName || selectedTweet.author.name} - ${formatTimeAgo(selectedTweet.createdAt)}` : "Tweet"}
299
299
  detailContent={selectedTweet ? <TweetDetail tweet={selectedTweet} width={width} onOpenUsername={openUsernameFeed} /> : null}
300
- selectedIndex={activeIndex}
301
- onSelectIndex={(index) => setSelectedTweetId(rows[index]?.id ?? null)}
302
- onActivateIndex={(index) => {
303
- const tweet = rows[index];
304
- if (!tweet) return;
300
+ selection={{
301
+ kind: "id",
302
+ selectedId: selectedTweetId,
303
+ getId: (tweet) => tweet.id,
304
+ onChange: (id) => setSelectedTweetId(id),
305
+ }}
306
+ onActivate={(tweet) => {
305
307
  setSelectedTweetId(tweet.id);
306
308
  setDetailOpen(true);
307
309
  }}
@@ -316,12 +318,6 @@ export function TweetSearchTable({
316
318
  sortDirection={sort.direction}
317
319
  onHeaderClick={handleHeaderClick}
318
320
  getItemKey={(tweet) => tweet.id}
319
- isSelected={(tweet) => tweet.id === selectedTweetId}
320
- onSelect={(tweet) => setSelectedTweetId(tweet.id)}
321
- onActivate={(tweet) => {
322
- setSelectedTweetId(tweet.id);
323
- setDetailOpen(true);
324
- }}
325
321
  renderCell={renderCell}
326
322
  emptyContent={emptyContent}
327
323
  emptyStateTitle={loading ? "Loading tweets..." : error ?? emptyStateTitle ?? "No tweets"}
@@ -262,11 +262,13 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
262
262
  onBack={() => setDetailMode(null)}
263
263
  detailTitle={detailTitle}
264
264
  detailContent={detailContent}
265
- selectedIndex={selectedTradeIndex}
266
- onSelectIndex={(index) => setSelectedTradeId(tradeRows[index]?.id ?? null)}
267
- onActivateIndex={(index) => {
268
- const trade = tradeRows[index];
269
- if (!trade) return;
265
+ selection={{
266
+ kind: "id",
267
+ selectedId: selectedTradeId,
268
+ getId: (trade) => trade.id,
269
+ onChange: (id) => setSelectedTradeId(id),
270
+ }}
271
+ onActivate={(trade) => {
270
272
  setSelectedTradeId(trade.id, { immediate: true });
271
273
  setDetailMode({ kind: "trade", tradeId: trade.id });
272
274
  }}
@@ -280,12 +282,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
280
282
  sortDirection={tradeSort.direction}
281
283
  onHeaderClick={(columnId) => setTradeSort((current) => nextSort(current, columnId as TradeColumnId, columnId === "member" || columnId === "ticker" ? "asc" : "desc"))}
282
284
  getItemKey={(trade) => trade.id}
283
- isSelected={(trade) => trade.id === selectedTradeId}
284
- onSelect={(trade) => setSelectedTradeId(trade.id)}
285
- onActivate={(trade) => {
286
- setSelectedTradeId(trade.id, { immediate: true });
287
- setDetailMode({ kind: "trade", tradeId: trade.id });
288
- }}
289
285
  renderCell={renderCongressTradeCell}
290
286
  emptyStateTitle="No House PTR trades."
291
287
  emptyStateHint="Press r to refresh."
@@ -297,11 +293,13 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
297
293
  onBack={() => setDetailMode(null)}
298
294
  detailTitle={detailTitle}
299
295
  detailContent={detailContent}
300
- selectedIndex={selectedMemberIndex}
301
- onSelectIndex={(index) => setSelectedMemberId(memberRows[index]?.id ?? null)}
302
- onActivateIndex={(index) => {
303
- const member = memberRows[index];
304
- if (!member) return;
296
+ selection={{
297
+ kind: "id",
298
+ selectedId: selectedMemberId,
299
+ getId: (member) => member.id,
300
+ onChange: (id) => setSelectedMemberId(id),
301
+ }}
302
+ onActivate={(member) => {
305
303
  setSelectedMemberId(member.id, { immediate: true });
306
304
  setDetailMode({ kind: "member", memberId: member.id });
307
305
  }}
@@ -315,12 +313,6 @@ export function CongressTradesPane({ focused, width, height }: PaneProps) {
315
313
  sortDirection={memberSort.direction}
316
314
  onHeaderClick={(columnId) => setMemberSort((current) => nextSort(current, columnId as MemberColumnId, columnId === "member" || columnId === "district" ? "asc" : "desc"))}
317
315
  getItemKey={(member) => member.id}
318
- isSelected={(member) => member.id === selectedMemberId}
319
- onSelect={(member) => setSelectedMemberId(member.id)}
320
- onActivate={(member) => {
321
- setSelectedMemberId(member.id, { immediate: true });
322
- setDetailMode({ kind: "member", memberId: member.id });
323
- }}
324
316
  renderCell={renderCongressMemberCell}
325
317
  emptyStateTitle="No House PTR members."
326
318
  emptyStateHint="Press r to refresh."
@@ -132,12 +132,15 @@ function EarningsCalendarPane({ focused, width, height }: PaneProps) {
132
132
  return (
133
133
  <DataTableView<EarningsDisplayRow, EarningsColumn>
134
134
  focused={focused}
135
- selectedIndex={selectedRowIndex}
136
- isNavigable={(row) => row.kind === "event"}
137
- onSelectIndex={(_index, row) => {
138
- if (row.kind === "event") setSelectedIdx(row.eventIdx);
135
+ selection={{
136
+ kind: "index",
137
+ selectedIndex: selectedRowIndex,
138
+ onChange: (_index, row) => {
139
+ if (row.kind === "event") setSelectedIdx(row.eventIdx);
140
+ },
139
141
  }}
140
- onActivateIndex={(_index, row) => {
142
+ isNavigable={(row) => row.kind === "event"}
143
+ onActivate={(row) => {
141
144
  if (row.kind === "event") openEvent(row.event);
142
145
  }}
143
146
  onRootKeyDown={handleTableKeyDown}
@@ -149,8 +152,6 @@ function EarningsCalendarPane({ focused, width, height }: PaneProps) {
149
152
  sortDirection="asc"
150
153
  onHeaderClick={() => {}}
151
154
  getItemKey={(row) => row.key}
152
- isSelected={(row) => row.kind === "event" && row.eventIdx === activeEventIdx}
153
- onSelect={selectDisplayRow}
154
155
  renderSectionHeader={renderEarningsSectionHeader}
155
156
  renderCell={renderCell}
156
157
  emptyStateTitle={loading ? "Loading earnings..." : "No upcoming earnings found"}