gloomberb 0.9.6 → 0.9.8

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 (130) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.ts +48 -0
  4. package/src/api-client/types.ts +45 -0
  5. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  6. package/src/app/runtime/desktop-deeplink.ts +496 -0
  7. package/src/app.tsx +34 -0
  8. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  9. package/src/components/chart/bar-chart-renderer.ts +111 -55
  10. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  11. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  12. package/src/components/chart/stock-chart/footer.ts +68 -13
  13. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  14. package/src/components/command-bar/pane-templates/items.ts +11 -3
  15. package/src/components/command-bar/routes/root/results.ts +1 -1
  16. package/src/components/command-bar/surface/index.test.tsx +52 -0
  17. package/src/components/command-bar/workflow/ops.ts +81 -20
  18. package/src/components/index.ts +1 -0
  19. package/src/components/toggle-list.tsx +29 -73
  20. package/src/components/ui/button.tsx +4 -1
  21. package/src/components/ui/checkbox.tsx +76 -0
  22. package/src/components/ui/choice-dialog.tsx +15 -3
  23. package/src/components/ui/frame.tsx +1 -1
  24. package/src/components/ui/index.ts +2 -0
  25. package/src/components/ui/message-composer.tsx +4 -0
  26. package/src/components/ui/native-select.tsx +4 -2
  27. package/src/market-data/coordinator/events.test.ts +49 -0
  28. package/src/market-data/coordinator/events.ts +25 -4
  29. package/src/market-data/coordinator/index.test.ts +223 -0
  30. package/src/market-data/coordinator/index.ts +40 -3
  31. package/src/market-data/coordinator/quotes.ts +13 -5
  32. package/src/market-data/hooks.tsx +17 -13
  33. package/src/market-data/market/freshness.ts +58 -1
  34. package/src/market-data/quotes/freshness.test.ts +49 -1
  35. package/src/market-data/quotes/freshness.ts +8 -0
  36. package/src/market-data/quotes/resolution.test.ts +185 -0
  37. package/src/market-data/quotes/resolution.ts +94 -8
  38. package/src/market-data/request-types.test.ts +61 -1
  39. package/src/market-data/request-types.ts +42 -3
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/account-management/footer.ts +2 -5
  42. package/src/plugins/builtin/account-management/form-components.tsx +260 -24
  43. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  44. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  45. package/src/plugins/builtin/account-management/model.ts +263 -3
  46. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  47. package/src/plugins/builtin/chat/chat.test.tsx +125 -0
  48. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  49. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  50. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  51. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  52. package/src/plugins/builtin/chat/content/index.tsx +154 -16
  53. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  54. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  55. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  56. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  58. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  59. package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
  60. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  61. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  62. package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
  63. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  64. package/src/plugins/builtin/chat/test-harness.tsx +21 -11
  65. package/src/plugins/builtin/cloud/plugin.tsx +7 -0
  66. package/src/plugins/builtin/correlation/index.tsx +4 -1
  67. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  68. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  69. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  70. package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
  71. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  72. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  73. package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
  74. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
  75. package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
  76. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
  77. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  78. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
  79. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  80. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  81. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  83. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  84. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  85. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  86. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  87. package/src/plugins/registry/context.ts +15 -0
  88. package/src/plugins/registry/index.ts +31 -0
  89. package/src/renderers/electrobun/bun/index.ts +42 -0
  90. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  91. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  92. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  93. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  94. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  95. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  96. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  97. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  98. package/src/renderers/electrobun/view/main.tsx +3 -0
  99. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  100. package/src/sources/provider-router/batches.ts +11 -3
  101. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  102. package/src/sources/provider-router/financial-routes.ts +55 -5
  103. package/src/sources/provider-router/financials.test.ts +49 -0
  104. package/src/sources/provider-router/financials.ts +40 -3
  105. package/src/sources/provider-router/index.test.ts +147 -0
  106. package/src/sources/provider-router/primary.ts +17 -3
  107. package/src/sources/provider-router/streaming.ts +6 -1
  108. package/src/sources/sec-edgar.test.ts +42 -0
  109. package/src/sources/sec-edgar.ts +1 -2
  110. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  111. package/src/sources/yahoo-finance/mappers.ts +2 -1
  112. package/src/sources/yahoo-finance/requests.ts +2 -1
  113. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  114. package/src/sources/yahoo-finance.ts +6 -2
  115. package/src/state/hooks/quote-streaming.test.ts +14 -1
  116. package/src/state/hooks/quote-streaming.ts +5 -4
  117. package/src/sync/controller.ts +296 -0
  118. package/src/sync/core-contributors.test.ts +251 -0
  119. package/src/sync/core-contributors.ts +400 -0
  120. package/src/sync/profile-analytics.ts +39 -0
  121. package/src/sync/react.ts +53 -0
  122. package/src/sync/types.ts +77 -0
  123. package/src/types/config.ts +1 -0
  124. package/src/types/desktop-deeplink.ts +7 -0
  125. package/src/types/plugin.ts +3 -0
  126. package/src/ui/host.tsx +14 -1
  127. package/src/ui/index.tsx +1 -0
  128. package/src/utils/exchanges.test.ts +11 -1
  129. package/src/utils/exchanges.ts +5 -1
  130. package/src/version.ts +1 -1
@@ -0,0 +1,264 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { ListView, TextField, type ListViewItem } from "../../../../components/ui";
3
+ import { useShortcut } from "../../../../react/input";
4
+ import { colors, hoverBg } from "../../../../theme/colors";
5
+ import { Box, Text, TextAttributes, type InputRenderable } from "../../../../ui";
6
+ import type { ChatUserSummary } from "../../../../api-client";
7
+ import { isPlainKey } from "../../../../utils/keyboard";
8
+ import {
9
+ hasOnlyDmUsernameArgs,
10
+ parseDmUsernames,
11
+ truncateChannelLabel,
12
+ } from "../channels";
13
+
14
+ const MAX_RECENT_USERS = 6;
15
+ const MIN_DIALOG_WIDTH = 32;
16
+ const MAX_DIALOG_WIDTH = 52;
17
+ const DIALOG_HEIGHT = 10;
18
+
19
+ interface DmUserCandidate {
20
+ username: string;
21
+ displayName: string;
22
+ }
23
+
24
+ function normalizeUsername(value: string | null | undefined): string {
25
+ return value?.trim().replace(/^@+/, "").toLowerCase() ?? "";
26
+ }
27
+
28
+ function currentTokenQuery(value: string): string {
29
+ const token = value.split(/[\s,]+/).at(-1) ?? "";
30
+ return normalizeUsername(token);
31
+ }
32
+
33
+ function candidateUsers(
34
+ userByUsername: Map<string, ChatUserSummary>,
35
+ currentUserId: string | null | undefined,
36
+ ): DmUserCandidate[] {
37
+ const candidates = new Map<string, DmUserCandidate>();
38
+ for (const [key, user] of userByUsername) {
39
+ if (currentUserId && user.id === currentUserId) continue;
40
+ const username = normalizeUsername(user.username ?? key);
41
+ if (!username) continue;
42
+ candidates.set(username, {
43
+ username,
44
+ displayName: user.displayName?.trim() || `@${username}`,
45
+ });
46
+ }
47
+ return [...candidates.values()].sort((left, right) => left.username.localeCompare(right.username));
48
+ }
49
+
50
+ function setUsernameSelected(value: string, username: string, selected: boolean): string {
51
+ const usernames = parseDmUsernames(value).filter((existing) => existing !== username);
52
+ if (selected) usernames.push(username);
53
+ return usernames.map((entry) => `@${entry}`).join(" ") + (usernames.length > 0 ? " " : "");
54
+ }
55
+
56
+ function usernamesLabel(usernames: string[], width: number): string {
57
+ return truncateChannelLabel(usernames.map((username) => `@${username}`).join(" "), width);
58
+ }
59
+
60
+ export function NewDmDialog({
61
+ width,
62
+ height,
63
+ userByUsername,
64
+ currentUserId,
65
+ onCancel,
66
+ onSubmit,
67
+ }: {
68
+ width: number;
69
+ height: number;
70
+ userByUsername: Map<string, ChatUserSummary>;
71
+ currentUserId?: string | null;
72
+ onCancel: () => void;
73
+ onSubmit: (usernames: string[]) => Promise<void>;
74
+ }) {
75
+ const inputRef = useRef<InputRenderable | null>(null);
76
+ const [value, setValue] = useState("");
77
+ const valueRef = useRef("");
78
+ const [selectedIndex, setSelectedIndex] = useState(0);
79
+ const [submitting, setSubmitting] = useState(false);
80
+ const [error, setError] = useState<string | null>(null);
81
+ const dialogWidth = Math.max(MIN_DIALOG_WIDTH, Math.min(MAX_DIALOG_WIDTH, width - 4));
82
+ const dialogHeight = Math.min(DIALOG_HEIGHT, Math.max(7, height - 2));
83
+ const left = Math.max(0, Math.floor((width - dialogWidth) / 2));
84
+ const top = Math.max(0, Math.floor((height - dialogHeight) / 2));
85
+ const contentWidth = Math.max(1, dialogWidth - 4);
86
+ const selectedUsernames = useMemo(() => parseDmUsernames(value), [value]);
87
+ const selectedUsernameSet = useMemo(() => new Set(selectedUsernames), [selectedUsernames]);
88
+ const allCandidates = useMemo(() => candidateUsers(userByUsername, currentUserId), [currentUserId, userByUsername]);
89
+ const query = currentTokenQuery(value);
90
+ const visibleCandidates = useMemo(() => {
91
+ const filtered = query
92
+ ? allCandidates.filter((candidate) => candidate.username.includes(query))
93
+ : allCandidates;
94
+ return filtered.slice(0, MAX_RECENT_USERS);
95
+ }, [allCandidates, query]);
96
+ const items = useMemo<ListViewItem[]>(() => visibleCandidates.map((candidate) => ({
97
+ id: candidate.username,
98
+ label: `@${candidate.username}`,
99
+ detail: candidate.displayName,
100
+ checked: selectedUsernameSet.has(candidate.username),
101
+ })), [selectedUsernameSet, visibleCandidates]);
102
+ const canSubmit = hasOnlyDmUsernameArgs(value) && selectedUsernames.length > 0 && !submitting;
103
+
104
+ useEffect(() => {
105
+ inputRef.current?.focus?.();
106
+ }, []);
107
+
108
+ useEffect(() => {
109
+ setSelectedIndex((current) => Math.max(0, Math.min(current, Math.max(items.length - 1, 0))));
110
+ }, [items.length]);
111
+
112
+ const updateValue = (nextValue: string) => {
113
+ valueRef.current = nextValue;
114
+ setValue(nextValue);
115
+ };
116
+
117
+ const toggleCandidate = (username: string) => {
118
+ updateValue(setUsernameSelected(valueRef.current, username, !parseDmUsernames(valueRef.current).includes(username)));
119
+ setError(null);
120
+ queueMicrotask(() => inputRef.current?.focus?.());
121
+ };
122
+
123
+ const submit = async () => {
124
+ const submittedValue = valueRef.current;
125
+ const submittedUsernames = parseDmUsernames(submittedValue);
126
+ if (!hasOnlyDmUsernameArgs(submittedValue) || submittedUsernames.length === 0 || submitting) {
127
+ setError("Enter at least one @username.");
128
+ return;
129
+ }
130
+ setSubmitting(true);
131
+ setError(null);
132
+ try {
133
+ await onSubmit(submittedUsernames);
134
+ } catch {
135
+ setError("Could not start conversation.");
136
+ setSubmitting(false);
137
+ }
138
+ };
139
+
140
+ useShortcut((event) => {
141
+ if (event.name === "escape") {
142
+ event.preventDefault?.();
143
+ event.stopPropagation?.();
144
+ onCancel();
145
+ return;
146
+ }
147
+ if (isPlainKey(event, "up", "down")) {
148
+ event.preventDefault?.();
149
+ event.stopPropagation?.();
150
+ setSelectedIndex((current) => {
151
+ if (items.length === 0) return 0;
152
+ return event.name === "up"
153
+ ? Math.max(0, current - 1)
154
+ : Math.min(items.length - 1, current + 1);
155
+ });
156
+ return;
157
+ }
158
+ if (event.name === "tab" && items[selectedIndex]) {
159
+ event.preventDefault?.();
160
+ event.stopPropagation?.();
161
+ toggleCandidate(items[selectedIndex]!.id);
162
+ return;
163
+ }
164
+ if (event.name === "enter" || event.name === "return") {
165
+ event.preventDefault?.();
166
+ event.stopPropagation?.();
167
+ void submit();
168
+ }
169
+ }, { allowEditable: true, phase: "before" });
170
+
171
+ return (
172
+ <Box
173
+ position="absolute"
174
+ left={left}
175
+ top={top}
176
+ width={dialogWidth}
177
+ height={dialogHeight}
178
+ flexDirection="column"
179
+ border
180
+ borderColor={colors.borderFocused}
181
+ backgroundColor={colors.bg}
182
+ paddingX={1}
183
+ onMouseDown={(event: any) => {
184
+ event?.preventDefault?.();
185
+ event?.stopPropagation?.();
186
+ }}
187
+ style={{ zIndex: 8 }}
188
+ >
189
+ <Box height={1} flexDirection="row">
190
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>New DM</Text>
191
+ <Box flexGrow={1} />
192
+ <Box width={3} height={1} backgroundColor={colors.panel} onMouseDown={onCancel} style={{ cursor: "pointer" }}>
193
+ <Text fg={colors.text}> x </Text>
194
+ </Box>
195
+ </Box>
196
+ <TextField
197
+ inputRef={inputRef}
198
+ value={value}
199
+ placeholder="@username, @second"
200
+ focused
201
+ width={contentWidth}
202
+ backgroundColor={colors.panel}
203
+ onChange={(nextValue) => {
204
+ updateValue(nextValue);
205
+ setError(null);
206
+ }}
207
+ onSubmit={() => { void submit(); }}
208
+ />
209
+ <Box height={1}>
210
+ <Text fg={selectedUsernames.length > 0 ? colors.textMuted : colors.textDim}>
211
+ {selectedUsernames.length > 0 ? usernamesLabel(selectedUsernames, contentWidth) : "Recent users"}
212
+ </Text>
213
+ </Box>
214
+ <ListView
215
+ items={items}
216
+ selectedIndex={items.length > 0 ? selectedIndex : -1}
217
+ height={Math.max(1, dialogHeight - 6)}
218
+ bgColor={colors.bg}
219
+ selectedBgColor={colors.selected}
220
+ hoverBgColor={hoverBg()}
221
+ emptyMessage="No recent users"
222
+ selectOnHover
223
+ onSelect={setSelectedIndex}
224
+ onActivate={(item) => toggleCandidate(item.id)}
225
+ renderRow={(item, state) => (
226
+ <Box flexDirection="row" width={contentWidth}>
227
+ <Text fg={state.selected ? colors.selectedText : colors.textDim}>
228
+ {item.checked ? "x " : "+ "}
229
+ </Text>
230
+ <Text
231
+ fg={state.selected ? colors.text : colors.textMuted}
232
+ attributes={state.selected ? TextAttributes.BOLD : 0}
233
+ >
234
+ {truncateChannelLabel(item.label, Math.max(1, contentWidth - 12))}
235
+ </Text>
236
+ <Box flexGrow={1} />
237
+ {item.detail ? (
238
+ <Text fg={colors.textDim}>{truncateChannelLabel(item.detail, 10)}</Text>
239
+ ) : null}
240
+ </Box>
241
+ )}
242
+ />
243
+ <Box height={1} flexDirection="row">
244
+ {error ? (
245
+ <Text fg={colors.negative}>{truncateChannelLabel(error, contentWidth)}</Text>
246
+ ) : (
247
+ <Text fg={colors.textDim}>{selectedUsernames.length > 1 ? "Group chat" : "Direct message"}</Text>
248
+ )}
249
+ <Box flexGrow={1} />
250
+ <Box
251
+ width={submitting ? 10 : 7}
252
+ height={1}
253
+ backgroundColor={canSubmit ? colors.selected : colors.panel}
254
+ onMouseDown={() => { void submit(); }}
255
+ style={{ cursor: canSubmit ? "pointer" : "default" }}
256
+ >
257
+ <Text fg={canSubmit ? colors.selectedText : colors.textDim}>
258
+ {submitting ? " Starting " : " Start "}
259
+ </Text>
260
+ </Box>
261
+ </Box>
262
+ </Box>
263
+ );
264
+ }
@@ -11,6 +11,7 @@ export function useChatContentShortcuts({
11
11
  blurInput,
12
12
  canSend,
13
13
  cancelEditMessage,
14
+ commandBarOpen,
14
15
  clearReplyTarget,
15
16
  cycleChannel,
16
17
  focusChannelSidebar,
@@ -22,6 +23,10 @@ export function useChatContentShortcuts({
22
23
  inputValueRef,
23
24
  loadingOlderMessages,
24
25
  messages,
26
+ mentionMenuOpen,
27
+ moveMentionSelection,
28
+ dismissMentionSuggestions,
29
+ commitMentionSelection,
25
30
  moveMessageSelection,
26
31
  moveSidebarChannelSelection,
27
32
  nativePaneChrome,
@@ -43,6 +48,7 @@ export function useChatContentShortcuts({
43
48
  blurInput: () => void;
44
49
  canSend: boolean;
45
50
  cancelEditMessage: () => void;
51
+ commandBarOpen: boolean;
46
52
  clearReplyTarget: () => void;
47
53
  cycleChannel: (direction: 1 | -1) => boolean;
48
54
  focusChannelSidebar: () => boolean;
@@ -54,6 +60,10 @@ export function useChatContentShortcuts({
54
60
  inputValueRef: MutableRefObject<string>;
55
61
  loadingOlderMessages: boolean;
56
62
  messages: ChatMessage[];
63
+ mentionMenuOpen: boolean;
64
+ moveMentionSelection: (direction: "up" | "down") => boolean;
65
+ dismissMentionSuggestions: () => boolean;
66
+ commitMentionSelection: () => boolean;
57
67
  moveMessageSelection: (direction: "up" | "down") => boolean;
58
68
  moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
59
69
  nativePaneChrome?: boolean;
@@ -71,7 +81,25 @@ export function useChatContentShortcuts({
71
81
  sidebarFocusedRef: MutableRefObject<boolean>;
72
82
  }) {
73
83
  useShortcut((event) => {
74
- if (!focused) return;
84
+ if (!focused || commandBarOpen || !inputFocused || !mentionMenuOpen) return;
85
+ if (
86
+ event.name !== "tab"
87
+ || event.ctrl
88
+ || event.meta
89
+ || event.super
90
+ || event.alt
91
+ || event.option
92
+ ) {
93
+ return;
94
+ }
95
+
96
+ event.preventDefault?.();
97
+ event.stopPropagation?.();
98
+ commitMentionSelection();
99
+ }, { allowEditable: true, phase: "before" });
100
+
101
+ useShortcut((event) => {
102
+ if (!focused || commandBarOpen) return;
75
103
  const isEnterKey = event.name === "return" || event.name === "enter";
76
104
 
77
105
  if (sidebarFocusedRef.current && showChannelSidebar) {
@@ -117,6 +145,24 @@ export function useChatContentShortcuts({
117
145
  }
118
146
 
119
147
  if (inputFocused) {
148
+ if (mentionMenuOpen) {
149
+ if (event.name === "escape") {
150
+ event.preventDefault?.();
151
+ event.stopPropagation?.();
152
+ dismissMentionSuggestions();
153
+ return;
154
+ }
155
+
156
+ if (isPlainKey(event, "up", "down")) {
157
+ event.preventDefault?.();
158
+ event.stopPropagation?.();
159
+ if (event.name === "up" || event.name === "down") {
160
+ moveMentionSelection(event.name);
161
+ }
162
+ return;
163
+ }
164
+ }
165
+
120
166
  if (event.name === "escape") {
121
167
  event.preventDefault?.();
122
168
  event.stopPropagation?.();
@@ -17,6 +17,7 @@ interface ChatSnapshotStateArgs {
17
17
  initialSnapshot: ChatSnapshot;
18
18
  inputRef: MutableRef<TextareaRenderable | null>;
19
19
  inputValueRef: MutableRef<string>;
20
+ onComposerStateChange?: (draft: string, cursorOffset: number) => void;
20
21
  prependAnchorRef: MutableRef<ChatPrependAnchor | null>;
21
22
  setFollowMessages: Dispatch<SetStateAction<boolean>>;
22
23
  setSelectedIdx: Dispatch<SetStateAction<number>>;
@@ -28,18 +29,21 @@ function syncDraftFromSnapshot({
28
29
  applyingExternalDraftRef,
29
30
  inputRef,
30
31
  inputValueRef,
32
+ onComposerStateChange,
31
33
  snapshot,
32
34
  updateComposerRows,
33
35
  }: {
34
36
  applyingExternalDraftRef: MutableRef<boolean>;
35
37
  inputRef: MutableRef<TextareaRenderable | null>;
36
38
  inputValueRef: MutableRef<string>;
39
+ onComposerStateChange?: (draft: string, cursorOffset: number) => void;
37
40
  snapshot: ChatSnapshot;
38
41
  updateComposerRows: (draft: string) => void;
39
42
  }) {
40
43
  if (inputValueRef.current !== snapshot.draft) {
41
44
  inputValueRef.current = snapshot.draft;
42
45
  updateComposerRows(snapshot.draft);
46
+ onComposerStateChange?.(snapshot.draft, snapshot.draft.length);
43
47
  }
44
48
  const textarea = inputRef.current;
45
49
  if (textarea && textarea.editBuffer.getText() !== snapshot.draft) {
@@ -62,6 +66,7 @@ export function useChatSnapshotState({
62
66
  initialSnapshot,
63
67
  inputRef,
64
68
  inputValueRef,
69
+ onComposerStateChange,
65
70
  prependAnchorRef,
66
71
  setFollowMessages,
67
72
  setSelectedIdx,
@@ -110,6 +115,7 @@ export function useChatSnapshotState({
110
115
  applyingExternalDraftRef,
111
116
  inputRef,
112
117
  inputValueRef,
118
+ onComposerStateChange,
113
119
  snapshot,
114
120
  updateComposerRows,
115
121
  });
@@ -130,6 +136,7 @@ export function useChatSnapshotState({
130
136
  controller,
131
137
  inputRef,
132
138
  inputValueRef,
139
+ onComposerStateChange,
133
140
  prependAnchorRef,
134
141
  setFollowMessages,
135
142
  setSelectedIdx,
@@ -29,7 +29,6 @@ interface ChatTranscriptProps {
29
29
  messages: ChatMessage[];
30
30
  nativePaneChrome: boolean | undefined;
31
31
  latestEditableMessageId: string | null;
32
- openDirectMessage: (target: ChatUserSummary) => void;
33
32
  openTicker: (symbol: string) => void;
34
33
  profilePopoverUser: ChatUserSummary | null;
35
34
  registerMessageElement: (messageId: string, node: unknown | null) => void;
@@ -61,7 +60,6 @@ export function ChatTranscript({
61
60
  messages,
62
61
  nativePaneChrome,
63
62
  latestEditableMessageId,
64
- openDirectMessage,
65
63
  openTicker,
66
64
  profilePopoverUser,
67
65
  registerMessageElement,
@@ -152,8 +150,6 @@ export function ChatTranscript({
152
150
  <UserProfilePopover
153
151
  user={profilePopoverUser}
154
152
  width={chatWidth}
155
- currentUserId={user?.id}
156
- onDirectMessage={openDirectMessage}
157
153
  onClose={scheduleProfilePopoverClose}
158
154
  onKeepOpen={cancelProfilePopoverClose}
159
155
  />
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ChatUserSummary } from "../../../../api-client";
3
+ import { hasPublicChatProfileInfo } from "./profile-popover";
4
+
5
+ function makeUser(overrides: Partial<ChatUserSummary>): ChatUserSummary {
6
+ return {
7
+ id: "u1",
8
+ username: "vince",
9
+ displayName: "Vince",
10
+ profilePublic: true,
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe("profile popover", () => {
16
+ test("treats public portfolio analytics as hover profile information", () => {
17
+ expect(hasPublicChatProfileInfo(makeUser({
18
+ bio: null,
19
+ company: null,
20
+ title: null,
21
+ portfolioAnalytics: {
22
+ oneYearReturn: 0.14,
23
+ spyBeta: 1.05,
24
+ },
25
+ }))).toBe(true);
26
+ });
27
+
28
+ test("hides analytics when the chat profile is private", () => {
29
+ expect(hasPublicChatProfileInfo(makeUser({
30
+ profilePublic: false,
31
+ portfolioAnalytics: {
32
+ oneYearReturn: 0.14,
33
+ },
34
+ }))).toBe(false);
35
+ });
36
+ });
@@ -1,36 +1,140 @@
1
1
  import { Box, Text } from "../../../../ui";
2
2
  import { TextAttributes } from "../../../../ui";
3
3
  import { colors } from "../../../../theme/colors";
4
- import type { ChatUserSummary } from "../../../../api-client";
4
+ import type { ChatUserSummary, PublicPortfolioAnalytics } from "../../../../api-client";
5
+ import { formatNumber } from "../../../../utils/format";
5
6
  import { truncateChannelLabel } from "../channels";
6
- import { ChatActionChip } from "./action-chip";
7
7
 
8
8
  export const PROFILE_POPOVER_CLOSE_DELAY_MS = 40;
9
9
 
10
+ function hasPortfolioAnalytics(analytics: PublicPortfolioAnalytics | null | undefined): boolean {
11
+ return Boolean(
12
+ analytics
13
+ && (
14
+ analytics.oneYearReturn != null
15
+ || analytics.spyBeta != null
16
+ ),
17
+ );
18
+ }
19
+
10
20
  export function hasPublicChatProfileInfo(user: ChatUserSummary): boolean {
11
21
  if (user.profilePublic === false) return false;
12
- return Boolean(user.bio?.trim() || user.title?.trim() || user.company?.trim());
22
+ return Boolean(user.bio?.trim() || user.title?.trim() || user.company?.trim() || hasPortfolioAnalytics(user.portfolioAnalytics));
23
+ }
24
+
25
+ function formatSignedPercent(value: number): string {
26
+ const percent = value * 100;
27
+ return `${percent >= 0 ? "+" : ""}${formatNumber(percent, 2)}%`;
28
+ }
29
+
30
+ type AnalyticsMetric = {
31
+ id: "one-year" | "beta";
32
+ label: string;
33
+ value: string;
34
+ rawValue: number;
35
+ };
36
+
37
+ function analyticsValueColor(id: string, value: number): string {
38
+ if (id === "one-year") {
39
+ if (value > 0) return colors.positive;
40
+ if (value < 0) return colors.negative;
41
+ }
42
+ return colors.warning;
43
+ }
44
+
45
+ function analyticsMetrics(analytics: PublicPortfolioAnalytics): AnalyticsMetric[] {
46
+ return [
47
+ analytics.oneYearReturn != null
48
+ ? {
49
+ id: "one-year",
50
+ label: "1Y",
51
+ value: formatSignedPercent(analytics.oneYearReturn),
52
+ rawValue: analytics.oneYearReturn,
53
+ }
54
+ : null,
55
+ analytics.spyBeta != null
56
+ ? {
57
+ id: "beta",
58
+ label: "Beta",
59
+ value: formatNumber(analytics.spyBeta, 2),
60
+ rawValue: analytics.spyBeta,
61
+ }
62
+ : null,
63
+ ].filter((metric): metric is AnalyticsMetric => !!metric);
64
+ }
65
+
66
+ function headerMetricLabel(metric: AnalyticsMetric): string {
67
+ return metric.id === "beta" ? "Beta" : metric.label;
68
+ }
69
+
70
+ function headerMetricsNaturalWidth(metrics: AnalyticsMetric[]): number {
71
+ const metricWidth = metrics.reduce((sum, metric) => (
72
+ sum + headerMetricLabel(metric).length + 1 + metric.value.length
73
+ ), 0);
74
+ return metricWidth + Math.max(0, metrics.length - 1);
75
+ }
76
+
77
+ function HeaderAnalyticsStats({
78
+ metrics,
79
+ width,
80
+ }: {
81
+ metrics: AnalyticsMetric[];
82
+ width: number;
83
+ }) {
84
+ if (metrics.length === 0 || width < 8) return null;
85
+ const gapWidth = Math.max(0, metrics.length - 1);
86
+ const naturalWidths = metrics.map((metric) => headerMetricLabel(metric).length + 1 + metric.value.length);
87
+ const availableMetricWidth = Math.max(metrics.length * 4, width - gapWidth);
88
+ let overflow = Math.max(0, naturalWidths.reduce((sum, value) => sum + value, 0) - availableMetricWidth);
89
+ const metricWidths = naturalWidths.map((naturalWidth, index) => {
90
+ const shrinkable = Math.max(0, naturalWidth - 4);
91
+ const shrink = Math.min(shrinkable, Math.ceil(overflow / (naturalWidths.length - index)));
92
+ overflow -= shrink;
93
+ return naturalWidth - shrink;
94
+ });
95
+
96
+ return (
97
+ <Box flexDirection="row" gap={1} width={width}>
98
+ {metrics.map((metric, index) => {
99
+ const label = headerMetricLabel(metric);
100
+ const metricWidth = metricWidths[index] ?? 4;
101
+ const labelWidth = Math.max(1, Math.min(label.length, metricWidth - 2));
102
+ const valueWidth = Math.max(1, metricWidth - labelWidth - 1);
103
+ return (
104
+ <Box key={metric.id} width={metricWidth} height={1} flexDirection="row" gap={1}>
105
+ <Text fg={colors.textMuted}>{truncateChannelLabel(label, labelWidth)}</Text>
106
+ <Text fg={analyticsValueColor(metric.id, metric.rawValue)} attributes={TextAttributes.BOLD}>
107
+ {truncateChannelLabel(metric.value, valueWidth)}
108
+ </Text>
109
+ </Box>
110
+ );
111
+ })}
112
+ </Box>
113
+ );
13
114
  }
14
115
 
15
116
  export function UserProfilePopover({
16
117
  user,
17
118
  width,
18
- currentUserId,
19
- onDirectMessage,
20
119
  onClose,
21
120
  onKeepOpen,
22
121
  }: {
23
122
  user: ChatUserSummary;
24
123
  width: number;
25
- currentUserId?: string | null;
26
- onDirectMessage: (user: ChatUserSummary) => void;
27
124
  onClose: () => void;
28
125
  onKeepOpen: () => void;
29
126
  }) {
30
127
  const popoverWidth = Math.max(24, Math.min(38, width - 4));
31
128
  const meta = [user.title, user.company].filter(Boolean).join(" · ");
32
129
  const bio = user.bio?.trim();
33
- const canDm = user.id === currentUserId || user.acceptUnknownDms !== false;
130
+ const analytics = user.portfolioAnalytics;
131
+ const metrics = analytics ? analyticsMetrics(analytics) : [];
132
+ const headerWidth = Math.max(1, popoverWidth - 2);
133
+ const maxStatsWidth = Math.max(0, headerWidth - 8);
134
+ const statsWidth = metrics.length > 0 && maxStatsWidth >= 8
135
+ ? Math.min(headerMetricsNaturalWidth(metrics), maxStatsWidth)
136
+ : 0;
137
+ const usernameWidth = Math.max(1, headerWidth - (statsWidth > 0 ? statsWidth + 1 : 0));
34
138
 
35
139
  return (
36
140
  <Box
@@ -47,19 +151,19 @@ export function UserProfilePopover({
47
151
  onMouseOut={onClose}
48
152
  style={{ zIndex: 4 }}
49
153
  >
50
- <Box height={1} flexDirection="row">
51
- <Text fg={colors.positive} attributes={TextAttributes.BOLD}>
52
- {truncateChannelLabel(user.username ? `@${user.username}` : user.displayName, Math.max(popoverWidth - 10, 1))}
53
- </Text>
154
+ <Box height={1} width={headerWidth} flexDirection="row">
155
+ <Box width={usernameWidth}>
156
+ <Text fg={colors.positive} attributes={TextAttributes.BOLD}>
157
+ {truncateChannelLabel(user.username ? `@${user.username}` : user.displayName, usernameWidth)}
158
+ </Text>
159
+ </Box>
160
+ {statsWidth > 0 ? (
161
+ <>
162
+ <Box width={1} />
163
+ <HeaderAnalyticsStats metrics={metrics} width={statsWidth} />
164
+ </>
165
+ ) : null}
54
166
  <Box flexGrow={1} />
55
- <ChatActionChip
56
- label={canDm ? "DM" : "Closed"}
57
- width={canDm ? 5 : 9}
58
- emphasized
59
- onPress={() => {
60
- if (canDm) onDirectMessage(user);
61
- }}
62
- />
63
167
  </Box>
64
168
  {meta ? <Text fg={colors.textDim}>{truncateChannelLabel(meta, popoverWidth - 2)}</Text> : null}
65
169
  {bio ? (