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
@@ -1,7 +1,7 @@
1
1
  import { Box, Text, useUiCapabilities } from "../../../../ui";
2
2
  import { useState, useEffect, useRef, useCallback, useMemo } from "react";
3
3
  import { type ScrollBoxRenderable, type TextareaRenderable } from "../../../../ui";
4
- import { useAppDispatch } from "../../../../state/app/context";
4
+ import { useAppDispatch, useAppSelector } from "../../../../state/app/context";
5
5
  import { useInlineTickers } from "../../../../state/hooks/inline-tickers";
6
6
  import { blendHex, colors } from "../../../../theme/colors";
7
7
  import { chatController } from "../controller";
@@ -32,10 +32,18 @@ import { buildChatUserByUsername } from "./user-map";
32
32
  import { useChatComposerRuntime } from "./composer-runtime";
33
33
  import { useChatMessageSelection } from "./selection-runtime";
34
34
  import type { ChatMessage } from "../../../../api-client";
35
+ import { NewDmDialog } from "./new-dm-dialog";
35
36
  import {
36
37
  CHAT_MESSAGE_EDIT_WINDOW_MS,
37
38
  findLatestEditableChatMessage,
38
39
  } from "../edit-window";
40
+ import {
41
+ applyMentionSuggestion,
42
+ buildRecentMentionSuggestions,
43
+ detectChatMentionTrigger,
44
+ filterMentionSuggestions,
45
+ } from "./mentions";
46
+ import { getComposerCursorOffset } from "./composer-cursor";
39
47
 
40
48
  interface ChatContentProps {
41
49
  width: number;
@@ -57,6 +65,7 @@ export function ChatContent({
57
65
  controller = chatController,
58
66
  }: ChatContentProps) {
59
67
  const dispatch = useAppDispatch();
68
+ const commandBarOpen = useAppSelector((state) => state.commandBarOpen);
60
69
  const channelId = normalizeChannelId(rawChannelId);
61
70
  const channelIdRef = useRef(channelId);
62
71
  channelIdRef.current = channelId;
@@ -67,6 +76,7 @@ export function ChatContent({
67
76
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
68
77
  const [editingMessage, setEditingMessage] = useState<ChatMessage | null>(null);
69
78
  const [followMessages, setFollowMessages] = useState(true);
79
+ const [newDmOpen, setNewDmOpen] = useState(false);
70
80
  const inputRef = useRef<TextareaRenderable>(null);
71
81
  const scrollRef = useRef<ScrollBoxRenderable>(null);
72
82
  const messageElementsRef = useRef(new Map<string, unknown>());
@@ -82,6 +92,18 @@ export function ChatContent({
82
92
  });
83
93
  const composerTextWidthRef = useRef(initialWidthMetrics.composerTextWidth);
84
94
  const inputValueRef = useRef(initialSnapshot.draft);
95
+ const [composerDraft, setComposerDraft] = useState(initialSnapshot.draft);
96
+ const [composerCursorOffset, setComposerCursorOffset] = useState(initialSnapshot.draft.length);
97
+ const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
98
+ const [dismissedMentionKey, setDismissedMentionKey] = useState<string | null>(null);
99
+ const syncComposerState = useCallback((draft: string, cursorOffset = getComposerCursorOffset(inputRef.current, draft)) => {
100
+ setComposerDraft((current) => (current === draft ? current : draft));
101
+ setComposerCursorOffset((current) => (current === cursorOffset ? current : cursorOffset));
102
+ }, []);
103
+ const syncComposerCursor = useCallback(() => {
104
+ const draft = inputRef.current?.editBuffer.getText() ?? inputValueRef.current;
105
+ syncComposerState(draft, getComposerCursorOffset(inputRef.current, draft));
106
+ }, [syncComposerState]);
85
107
  const [composerRows, setComposerRows] = useState(() => estimateComposerHeight(initialSnapshot.draft, initialWidthMetrics.composerTextWidth));
86
108
  const updateComposerRows = useCallback((draft: string) => {
87
109
  const nextRows = estimateComposerHeight(draft, composerTextWidthRef.current);
@@ -107,6 +129,7 @@ export function ChatContent({
107
129
  initialSnapshot,
108
130
  inputRef,
109
131
  inputValueRef,
132
+ onComposerStateChange: syncComposerState,
110
133
  prependAnchorRef,
111
134
  setFollowMessages,
112
135
  setSelectedIdx,
@@ -155,6 +178,41 @@ export function ChatContent({
155
178
  const latestEditableMessageId = useMemo(() => {
156
179
  return findLatestEditableChatMessage(messages, user?.id, editWindowNowMs)?.id ?? null;
157
180
  }, [editWindowNowMs, messages, user?.id]);
181
+ useEffect(() => {
182
+ updateComposerRows(inputValueRef.current);
183
+ }, [updateComposerRows]);
184
+
185
+ const messageContents = useMemo(() => messages.map((message) => message.content), [messages]);
186
+ const { catalog, openTicker } = useInlineTickers(messageContents);
187
+ const userByUsername = useMemo(() => buildChatUserByUsername(channels, messages), [channels, messages]);
188
+ const activeChannel = useMemo(() => channels.find((channel) => channel.id === channelId), [channelId, channels]);
189
+ const activeChannelTitle = useMemo(() => formatChatPaneTitle(activeChannel, channelId), [activeChannel, channelId]);
190
+ const recentMentionSuggestions = useMemo(() => buildRecentMentionSuggestions({
191
+ activeChannel,
192
+ currentUserId: user?.id,
193
+ messages,
194
+ }), [activeChannel, messages, user?.id]);
195
+ const mentionDisabled = activeChannel?.kind === "direct" || (!activeChannel && channelId.startsWith("dm:"));
196
+ const mentionTrigger = useMemo(() => (
197
+ mentionDisabled ? null : detectChatMentionTrigger(composerDraft, composerCursorOffset)
198
+ ), [
199
+ mentionDisabled,
200
+ composerCursorOffset,
201
+ composerDraft,
202
+ ]);
203
+ const mentionTriggerKey = mentionTrigger
204
+ ? `${channelId}:${mentionTrigger.start}:${mentionTrigger.end}:${mentionTrigger.query}`
205
+ : null;
206
+ const mentionSuggestions = useMemo(() => {
207
+ if (!mentionTrigger || mentionTriggerKey === dismissedMentionKey) return [];
208
+ return filterMentionSuggestions(recentMentionSuggestions, mentionTrigger.query);
209
+ }, [dismissedMentionKey, mentionTrigger, mentionTriggerKey, recentMentionSuggestions]);
210
+ const mentionSelectedIndexSafe = mentionSuggestions.length > 0
211
+ ? Math.min(mentionSelectedIndex, mentionSuggestions.length - 1)
212
+ : 0;
213
+ useEffect(() => {
214
+ setMentionSelectedIndex(0);
215
+ }, [mentionTriggerKey]);
158
216
  const {
159
217
  composerHeight,
160
218
  messageAreaHeight,
@@ -163,19 +221,10 @@ export function ChatContent({
163
221
  composerRows,
164
222
  editingMessage,
165
223
  height,
224
+ mentionSuggestionCount: mentionSuggestions.length,
166
225
  nativePaneChrome,
167
226
  replyTo,
168
227
  });
169
-
170
- useEffect(() => {
171
- updateComposerRows(inputValueRef.current);
172
- }, [updateComposerRows]);
173
-
174
- const messageContents = useMemo(() => messages.map((message) => message.content), [messages]);
175
- const { catalog, openTicker } = useInlineTickers(messageContents);
176
- const userByUsername = useMemo(() => buildChatUserByUsername(channels, messages), [channels, messages]);
177
- const activeChannel = useMemo(() => channels.find((channel) => channel.id === channelId), [channelId, channels]);
178
- const activeChannelTitle = useMemo(() => formatChatPaneTitle(activeChannel, channelId), [activeChannel, channelId]);
179
228
  const {
180
229
  cancelProfilePopoverClose,
181
230
  closeProfilePopover,
@@ -217,7 +266,6 @@ export function ChatContent({
217
266
  focusChannelSidebar,
218
267
  focusChatContent,
219
268
  moveSidebarChannelSelection,
220
- openDirectMessage,
221
269
  selectSidebarChannel,
222
270
  setDirectExpanded,
223
271
  setSidebarFocused,
@@ -230,8 +278,6 @@ export function ChatContent({
230
278
  channelIdRef,
231
279
  channels,
232
280
  channelsLoading,
233
- closeProfilePopover,
234
- controller,
235
281
  focused,
236
282
  inputFocused,
237
283
  onChannelChange,
@@ -240,12 +286,43 @@ export function ChatContent({
240
286
  });
241
287
 
242
288
  const focusInput = useCallback(() => {
289
+ setNewDmOpen(false);
243
290
  setSidebarFocused(false);
244
291
  setInputFocused(true);
245
292
  dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
246
293
  inputRef.current?.focus?.();
247
294
  }, [dispatch, setSidebarFocused]);
248
295
 
296
+ const closeNewDmDialog = useCallback(() => {
297
+ setNewDmOpen(false);
298
+ dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
299
+ }, [dispatch]);
300
+
301
+ const openNewDmDialog = useCallback(() => {
302
+ blurInput();
303
+ closeProfilePopover();
304
+ setSidebarFocused(false);
305
+ setNewDmOpen(true);
306
+ dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
307
+ }, [blurInput, closeProfilePopover, dispatch, setSidebarFocused]);
308
+
309
+ const openConversationFromDialog = useCallback(async (usernames: string[]) => {
310
+ const channel = usernames.length === 1
311
+ ? await controller.openDirectChannel({ username: usernames[0] })
312
+ : await controller.openGroupChannel({ usernames });
313
+ setDirectExpanded(true);
314
+ channelIdRef.current = channel.id;
315
+ selectSidebarChannel(channel.id);
316
+ setSidebarFocused(false);
317
+ closeNewDmDialog();
318
+ }, [channelIdRef, closeNewDmDialog, controller, selectSidebarChannel, setDirectExpanded, setSidebarFocused]);
319
+
320
+ useEffect(() => {
321
+ if (!focused && newDmOpen) {
322
+ closeNewDmDialog();
323
+ }
324
+ }, [closeNewDmDialog, focused, newDmOpen]);
325
+
249
326
  const {
250
327
  beginEditLatestMessage,
251
328
  beginEditMessage,
@@ -257,6 +334,7 @@ export function ChatContent({
257
334
  focusComposer,
258
335
  inputPlaceholder,
259
336
  replyPreview,
337
+ replaceComposerDraft,
260
338
  returnToComposer,
261
339
  sendMessage,
262
340
  } = useChatComposerRuntime({
@@ -273,6 +351,7 @@ export function ChatContent({
273
351
  inputRef,
274
352
  inputValueRef,
275
353
  messages,
354
+ onComposerStateChange: syncComposerState,
276
355
  onChannelChange,
277
356
  editingMessage,
278
357
  latestEditableMessageId,
@@ -286,6 +365,44 @@ export function ChatContent({
286
365
  useDefaultControllerChannel,
287
366
  });
288
367
 
368
+ const moveMentionSelection = useCallback((direction: "up" | "down") => {
369
+ if (mentionSuggestions.length === 0) return false;
370
+ setMentionSelectedIndex((current) => {
371
+ const safeCurrent = Math.max(0, Math.min(current, mentionSuggestions.length - 1));
372
+ if (direction === "up") {
373
+ return safeCurrent <= 0 ? mentionSuggestions.length - 1 : safeCurrent - 1;
374
+ }
375
+ return safeCurrent >= mentionSuggestions.length - 1 ? 0 : safeCurrent + 1;
376
+ });
377
+ return true;
378
+ }, [mentionSuggestions.length]);
379
+
380
+ const dismissMentionSuggestions = useCallback(() => {
381
+ if (!mentionTriggerKey || mentionSuggestions.length === 0) return false;
382
+ setDismissedMentionKey(mentionTriggerKey);
383
+ return true;
384
+ }, [mentionSuggestions.length, mentionTriggerKey]);
385
+
386
+ const commitMentionSelection = useCallback((index = mentionSelectedIndexSafe) => {
387
+ if (!mentionTrigger || mentionSuggestions.length === 0) return false;
388
+ const suggestion = mentionSuggestions[index] ?? mentionSuggestions[0];
389
+ if (!suggestion) return false;
390
+ const replacement = applyMentionSuggestion(composerDraft, mentionTrigger, suggestion);
391
+ replaceComposerDraft(replacement.draft, replacement.cursorOffset);
392
+ setMentionSelectedIndex(0);
393
+ setDismissedMentionKey(mentionTriggerKey);
394
+ queueMicrotask(() => focusInput());
395
+ return true;
396
+ }, [
397
+ composerDraft,
398
+ focusInput,
399
+ mentionSelectedIndexSafe,
400
+ mentionSuggestions,
401
+ mentionTrigger,
402
+ mentionTriggerKey,
403
+ replaceComposerDraft,
404
+ ]);
405
+
289
406
  const {
290
407
  handleTranscriptScrollActivity,
291
408
  jumpToMessage,
@@ -322,17 +439,22 @@ export function ChatContent({
322
439
  blurInput,
323
440
  canSend,
324
441
  cancelEditMessage,
442
+ commandBarOpen,
325
443
  clearReplyTarget,
326
444
  cycleChannel,
327
445
  focusChannelSidebar,
328
446
  focusChatContent,
329
447
  focusComposer,
330
- focused,
448
+ focused: focused && !newDmOpen,
331
449
  hasOlderMessages,
332
450
  inputFocused,
333
451
  inputValueRef,
334
452
  loadingOlderMessages,
335
453
  messages,
454
+ mentionMenuOpen: mentionSuggestions.length > 0,
455
+ moveMentionSelection,
456
+ dismissMentionSuggestions,
457
+ commitMentionSelection,
336
458
  moveMessageSelection,
337
459
  moveSidebarChannelSelection,
338
460
  nativePaneChrome,
@@ -376,9 +498,11 @@ export function ChatContent({
376
498
  keyboardFocused={sidebarFocused}
377
499
  loading={channelsLoading}
378
500
  canManageNotifications={!!user?.emailVerified}
501
+ canCreateConversation={!!user?.emailVerified}
379
502
  directExpanded={directExpanded}
380
503
  onSelect={selectSidebarChannel}
381
504
  onFocusRequest={() => setSidebarFocused(true)}
505
+ onCreateConversation={openNewDmDialog}
382
506
  onToggleNotifications={(nextChannelId, enabled) => {
383
507
  controller.setChannelNotificationsEnabled(nextChannelId, enabled);
384
508
  }}
@@ -420,7 +544,6 @@ export function ChatContent({
420
544
  messages={messages}
421
545
  nativePaneChrome={nativePaneChrome}
422
546
  latestEditableMessageId={latestEditableMessageId}
423
- openDirectMessage={openDirectMessage}
424
547
  openTicker={openTicker}
425
548
  profilePopoverUser={profilePopoverUser}
426
549
  registerMessageElement={registerMessageElement}
@@ -434,6 +557,17 @@ export function ChatContent({
434
557
  userByUsername={userByUsername}
435
558
  />
436
559
 
560
+ {newDmOpen ? (
561
+ <NewDmDialog
562
+ width={chatWidth}
563
+ height={height}
564
+ userByUsername={userByUsername}
565
+ currentUserId={user?.id}
566
+ onCancel={closeNewDmDialog}
567
+ onSubmit={openConversationFromDialog}
568
+ />
569
+ ) : null}
570
+
437
571
  {!nativePaneChrome && !canSend && (
438
572
  <Box height={1} width={contentWidth}>
439
573
  <Text fg={colors.border}>{"-".repeat(contentWidth)}</Text>
@@ -461,6 +595,10 @@ export function ChatContent({
461
595
  replyPreview={replyPreview}
462
596
  replyTo={replyTo}
463
597
  sendMessage={sendMessage}
598
+ mentionSuggestions={mentionSuggestions}
599
+ mentionSelectedIndex={mentionSelectedIndexSafe}
600
+ onMentionCursorChange={syncComposerCursor}
601
+ onMentionSelect={commitMentionSelection}
464
602
  user={user}
465
603
  />
466
604
  </Box>
@@ -44,6 +44,7 @@ export function resolveChatContentHeightMetrics({
44
44
  composerRows,
45
45
  editingMessage,
46
46
  height,
47
+ mentionSuggestionCount,
47
48
  nativePaneChrome,
48
49
  replyTo,
49
50
  }: {
@@ -51,6 +52,7 @@ export function resolveChatContentHeightMetrics({
51
52
  composerRows: number;
52
53
  editingMessage: ChatMessage | null;
53
54
  height: number;
55
+ mentionSuggestionCount?: number;
54
56
  nativePaneChrome: boolean | undefined;
55
57
  replyTo: ChatMessage | null;
56
58
  }) {
@@ -59,7 +61,14 @@ export function resolveChatContentHeightMetrics({
59
61
  ? Math.min(CHAT_COMPOSER_MAX_ROWS + 1, Math.max(2, composerRows + 1))
60
62
  : composerRows
61
63
  : 0;
62
- const inputAreaHeight = getChatComposerAreaHeight({ canSend, composerHeight, editingMessage, nativePaneChrome, replyTo });
64
+ const inputAreaHeight = getChatComposerAreaHeight({
65
+ canSend,
66
+ composerHeight,
67
+ editingMessage,
68
+ mentionSuggestionCount,
69
+ nativePaneChrome,
70
+ replyTo,
71
+ });
63
72
  const topSeparatorHeight = nativePaneChrome ? 0 : 1;
64
73
  const footerSeparatorHeight = !nativePaneChrome && !canSend ? 1 : 0;
65
74
  const messageAreaHeight = Math.max(1, height - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ChatChannel, ChatMessage } from "../../../../api-client";
3
+ import {
4
+ applyMentionSuggestion,
5
+ buildRecentMentionSuggestions,
6
+ detectChatMentionTrigger,
7
+ filterMentionSuggestions,
8
+ } from "./mentions";
9
+
10
+ const message = (id: string, username: string): ChatMessage => ({
11
+ id,
12
+ channelId: "everyone",
13
+ content: id,
14
+ replyToId: null,
15
+ createdAt: `2026-06-30T00:00:0${id}.000Z`,
16
+ user: { id: `u-${username}`, username, displayName: username },
17
+ });
18
+
19
+ describe("chat mention suggestions", () => {
20
+ test("detects an active mention trigger at the caret", () => {
21
+ expect(detectChatMentionTrigger("watch @al", "watch @al".length)).toEqual({
22
+ start: 6,
23
+ end: 9,
24
+ query: "al",
25
+ });
26
+ expect(detectChatMentionTrigger("email a@b", "email a@b".length)).toBeNull();
27
+ expect(detectChatMentionTrigger("done @al now", "done @al now".length)).toBeNull();
28
+ });
29
+
30
+ test("orders visible users by recent messages, filters by prefix, and skips direct channels", () => {
31
+ const activeChannel: ChatChannel = {
32
+ id: "everyone",
33
+ name: "everyone",
34
+ kind: "public",
35
+ created_at: "2026-06-30T00:00:00.000Z",
36
+ };
37
+ const suggestions = buildRecentMentionSuggestions({
38
+ activeChannel,
39
+ currentUserId: "u-me",
40
+ messages: [
41
+ message("1", "alpha"),
42
+ message("2", "bravo"),
43
+ { ...message("3", "me"), user: { id: "u-me", username: "me", displayName: "me" } },
44
+ message("4", "alpha"),
45
+ message("5", "charlie"),
46
+ ],
47
+ });
48
+
49
+ expect(suggestions.map((suggestion) => suggestion.username)).toEqual(["charlie", "alpha", "bravo"]);
50
+ expect(filterMentionSuggestions(suggestions, "a").map((suggestion) => suggestion.username)).toEqual(["alpha"]);
51
+ expect(buildRecentMentionSuggestions({
52
+ activeChannel: {
53
+ id: "dm:alpha",
54
+ name: "alpha",
55
+ kind: "direct",
56
+ created_at: "2026-06-30T00:00:00.000Z",
57
+ },
58
+ currentUserId: "u-me",
59
+ messages: [message("1", "alpha")],
60
+ })).toEqual([]);
61
+ });
62
+
63
+ test("replaces only the active mention fragment", () => {
64
+ const trigger = detectChatMentionTrigger("check @al", "check @al".length);
65
+ expect(trigger).not.toBeNull();
66
+
67
+ expect(applyMentionSuggestion("check @al", trigger!, {
68
+ username: "alpha",
69
+ })).toEqual({
70
+ draft: "check @alpha ",
71
+ cursorOffset: "check @alpha ".length,
72
+ });
73
+ });
74
+ });
@@ -0,0 +1,88 @@
1
+ import type { ChatChannel, ChatMessage } from "../../../../api-client";
2
+
3
+ export interface ChatMentionSuggestion {
4
+ username: string;
5
+ }
6
+
7
+ interface ChatMentionTrigger {
8
+ start: number;
9
+ end: number;
10
+ query: string;
11
+ }
12
+
13
+ const MENTION_TRIGGER_RE = /(^|[\s([{<,.;:!?])@([A-Za-z0-9_]{0,30})$/;
14
+ const MAX_MENTION_SUGGESTIONS = 5;
15
+
16
+ function normalizeUsername(username: string | null | undefined): string | null {
17
+ const normalized = username?.trim().replace(/^@+/, "");
18
+ return normalized ? normalized : null;
19
+ }
20
+
21
+ export function detectChatMentionTrigger(
22
+ draft: string,
23
+ cursorOffset: number,
24
+ ): ChatMentionTrigger | null {
25
+ const cursor = Math.max(0, Math.min(cursorOffset, draft.length));
26
+ const beforeCursor = draft.slice(0, cursor);
27
+ const match = beforeCursor.match(MENTION_TRIGGER_RE);
28
+ if (!match) return null;
29
+ const query = match[2] ?? "";
30
+ const start = beforeCursor.length - query.length - 1;
31
+ return { start, end: cursor, query };
32
+ }
33
+
34
+ export function buildRecentMentionSuggestions({
35
+ activeChannel,
36
+ currentUserId,
37
+ messages,
38
+ limit = MAX_MENTION_SUGGESTIONS,
39
+ }: {
40
+ activeChannel: ChatChannel | undefined;
41
+ currentUserId: string | null | undefined;
42
+ messages: ChatMessage[];
43
+ limit?: number;
44
+ }): ChatMentionSuggestion[] {
45
+ if (activeChannel?.kind === "direct") return [];
46
+ const seen = new Set<string>();
47
+ const suggestions: ChatMentionSuggestion[] = [];
48
+
49
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
50
+ const user = messages[index]?.user;
51
+ const username = normalizeUsername(user?.username);
52
+ if (!user || !username || user.id === currentUserId) continue;
53
+ const key = username.toLowerCase();
54
+ if (seen.has(key)) continue;
55
+ seen.add(key);
56
+ suggestions.push({ username });
57
+ if (suggestions.length >= limit) break;
58
+ }
59
+
60
+ return suggestions;
61
+ }
62
+
63
+ export function filterMentionSuggestions(
64
+ suggestions: ChatMentionSuggestion[],
65
+ query: string,
66
+ limit = MAX_MENTION_SUGGESTIONS,
67
+ ): ChatMentionSuggestion[] {
68
+ const normalizedQuery = normalizeUsername(query)?.toLowerCase() ?? "";
69
+ return suggestions
70
+ .filter((suggestion) => !normalizedQuery || suggestion.username.toLowerCase().startsWith(normalizedQuery))
71
+ .slice(0, limit);
72
+ }
73
+
74
+ export function applyMentionSuggestion(
75
+ draft: string,
76
+ trigger: ChatMentionTrigger,
77
+ suggestion: ChatMentionSuggestion,
78
+ ): { draft: string; cursorOffset: number } {
79
+ const before = draft.slice(0, trigger.start);
80
+ const after = draft.slice(trigger.end);
81
+ const mention = `@${suggestion.username}`;
82
+ const suffix = after.length === 0 || !/^\s/.test(after) ? " " : "";
83
+ const nextDraft = `${before}${mention}${suffix}${after}`;
84
+ return {
85
+ draft: nextDraft,
86
+ cursorOffset: before.length + mention.length + suffix.length,
87
+ };
88
+ }