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
@@ -45,6 +45,55 @@ function makeOwnMessage(content = "typo", createdAt = recentChatTimestamp()): Ch
45
45
  };
46
46
  }
47
47
 
48
+ const makeNamedMessage = (index: number, username: string): ChatMessage => ({
49
+ id: `named-${index}`,
50
+ channelId: "everyone",
51
+ content: `message from ${username}`,
52
+ replyToId: null,
53
+ createdAt: `2026-03-30T00:01:${String(index).padStart(2, "0")}.000Z`,
54
+ user: { id: `u-${username}`, username, displayName: username },
55
+ });
56
+
57
+ async function renderFocusedComposerWithDraft(
58
+ controller: ReturnType<typeof createController>,
59
+ draft: string,
60
+ options: Parameters<typeof createHarness>[1] = {},
61
+ ) {
62
+ const harnessOptions = {
63
+ width: 72,
64
+ height: 14,
65
+ ...options,
66
+ };
67
+ await act(async () => {
68
+ testSetup = await testRender(createHarness(controller, harnessOptions), {
69
+ width: harnessOptions.width ?? 72,
70
+ height: harnessOptions.height ?? 14,
71
+ });
72
+ });
73
+
74
+ await flushFrame();
75
+
76
+ const frameBeforeClick = setup().captureCharFrame().split("\n");
77
+ const inputRow = frameBeforeClick.findIndex((line) => line.includes("Type a message..."));
78
+ const inputCol = frameBeforeClick[inputRow]?.indexOf("Type a message...") ?? -1;
79
+
80
+ expect(inputRow).toBeGreaterThanOrEqual(0);
81
+ expect(inputCol).toBeGreaterThanOrEqual(0);
82
+
83
+ await act(async () => {
84
+ await setup().mockMouse.click(inputCol + 1, inputRow);
85
+ await setup().renderOnce();
86
+ await setup().renderOnce();
87
+ });
88
+
89
+ await act(async () => {
90
+ await setup().mockInput.typeText(draft);
91
+ await setup().renderOnce();
92
+ await setup().renderOnce();
93
+ });
94
+ await flushFrame();
95
+ }
96
+
48
97
  beforeEach(() => {
49
98
  installChatApiTestDefaults();
50
99
  });
@@ -177,6 +226,82 @@ describe("ChatContent", () => {
177
226
  expect(frameAfterType).not.toContain("> betaalpha");
178
227
  });
179
228
 
229
+ test("autocompletes recent user mentions from the focused composer", async () => {
230
+ const controller = createController({
231
+ messages: [
232
+ makeNamedMessage(1, "alpha"),
233
+ makeNamedMessage(2, "bravo"),
234
+ makeNamedMessage(3, "charlie"),
235
+ ],
236
+ });
237
+
238
+ await renderFocusedComposerWithDraft(controller, "@");
239
+
240
+ let frame = setup().captureCharFrame();
241
+ expect(frame).toContain("@charlie");
242
+ expect(frame).toContain("@bravo");
243
+
244
+ await emitKeypress({ name: "down", sequence: "\u001b[B" });
245
+
246
+ await act(async () => {
247
+ setup().mockInput.pressEnter();
248
+ await setup().renderOnce();
249
+ await setup().renderOnce();
250
+ });
251
+ await flushFrame();
252
+
253
+ frame = setup().captureCharFrame();
254
+ expect(frame).toContain("> @bravo");
255
+ expect(frame).not.toContain("@charlie");
256
+ });
257
+
258
+ test("tab accepts the current mention suggestion without pane cycling", async () => {
259
+ const controller = createController({
260
+ messages: [
261
+ makeNamedMessage(1, "alpha"),
262
+ makeNamedMessage(2, "bravo"),
263
+ makeNamedMessage(3, "charlie"),
264
+ ],
265
+ });
266
+
267
+ await renderFocusedComposerWithDraft(controller, "@");
268
+
269
+ const event = await emitKeypress({ name: "tab", sequence: "\t" });
270
+ await flushFrame();
271
+
272
+ const frame = setup().captureCharFrame();
273
+ expect(frame).toContain("> @charlie");
274
+ expect(frame).not.toContain("@bravo");
275
+ expect(event.defaultPrevented).toBe(true);
276
+ expect(event.propagationStopped).toBe(true);
277
+ });
278
+
279
+ test("leaves tab for the command bar when mention suggestions are open behind it", async () => {
280
+ const controller = createController({
281
+ messages: [
282
+ makeNamedMessage(1, "alpha"),
283
+ makeNamedMessage(2, "bravo"),
284
+ makeNamedMessage(3, "charlie"),
285
+ ],
286
+ });
287
+
288
+ await renderFocusedComposerWithDraft(controller, "@", {
289
+ configureState: (state) => {
290
+ state.commandBarOpen = true;
291
+ },
292
+ });
293
+
294
+ const event = await emitKeypress({ name: "tab", sequence: "\t" });
295
+ await flushFrame();
296
+
297
+ const frame = setup().captureCharFrame();
298
+ expect(frame).toContain("@charlie");
299
+ expect(frame).toContain("> @");
300
+ expect(frame).not.toContain("> @charlie");
301
+ expect(event.defaultPrevented).toBe(false);
302
+ expect(event.propagationStopped).toBe(false);
303
+ });
304
+
180
305
  test("up arrow selects the newest message first when nothing is selected", async () => {
181
306
  const controller = createController({
182
307
  messages: [makeMessage(1), makeMessage(2)],
@@ -1,12 +1,11 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
3
- import type { ChatChannel, ChatUserSummary } from "../../../../api-client";
3
+ import type { ChatChannel } from "../../../../api-client";
4
4
  import { useThrottledCommitValue } from "../../../../react/use-throttled-commit-value";
5
5
  import {
6
6
  DEFAULT_CHAT_CHANNEL_ID,
7
7
  normalizeChannelId,
8
8
  } from "../channels";
9
- import type { ChatContentController } from "./types";
10
9
 
11
10
  const CHANNEL_NAVIGATION_COMMIT_DELAY_MS = 150;
12
11
 
@@ -20,8 +19,6 @@ export function useChatChannelNavigation({
20
19
  channelIdRef,
21
20
  channels,
22
21
  channelsLoading,
23
- closeProfilePopover,
24
- controller,
25
22
  focused,
26
23
  inputFocused,
27
24
  onChannelChange,
@@ -33,8 +30,6 @@ export function useChatChannelNavigation({
33
30
  channelIdRef: MutableRefObject<string>;
34
31
  channels: ChatChannel[];
35
32
  channelsLoading: boolean;
36
- closeProfilePopover: () => void;
37
- controller: ChatContentController;
38
33
  focused: boolean;
39
34
  inputFocused: boolean;
40
35
  onChannelChange?: (channelId: string) => void;
@@ -46,7 +41,6 @@ export function useChatChannelNavigation({
46
41
  focusChannelSidebar: () => boolean;
47
42
  focusChatContent: () => boolean;
48
43
  moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
49
- openDirectMessage: (target: ChatUserSummary) => Promise<void>;
50
44
  selectSidebarChannel: (channelId: string) => void;
51
45
  setDirectExpanded: Dispatch<SetStateAction<boolean>>;
52
46
  setSidebarFocused: (nextFocused: boolean) => void;
@@ -82,19 +76,6 @@ export function useChatChannelNavigation({
82
76
  replaceValue: replaceSidebarCursorChannelId,
83
77
  } = useThrottledCommitValue(channelId, changeChannel, CHANNEL_NAVIGATION_COMMIT_DELAY_MS);
84
78
 
85
- const openDirectMessage = useCallback(async (target: ChatUserSummary) => {
86
- if (!target.id && !target.username) return;
87
- try {
88
- const channel = await controller.openDirectChannel({
89
- userId: target.id,
90
- username: target.username ?? undefined,
91
- });
92
- setDirectExpanded(true);
93
- setSidebarCursorChannelId(channel.id, { immediate: true });
94
- closeProfilePopover();
95
- } catch {}
96
- }, [closeProfilePopover, controller, setSidebarCursorChannelId]);
97
-
98
79
  useEffect(() => {
99
80
  if (!onChannelChange || channelsLoading || channels.length === 0) return;
100
81
  if (channels.some((channel) => channel.id === channelId)) return;
@@ -185,7 +166,6 @@ export function useChatChannelNavigation({
185
166
  focusChannelSidebar,
186
167
  focusChatContent,
187
168
  moveSidebarChannelSelection,
188
- openDirectMessage,
189
169
  selectSidebarChannel,
190
170
  setDirectExpanded,
191
171
  setSidebarFocused,
@@ -0,0 +1,29 @@
1
+ import type { TextareaRenderable } from "../../../../ui";
2
+
3
+ const clampCursorOffset = (offset: number, draft: string) => Math.max(0, Math.min(offset, draft.length));
4
+
5
+ export function getComposerCursorOffset(
6
+ textarea: TextareaRenderable | null | undefined,
7
+ draft: string,
8
+ ): number {
9
+ const offset = textarea?.visualCursor?.offset ?? textarea?.cursorOffset ?? draft.length;
10
+ return clampCursorOffset(offset, draft);
11
+ }
12
+
13
+ export function moveComposerCursorToOffset(
14
+ textarea: TextareaRenderable,
15
+ draft: string,
16
+ offset: number,
17
+ ): void {
18
+ const nextOffset = clampCursorOffset(offset, draft);
19
+ const editBuffer = textarea.editBuffer as typeof textarea.editBuffer & {
20
+ setCursorByOffset?: (offset: number) => void;
21
+ };
22
+ if (typeof editBuffer.setCursorByOffset === "function") return editBuffer.setCursorByOffset(nextOffset);
23
+ if (typeof textarea.setCursorOffset === "function") return textarea.setCursorOffset(nextOffset);
24
+ try {
25
+ textarea.cursorOffset = nextOffset;
26
+ } catch {
27
+ // Some host renderers expose cursorOffset as read-only.
28
+ }
29
+ }
@@ -7,31 +7,12 @@ import {
7
7
  } from "../layout";
8
8
  import { parseChatComposerCommand } from "../composer-commands";
9
9
  import type { ChatContentController } from "./types";
10
+ import { getComposerCursorOffset, moveComposerCursorToOffset } from "./composer-cursor";
10
11
 
11
12
  interface MutableRef<T> {
12
13
  current: T;
13
14
  }
14
15
 
15
- function moveComposerCursorToEnd(textarea: TextareaRenderable, draft: string) {
16
- const offset = draft.length;
17
- const editBuffer = textarea.editBuffer as typeof textarea.editBuffer & {
18
- setCursorByOffset?: (offset: number) => void;
19
- };
20
- if (typeof editBuffer.setCursorByOffset === "function") {
21
- editBuffer.setCursorByOffset(offset);
22
- return;
23
- }
24
- if (typeof textarea.setCursorOffset === "function") {
25
- textarea.setCursorOffset(offset);
26
- return;
27
- }
28
- try {
29
- textarea.cursorOffset = offset;
30
- } catch {
31
- // Some host renderers expose cursorOffset as read-only.
32
- }
33
- }
34
-
35
16
  export function useChatComposerRuntime({
36
17
  applyingExternalDraftRef,
37
18
  blurInput,
@@ -46,6 +27,7 @@ export function useChatComposerRuntime({
46
27
  inputRef,
47
28
  inputValueRef,
48
29
  messages,
30
+ onComposerStateChange,
49
31
  onChannelChange,
50
32
  editingMessage,
51
33
  latestEditableMessageId,
@@ -71,6 +53,7 @@ export function useChatComposerRuntime({
71
53
  inputRef: MutableRef<TextareaRenderable | null>;
72
54
  inputValueRef: MutableRef<string>;
73
55
  messages: ChatMessage[];
56
+ onComposerStateChange?: (draft: string, cursorOffset: number) => void;
74
57
  onChannelChange?: (channelId: string) => void;
75
58
  editingMessage: ChatMessage | null;
76
59
  latestEditableMessageId: string | null;
@@ -112,7 +95,7 @@ export function useChatComposerRuntime({
112
95
  }
113
96
  }, [channelId, controller, useDefaultControllerChannel]);
114
97
 
115
- const replaceLocalComposer = useCallback((draft: string) => {
98
+ const replaceLocalComposer = useCallback((draft: string, cursorOffset = draft.length) => {
116
99
  inputValueRef.current = draft;
117
100
  updateComposerRows(draft);
118
101
  const textarea = inputRef.current;
@@ -120,14 +103,20 @@ export function useChatComposerRuntime({
120
103
  applyingExternalDraftRef.current = true;
121
104
  try {
122
105
  textarea.setText(draft);
123
- moveComposerCursorToEnd(textarea, draft);
106
+ moveComposerCursorToOffset(textarea, draft, cursorOffset);
124
107
  } finally {
125
108
  applyingExternalDraftRef.current = false;
126
109
  }
127
110
  } else if (textarea) {
128
- moveComposerCursorToEnd(textarea, draft);
111
+ moveComposerCursorToOffset(textarea, draft, cursorOffset);
129
112
  }
130
- }, [applyingExternalDraftRef, inputRef, inputValueRef, updateComposerRows]);
113
+ onComposerStateChange?.(draft, cursorOffset);
114
+ }, [applyingExternalDraftRef, inputRef, inputValueRef, onComposerStateChange, updateComposerRows]);
115
+
116
+ const replaceComposerDraft = useCallback((draft: string, cursorOffset = draft.length) => {
117
+ replaceLocalComposer(draft, cursorOffset);
118
+ persistDraft(draft);
119
+ }, [persistDraft, replaceLocalComposer]);
131
120
 
132
121
  const beginReplyTo = useCallback((index: number, options?: { deferFocus?: boolean }) => {
133
122
  if (!canSend || index < 0 || index >= messages.length) return;
@@ -295,14 +284,22 @@ export function useChatComposerRuntime({
295
284
 
296
285
  const commitLocalDraft = useCallback((draft: string) => {
297
286
  if (applyingExternalDraftRef.current) return;
287
+ const previousDraft = inputValueRef.current;
298
288
  inputValueRef.current = draft;
299
289
  updateComposerRows(draft);
300
290
  persistDraft(draft);
291
+ const rawCursorOffset = getComposerCursorOffset(inputRef.current, draft);
292
+ const cursorOffset = rawCursorOffset === 0 && draft.length > previousDraft.length && draft.startsWith(previousDraft)
293
+ ? draft.length
294
+ : rawCursorOffset;
295
+ onComposerStateChange?.(draft, cursorOffset);
301
296
  }, [
302
297
  applyingExternalDraftRef,
303
298
  channelId,
304
299
  controller,
300
+ inputRef,
305
301
  inputValueRef,
302
+ onComposerStateChange,
306
303
  persistDraft,
307
304
  updateComposerRows,
308
305
  useDefaultControllerChannel,
@@ -376,6 +373,7 @@ export function useChatComposerRuntime({
376
373
  focusComposer,
377
374
  inputPlaceholder,
378
375
  replyPreview,
376
+ replaceComposerDraft,
379
377
  returnToComposer,
380
378
  sendMessage,
381
379
  };
@@ -5,6 +5,7 @@ import type { ChatMessage } from "../../../../api-client";
5
5
  import { InlineAuthActions } from "../../cloud/auth-actions";
6
6
  import { ChatActionChip } from "../message/action-chip";
7
7
  import { COMPOSER_ACTION_WIDTH } from "../layout";
8
+ import type { ChatMentionSuggestion } from "./mentions";
8
9
 
9
10
  const DESKTOP_CHAT_INPUT_TOP_MARGIN_PX = 6;
10
11
 
@@ -33,9 +34,73 @@ interface ChatComposerAreaProps {
33
34
  replyPreview: string;
34
35
  replyTo: ChatMessage | null;
35
36
  sendMessage: () => void;
37
+ mentionSuggestions: ChatMentionSuggestion[];
38
+ mentionSelectedIndex: number;
39
+ onMentionCursorChange: () => void;
40
+ onMentionSelect: (index?: number) => boolean;
36
41
  user: { id: string; username: string; emailVerified: boolean } | null;
37
42
  }
38
43
 
44
+ function ChatMentionSuggestions({
45
+ contentWidth,
46
+ nativePaneChrome,
47
+ onSelect,
48
+ selectedIndex,
49
+ suggestions,
50
+ }: {
51
+ contentWidth: number;
52
+ nativePaneChrome: boolean | undefined;
53
+ onSelect: (index?: number) => boolean;
54
+ selectedIndex: number;
55
+ suggestions: ChatMentionSuggestion[];
56
+ }) {
57
+ if (suggestions.length === 0) return null;
58
+ const width = Math.max(18, Math.min(contentWidth, 44));
59
+ const menuStyle = nativePaneChrome
60
+ ? {
61
+ marginBottom: 4,
62
+ border: `1px solid ${colors.border}`,
63
+ borderRadius: 6,
64
+ overflow: "hidden",
65
+ }
66
+ : undefined;
67
+
68
+ return (
69
+ <Box
70
+ width={width}
71
+ height={suggestions.length}
72
+ flexDirection="column"
73
+ backgroundColor={nativePaneChrome ? colors.panel : colors.bg}
74
+ style={menuStyle}
75
+ >
76
+ {suggestions.map((suggestion, index) => {
77
+ const selected = index === selectedIndex;
78
+ return (
79
+ <Box
80
+ key={suggestion.username}
81
+ height={1}
82
+ width={width}
83
+ flexDirection="row"
84
+ backgroundColor={selected ? colors.selected : "transparent"}
85
+ onMouseDown={() => { onSelect(index); }}
86
+ style={{
87
+ cursor: "pointer",
88
+ paddingInline: nativePaneChrome ? 8 : undefined,
89
+ }}
90
+ >
91
+ <Text
92
+ fg={selected ? colors.selectedText : colors.positive}
93
+ attributes={selected ? TextAttributes.BOLD : 0}
94
+ >
95
+ {`@${suggestion.username}`}
96
+ </Text>
97
+ </Box>
98
+ );
99
+ })}
100
+ </Box>
101
+ );
102
+ }
103
+
39
104
  export function ChatComposerArea({
40
105
  canSend,
41
106
  cancelEditMessage,
@@ -57,6 +122,10 @@ export function ChatComposerArea({
57
122
  replyPreview,
58
123
  replyTo,
59
124
  sendMessage,
125
+ mentionSuggestions,
126
+ mentionSelectedIndex,
127
+ onMentionCursorChange,
128
+ onMentionSelect,
60
129
  user,
61
130
  }: ChatComposerAreaProps) {
62
131
  if (!canSend) {
@@ -122,6 +191,14 @@ export function ChatComposerArea({
122
191
  </Box>
123
192
  )}
124
193
 
194
+ <ChatMentionSuggestions
195
+ contentWidth={contentWidth}
196
+ nativePaneChrome={nativePaneChrome}
197
+ onSelect={onMentionSelect}
198
+ selectedIndex={mentionSelectedIndex}
199
+ suggestions={mentionSuggestions}
200
+ />
201
+
125
202
  <MessageComposer
126
203
  inputRef={inputRef}
127
204
  initialValue={inputValueRef.current}
@@ -132,6 +209,7 @@ export function ChatComposerArea({
132
209
  height={composerHeight}
133
210
  onFocusRequest={focusComposer}
134
211
  onInput={commitLocalDraft}
212
+ onCursorChange={onMentionCursorChange}
135
213
  keyBindings={[
136
214
  { name: "return", action: "submit" },
137
215
  { name: "linefeed", action: "submit" },
@@ -141,6 +219,7 @@ export function ChatComposerArea({
141
219
  { name: "linefeed", meta: true, action: "submit" },
142
220
  ]}
143
221
  onSubmit={() => {
222
+ if (onMentionSelect()) return;
144
223
  if (inputValueRef.current.trim()) {
145
224
  sendMessage();
146
225
  }
@@ -157,13 +236,17 @@ export function getChatComposerAreaHeight({
157
236
  editingMessage,
158
237
  nativePaneChrome,
159
238
  replyTo,
239
+ mentionSuggestionCount,
160
240
  }: {
161
241
  canSend: boolean;
162
242
  composerHeight: number;
163
243
  editingMessage: ChatMessage | null;
164
244
  nativePaneChrome: boolean | undefined;
165
245
  replyTo: ChatMessage | null;
246
+ mentionSuggestionCount?: number;
166
247
  }) {
167
248
  if (!canSend) return 2;
168
- return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome }) + (editingMessage || replyTo ? 1 : 0);
249
+ return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome })
250
+ + (editingMessage || replyTo ? 1 : 0)
251
+ + Math.max(0, mentionSuggestionCount ?? 0);
169
252
  }