gloomberb 0.9.7 → 0.9.9

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 (276) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.test.ts +3 -3
  4. package/src/api-client/index.ts +48 -6
  5. package/src/api-client/request.test.ts +63 -0
  6. package/src/api-client/request.ts +53 -5
  7. package/src/api-client/types.ts +45 -2
  8. package/src/app/pane-runtime/layout-placement.ts +0 -1
  9. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  10. package/src/app/runtime/desktop-deeplink.ts +496 -0
  11. package/src/app.tsx +34 -0
  12. package/src/brokers/account-cache.ts +2 -10
  13. package/src/brokers/broker-portfolio-sync.ts +0 -1
  14. package/src/brokers/broker-ticker-sync.ts +20 -16
  15. package/src/brokers/remote-broker-adapter.ts +3 -0
  16. package/src/capabilities/registry.test.ts +25 -0
  17. package/src/capabilities/registry.ts +22 -5
  18. package/src/cli/commands/automation.ts +1 -3
  19. package/src/cli/commands/command-utils.ts +0 -7
  20. package/src/cli/commands/overview.ts +2 -2
  21. package/src/cli/commands/plugins.ts +34 -17
  22. package/src/cli/index.ts +2 -2
  23. package/src/cli.test.ts +0 -157
  24. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  25. package/src/components/chart/bar-chart-renderer.ts +111 -55
  26. package/src/components/chart/comparison/renderer.ts +0 -1
  27. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  28. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  29. package/src/components/chart/core/index.test.ts +0 -24
  30. package/src/components/chart/core/renderer.ts +0 -4
  31. package/src/components/chart/core/resolution.test.ts +0 -17
  32. package/src/components/chart/core/resolution.ts +0 -26
  33. package/src/components/chart/core/scroll.test.ts +0 -6
  34. package/src/components/chart/core/scroll.ts +0 -22
  35. package/src/components/chart/core/viewport.test.ts +2 -23
  36. package/src/components/chart/core/viewport.ts +0 -27
  37. package/src/components/chart/market-session.ts +1 -1
  38. package/src/components/chart/native/surface/hooks.ts +10 -3
  39. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  40. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  41. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  42. package/src/components/chart/stock-chart/footer.ts +68 -13
  43. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  44. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  45. package/src/components/command-bar/helpers.test.ts +1 -80
  46. package/src/components/command-bar/helpers.ts +1 -36
  47. package/src/components/command-bar/pane-settings.ts +1 -1
  48. package/src/components/command-bar/pane-templates/items.ts +11 -3
  49. package/src/components/command-bar/panel/runtime.ts +0 -2
  50. package/src/components/command-bar/routes/root/results.ts +1 -1
  51. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  52. package/src/components/command-bar/surface/index.test.tsx +52 -16
  53. package/src/components/command-bar/surface/index.tsx +0 -1
  54. package/src/components/command-bar/workflow/ops.ts +81 -20
  55. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  56. package/src/components/feed-data-table/stack-view.tsx +0 -1
  57. package/src/components/index.ts +1 -0
  58. package/src/components/layout/shell/index.test.tsx +2 -113
  59. package/src/components/layout/shell/index.tsx +0 -1
  60. package/src/components/layout/shell/menu.ts +0 -1
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  62. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  63. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  64. package/src/components/price-selector-dialog.tsx +3 -3
  65. package/src/components/ticker/badge/text.tsx +0 -1
  66. package/src/components/toggle-list.tsx +29 -73
  67. package/src/components/ui/button.tsx +4 -1
  68. package/src/components/ui/checkbox.tsx +76 -0
  69. package/src/components/ui/choice-dialog.tsx +15 -3
  70. package/src/components/ui/frame.tsx +1 -1
  71. package/src/components/ui/index.ts +2 -0
  72. package/src/components/ui/message-composer.tsx +4 -0
  73. package/src/components/ui/native-select.tsx +4 -2
  74. package/src/components/ui/page-stack-view.tsx +1 -1
  75. package/src/components/ui/ui.test.tsx +0 -78
  76. package/src/core/state/app/layout-reducer.ts +0 -1
  77. package/src/data/config/layout.ts +3 -3
  78. package/src/data/resource-store.ts +6 -0
  79. package/src/data/sqlite/cache.test.ts +5 -0
  80. package/src/data/ticker-store.ts +0 -7
  81. package/src/market-data/coordinator/auxiliary.ts +1 -44
  82. package/src/market-data/coordinator/entries.ts +0 -1
  83. package/src/market-data/coordinator/events.test.ts +49 -0
  84. package/src/market-data/coordinator/events.ts +25 -4
  85. package/src/market-data/coordinator/index.test.ts +223 -10
  86. package/src/market-data/coordinator/index.ts +41 -21
  87. package/src/market-data/coordinator/quotes.ts +13 -11
  88. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  89. package/src/market-data/hooks.tsx +17 -13
  90. package/src/market-data/market/freshness.ts +58 -1
  91. package/src/market-data/market/status.ts +19 -1
  92. package/src/market-data/query-store.ts +0 -4
  93. package/src/market-data/quotes/freshness.test.ts +49 -1
  94. package/src/market-data/quotes/freshness.ts +8 -0
  95. package/src/market-data/quotes/resolution.test.ts +185 -0
  96. package/src/market-data/quotes/resolution.ts +96 -10
  97. package/src/market-data/request-types.test.ts +61 -1
  98. package/src/market-data/request-types.ts +42 -8
  99. package/src/market-data/result-types.ts +0 -11
  100. package/src/market-data/selectors.ts +1 -5
  101. package/src/news/aggregator.ts +5 -1
  102. package/src/pane-settings.test.ts +0 -18
  103. package/src/plugins/builtin/account-management/footer.ts +2 -5
  104. package/src/plugins/builtin/account-management/form-components.tsx +175 -59
  105. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  106. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  107. package/src/plugins/builtin/account-management/model.ts +259 -23
  108. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  109. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  110. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  111. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  112. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  113. package/src/plugins/builtin/buildout/format.ts +2 -11
  114. package/src/plugins/builtin/chat/chat.test.tsx +127 -1
  115. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  116. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  117. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  118. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  119. package/src/plugins/builtin/chat/content/index.tsx +153 -16
  120. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  121. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  122. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  123. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  124. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  125. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  126. package/src/plugins/builtin/chat/content/transcript.tsx +0 -5
  127. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  128. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  129. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  130. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  131. package/src/plugins/builtin/chat/sidebar.test.tsx +83 -6
  132. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  133. package/src/plugins/builtin/chat/test-harness.tsx +22 -12
  134. package/src/plugins/builtin/cloud/plugin.tsx +8 -1
  135. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  136. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  137. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  138. package/src/plugins/builtin/correlation/index.tsx +4 -1
  139. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  140. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  141. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  142. package/src/plugins/builtin/earnings/index.tsx +0 -9
  143. package/src/plugins/builtin/econ/index.tsx +1 -5
  144. package/src/plugins/builtin/help/index.tsx +2 -2
  145. package/src/plugins/builtin/holders/pane.tsx +0 -5
  146. package/src/plugins/builtin/insider/index.tsx +1 -8
  147. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  148. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  149. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  150. package/src/plugins/builtin/news/index.tsx +0 -1
  151. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  152. package/src/plugins/builtin/options/view.test.tsx +1 -38
  153. package/src/plugins/builtin/plugin-groups.ts +17 -3
  154. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  155. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -25
  156. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  157. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  158. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  159. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  160. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  161. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  162. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  163. package/src/plugins/builtin/sec/index.tsx +0 -2
  164. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  165. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  166. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  167. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +42 -6
  168. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  169. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  170. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  171. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  172. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  173. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  174. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  175. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  176. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  177. package/src/plugins/builtin/world-indices/model.ts +0 -7
  178. package/src/plugins/ibkr/account-cache.ts +1 -1
  179. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  180. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  181. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  182. package/src/plugins/ibkr/trade/utils.ts +1 -7
  183. package/src/plugins/loader.ts +2 -1
  184. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  185. package/src/plugins/registry/context.ts +15 -0
  186. package/src/plugins/registry/contributions.ts +22 -11
  187. package/src/plugins/registry/index.test.ts +44 -0
  188. package/src/plugins/registry/index.ts +75 -26
  189. package/src/remote/controller.ts +1 -7
  190. package/src/remote/json-patch.ts +15 -1
  191. package/src/remote/server.ts +16 -6
  192. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  193. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  194. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  195. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  196. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  197. package/src/renderers/electrobun/bun/index.ts +42 -0
  198. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  199. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  200. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  201. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  202. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  203. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  204. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  205. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  206. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  207. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  208. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  209. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  210. package/src/renderers/electrobun/view/main.tsx +3 -0
  211. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  212. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  213. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  214. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  215. package/src/renderers/opentui/host.tsx +11 -3
  216. package/src/renderers/opentui/input-host.tsx +5 -1
  217. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  218. package/src/sources/gloomberb-cloud/index.ts +1 -30
  219. package/src/sources/gloomberb-cloud/news.ts +0 -12
  220. package/src/sources/provider-router/batches.ts +11 -3
  221. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  222. package/src/sources/provider-router/financial-routes.ts +77 -5
  223. package/src/sources/provider-router/financials.test.ts +49 -0
  224. package/src/sources/provider-router/financials.ts +40 -3
  225. package/src/sources/provider-router/index.test.ts +314 -0
  226. package/src/sources/provider-router/primary.ts +17 -3
  227. package/src/sources/provider-router/search.ts +5 -0
  228. package/src/sources/provider-router/streaming.ts +6 -1
  229. package/src/sources/provider-router/supplemental.ts +4 -3
  230. package/src/sources/sec-edgar.test.ts +42 -0
  231. package/src/sources/sec-edgar.ts +1 -2
  232. package/src/sources/yahoo-finance/history.ts +5 -1
  233. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  234. package/src/sources/yahoo-finance/mappers.ts +2 -1
  235. package/src/sources/yahoo-finance/options.ts +59 -14
  236. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  237. package/src/sources/yahoo-finance/requests.ts +2 -1
  238. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  239. package/src/sources/yahoo-finance.test.ts +40 -21
  240. package/src/sources/yahoo-finance.ts +19 -10
  241. package/src/state/app/context/index.test.tsx +0 -81
  242. package/src/state/app/context/selector.test.tsx +6 -2
  243. package/src/state/hooks/quote-streaming.test.ts +14 -1
  244. package/src/state/hooks/quote-streaming.ts +5 -4
  245. package/src/sync/controller.test.ts +31 -0
  246. package/src/sync/controller.ts +300 -0
  247. package/src/sync/core-contributors.test.ts +251 -0
  248. package/src/sync/core-contributors.ts +400 -0
  249. package/src/sync/profile-analytics.ts +39 -0
  250. package/src/sync/react.ts +53 -0
  251. package/src/sync/types.ts +77 -0
  252. package/src/test-support/data-provider.ts +0 -1
  253. package/src/types/desktop-deeplink.ts +7 -0
  254. package/src/types/plugin.ts +3 -0
  255. package/src/ui/host.tsx +15 -1
  256. package/src/ui/index.tsx +2 -0
  257. package/src/updater.test.ts +0 -21
  258. package/src/updater.ts +20 -16
  259. package/src/utils/async-deadline.ts +26 -0
  260. package/src/utils/exchanges.test.ts +11 -1
  261. package/src/utils/exchanges.ts +5 -1
  262. package/src/utils/selection-clipboard.ts +2 -2
  263. package/src/version.ts +1 -1
  264. package/src/components/layout/pane/header.test.tsx +0 -39
  265. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  266. package/src/plugins/builtin/chat/index.tsx +0 -4
  267. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  268. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  269. package/src/plugins/builtin/help/index.test.tsx +0 -242
  270. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  271. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  272. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  273. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  274. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  275. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  276. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -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,
@@ -71,7 +69,6 @@ export function ChatTranscript({
71
69
  setHoveredIdx,
72
70
  showProfilePopover,
73
71
  stickyTranscript,
74
- user,
75
72
  userByUsername,
76
73
  }: ChatTranscriptProps) {
77
74
  return (
@@ -152,8 +149,6 @@ export function ChatTranscript({
152
149
  <UserProfilePopover
153
150
  user={profilePopoverUser}
154
151
  width={chatWidth}
155
- currentUserId={user?.id}
156
- onDirectMessage={openDirectMessage}
157
152
  onClose={scheduleProfilePopoverClose}
158
153
  onKeepOpen={cancelProfilePopoverClose}
159
154
  />
@@ -30,7 +30,6 @@ import {
30
30
  disposeChatControllerRuntime,
31
31
  resetChatControllerRuntime,
32
32
  } from "./lifecycle";
33
- import type { ChatSessionUser } from "./persistence";
34
33
  import {
35
34
  closeAllChannelConnections,
36
35
  closeInactiveChannelConnections,
@@ -69,15 +68,6 @@ export class ChatController {
69
68
  private notifyFn: (notification: AppNotificationRequest) => void = () => {};
70
69
  private notifiedMessageIds = new Set<string>();
71
70
 
72
- private get hydrated(): boolean { return this.session.hydrated; }
73
- private set hydrated(value: boolean) { this.session.hydrated = value; }
74
- private get sessionToken(): string | null { return this.session.sessionToken; }
75
- private set sessionToken(value: string | null) { this.session.sessionToken = value; }
76
- private get sessionChecked(): boolean { return this.session.sessionChecked; }
77
- private set sessionChecked(value: boolean) { this.session.sessionChecked = value; }
78
- private get user(): ChatSessionUser | null { return this.session.user; }
79
- private set user(value: ChatSessionUser | null) { this.session.user = value; }
80
-
81
71
  private readonly storage = new ChatControllerStorage({
82
72
  emit: (channelId) => this.emit(channelId),
83
73
  getSessionToken: () => this.session.sessionToken,
@@ -245,8 +245,8 @@ describe("ChatController", () => {
245
245
  const controller = new ChatController();
246
246
 
247
247
  apiClient.setSessionToken("token-123");
248
- (controller as any).sessionToken = "token-123";
249
- (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
248
+ (controller as any).session.sessionToken = "token-123";
249
+ (controller as any).session.user = { id: "u1", username: "vince", emailVerified: false };
250
250
 
251
251
  controller.setAppActive(false);
252
252
  (controller as any).realtime.syncVerificationPolling();
@@ -263,12 +263,12 @@ describe("ChatController", () => {
263
263
  let closed = false;
264
264
 
265
265
  apiClient.setSessionToken("token-123");
266
- (controller as any).sessionToken = "token-123";
267
- (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
266
+ (controller as any).session.sessionToken = "token-123";
267
+ (controller as any).session.user = { id: "u1", username: "vince", emailVerified: false };
268
268
  (controller as any).realtime.syncVerificationPolling();
269
269
  expect((controller as any).realtime.verificationPollTimer).not.toBeNull();
270
270
 
271
- (controller as any).user = { id: "u1", username: "vince", emailVerified: true };
271
+ (controller as any).session.user = { id: "u1", username: "vince", emailVerified: true };
272
272
  apiClient.connectChannel = () => ({
273
273
  send: async () => {
274
274
  throw new Error("not implemented");
@@ -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
+ });