gloomberb 0.17.2 → 0.17.4

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 (200) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
@@ -1,35 +1,67 @@
1
1
  import { useMemo, useRef, useState } from "react";
2
- import { Box, Text, useUiCapabilities, type BoxRenderable } from "../../../../ui";
2
+ import { Box, Span, Text, useUiCapabilities, type BoxRenderable } from "../../../../ui";
3
3
  import { TextAttributes } from "../../../../ui";
4
- import { ExternalLinkText } from "../../../../components/ui";
4
+ import { Divider, ExternalLinkText } from "../../../../components/ui";
5
5
  import { InlineTickerBadge } from "../../../../components/ticker/badge";
6
6
  import type { InlineTickerCatalogEntry } from "../../../../state/hooks/inline-tickers";
7
7
  import { blendHex, colors } from "../../../../theme/colors";
8
8
  import type { ChatUserSummary } from "../../../../api-client";
9
- import { tokenizeInlineContent, type InlineContentToken } from "../../../../utils/inline-content-tokenizer";
10
- import { chatBadgeTextWidth } from "../layout";
9
+ import { parseMarkdownLines, type MarkdownLine } from "../../../../utils/markdown";
10
+ import { chatBadgeTextWidth, type ChatBodyLine } from "../layout";
11
+ import { chatInlineTokens, type ChatInlineToken, type ChatTextStyle } from "../markdown";
11
12
  import type { ChatUserAnchor, ChatUserContextMenuEvent } from "./types";
12
13
 
14
+ interface ChatUserHandlers {
15
+ userByUsername?: Map<string, ChatUserSummary>;
16
+ onUserHover?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
17
+ onUserHoverEnd?: () => void;
18
+ onUserActivate?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
19
+ onUserContextMenu?: (user: ChatUserSummary, event: ChatUserContextMenuEvent, anchor?: ChatUserAnchor) => void;
20
+ }
21
+
22
+ interface ChatTextProps extends ChatUserHandlers {
23
+ catalog: Record<string, InlineTickerCatalogEntry>;
24
+ textColor: string;
25
+ /** Code, list markers, a quote's bar and a link's host. */
26
+ mutedColor: string;
27
+ openTicker: (symbol: string) => void;
28
+ }
29
+
30
+ /** Desktop and web: a run of text inside a line, which wraps with the line. */
31
+ const INLINE_TEXT_STYLE = { display: "inline", whiteSpace: "pre-wrap", overflowWrap: "anywhere", minWidth: 0 } as const;
32
+ /** Desktop and web: a chip that sits in a line of text, on the line's own row. */
33
+ const INLINE_CHIP_STYLE = { display: "inline-flex", verticalAlign: "top", height: "var(--cell-h)" } as const;
34
+
35
+ function styleAttributes(style: ChatTextStyle | undefined): number {
36
+ if (!style) return 0;
37
+ return (style.bold ? TextAttributes.BOLD : 0)
38
+ | (style.italic ? TextAttributes.ITALIC : 0)
39
+ | (style.strike ? TextAttributes.STRIKETHROUGH : 0);
40
+ }
41
+
42
+ function styleColor(style: ChatTextStyle | undefined, textColor: string, mutedColor: string): string {
43
+ return style?.code || style?.muted ? mutedColor : textColor;
44
+ }
45
+
13
46
  /** An @mention: a name that opens its user's card, when the chat knows the user. */
14
47
  function UserMentionToken({
15
48
  value,
16
49
  user,
50
+ inline,
17
51
  nativeContextMenu,
18
52
  onUserHover,
19
53
  onUserHoverEnd,
20
54
  onUserActivate,
21
55
  onUserContextMenu,
22
- }: {
56
+ }: Omit<ChatUserHandlers, "userByUsername"> & {
23
57
  value: string;
24
58
  user: ChatUserSummary | null;
59
+ inline: boolean;
25
60
  nativeContextMenu: boolean;
26
- onUserHover?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
27
- onUserHoverEnd?: () => void;
28
- onUserActivate?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
29
- onUserContextMenu?: (user: ChatUserSummary, event: ChatUserContextMenuEvent, anchor?: ChatUserAnchor) => void;
30
61
  }) {
31
62
  // The card opens beside the mention the pointer is on.
32
63
  const tokenRef = useRef<BoxRenderable | null>(null);
64
+ const pointer = user && onUserActivate ? { cursor: "pointer" } : undefined;
33
65
  return (
34
66
  <Box
35
67
  ref={tokenRef}
@@ -53,7 +85,7 @@ function UserMentionToken({
53
85
  "data-gloom-context-menu-surface": "true",
54
86
  onContextMenu: (event: ChatUserContextMenuEvent) => onUserContextMenu(user, event, tokenRef.current),
55
87
  } : {})}
56
- style={user && onUserActivate ? { cursor: "pointer" } : undefined}
88
+ style={inline ? { ...INLINE_CHIP_STYLE, ...pointer } : pointer}
57
89
  >
58
90
  <Text fg={colors.positive} attributes={TextAttributes.BOLD}>
59
91
  {value}
@@ -62,113 +94,198 @@ function UserMentionToken({
62
94
  );
63
95
  }
64
96
 
65
- export function ResponsiveTickerBadgeText({
66
- text = "",
67
- tokens: providedTokens,
68
- prewrapped = false,
97
+ /**
98
+ * The tokens of a message line: styled text, links, @mentions and $TICKER
99
+ * chips. `inline` draws them for a line of text on the desktop and the web;
100
+ * otherwise each is a cell of a terminal row.
101
+ */
102
+ function ChatTokens({
103
+ tokens,
104
+ keyPrefix,
105
+ inline,
106
+ prewrapped,
69
107
  catalog,
70
108
  textColor,
109
+ mutedColor,
71
110
  openTicker,
111
+ hoveredSymbol,
112
+ setHoveredSymbol,
72
113
  userByUsername,
73
- onUserHover,
74
- onUserHoverEnd,
75
- onUserActivate,
76
- onUserContextMenu,
77
- }: {
78
- text?: string;
79
- tokens?: readonly InlineContentToken[];
80
- prewrapped?: boolean;
81
- catalog: Record<string, InlineTickerCatalogEntry>;
82
- textColor: string;
83
- openTicker: (symbol: string) => void;
84
- userByUsername?: Map<string, ChatUserSummary>;
85
- onUserHover?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
86
- onUserHoverEnd?: () => void;
87
- onUserActivate?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
88
- onUserContextMenu?: (user: ChatUserSummary, event: ChatUserContextMenuEvent, anchor?: ChatUserAnchor) => void;
114
+ ...userHandlers
115
+ }: ChatTextProps & {
116
+ tokens: readonly ChatInlineToken[];
117
+ keyPrefix: string;
118
+ inline: boolean;
119
+ /** Terminal rows wrapped ahead of time, whose chips keep the width reserved for them. */
120
+ prewrapped: boolean;
121
+ hoveredSymbol: string | null;
122
+ setHoveredSymbol: (update: (current: string | null) => string | null) => void;
89
123
  }) {
90
124
  const { nativeContextMenu } = useUiCapabilities();
91
- const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
92
- const tokens = useMemo(() => providedTokens ?? tokenizeInlineContent(text), [providedTokens, text]);
93
- const renderTextToken = (value: string, tokenIndex: number) => {
94
- if (!value) return null;
95
- return (
96
- <Text
97
- key={`text:${tokenIndex}`}
98
- fg={textColor}
99
- wrapText={!prewrapped}
100
- style={prewrapped
101
- ? undefined
102
- : {
103
- minWidth: 0,
104
- whiteSpace: "pre-wrap",
105
- overflowWrap: "anywhere",
106
- }}
107
- >
108
- {value}
109
- </Text>
110
- );
111
- };
112
- const renderUsernameToken = (username: string, value: string, tokenIndex: number) => (
113
- <UserMentionToken
114
- key={`mention:${tokenIndex}:${username}`}
115
- value={value}
116
- user={userByUsername?.get(username.toLowerCase()) ?? null}
117
- nativeContextMenu={nativeContextMenu === true}
118
- onUserHover={onUserHover}
119
- onUserHoverEnd={onUserHoverEnd}
120
- onUserActivate={onUserActivate}
121
- onUserContextMenu={onUserContextMenu}
122
- />
123
- );
124
-
125
125
  return (
126
- <Box
127
- flexDirection="row"
128
- flexWrap="wrap"
129
- flexGrow={1}
130
- style={{ minWidth: 0, width: "100%" }}
131
- >
126
+ <>
132
127
  {tokens.map((token, index) => {
128
+ const key = `${keyPrefix}:${index}`;
133
129
  if (token.kind === "text") {
134
- return renderTextToken(token.value, index);
130
+ if (!token.value) return null;
131
+ const fg = styleColor(token.style, textColor, mutedColor);
132
+ const attributes = styleAttributes(token.style) || undefined;
133
+ return inline
134
+ ? <Span key={key} fg={fg} attributes={attributes} style={INLINE_TEXT_STYLE}>{token.value}</Span>
135
+ : <Text key={key} fg={fg} attributes={attributes}>{token.value}</Text>;
135
136
  }
136
137
 
137
138
  if (token.kind === "link") {
138
139
  return (
139
140
  <ExternalLinkText
140
- key={`link:${index}`}
141
+ key={key}
141
142
  url={token.url}
142
143
  label={token.value}
143
144
  color={textColor}
145
+ attributes={styleAttributes(token.style)}
146
+ inline={inline}
144
147
  />
145
148
  );
146
149
  }
147
150
 
148
151
  if (token.kind === "username") {
149
- return renderUsernameToken(token.username, token.value, index);
152
+ return (
153
+ <UserMentionToken
154
+ key={key}
155
+ value={token.value}
156
+ user={userByUsername?.get(token.username.toLowerCase()) ?? null}
157
+ inline={inline}
158
+ nativeContextMenu={nativeContextMenu === true}
159
+ {...userHandlers}
160
+ />
161
+ );
150
162
  }
151
163
 
152
164
  const entry = catalog[token.symbol];
153
165
  if (!entry || entry.status === "missing") {
154
- return <Text key={`raw:${index}`} fg={textColor}>{token.value}</Text>;
166
+ const fg = styleColor(token.style, textColor, mutedColor);
167
+ const attributes = styleAttributes(token.style) || undefined;
168
+ return inline
169
+ ? <Span key={key} fg={fg} attributes={attributes} style={INLINE_TEXT_STYLE}>{token.value}</Span>
170
+ : <Text key={key} fg={fg} attributes={attributes}>{token.value}</Text>;
155
171
  }
156
- // Pre-wrapped lines reserved this width for the chip; it never outgrows it.
157
- const maxTextWidth = prewrapped ? chatBadgeTextWidth(token.symbol, entry) : undefined;
158
- return (
172
+ const badge = (
159
173
  <InlineTickerBadge
160
- key={`badge:${index}:${token.symbol}`}
174
+ key={key}
161
175
  symbol={token.symbol}
162
176
  entry={entry}
163
- maxTextWidth={maxTextWidth}
177
+ // Pre-wrapped lines reserved this width for the chip; it never outgrows it.
178
+ maxTextWidth={prewrapped ? chatBadgeTextWidth(token.symbol, entry) : undefined}
164
179
  hovered={hoveredSymbol === token.symbol}
165
- onHoverStart={() => setHoveredSymbol(token.symbol)}
180
+ onHoverStart={() => setHoveredSymbol(() => token.symbol)}
166
181
  onHoverEnd={() => {
167
182
  setHoveredSymbol((current) => (current === token.symbol ? null : current));
168
183
  }}
169
184
  onOpen={openTicker}
170
185
  />
171
186
  );
187
+ return inline ? <Box key={key} style={INLINE_CHIP_STYLE}>{badge}</Box> : badge;
188
+ })}
189
+ </>
190
+ );
191
+ }
192
+
193
+ /** One row of a message in the terminal, wrapped ahead of time (getMessageBodyTokenLines). */
194
+ export function TerminalChatMessageRow({ line, ...props }: ChatTextProps & { line: ChatBodyLine }) {
195
+ const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
196
+ return (
197
+ <Box flexDirection="row" flexWrap="wrap" flexGrow={1} style={{ minWidth: 0, width: "100%" }}>
198
+ <ChatTokens
199
+ {...props}
200
+ tokens={line}
201
+ keyPrefix="row"
202
+ inline={false}
203
+ prewrapped
204
+ hoveredSymbol={hoveredSymbol}
205
+ setHoveredSymbol={setHoveredSymbol}
206
+ />
207
+ </Box>
208
+ );
209
+ }
210
+
211
+ /** A line of text that wraps at the width of its box, a list item's later lines hung under its text. */
212
+ function lineStyle(hang = 0) {
213
+ return {
214
+ display: "block",
215
+ minWidth: 0,
216
+ whiteSpace: "pre-wrap",
217
+ overflowWrap: "anywhere",
218
+ ...(hang > 0 ? { paddingLeft: `${hang}ch`, textIndent: `-${hang}ch` } : {}),
219
+ } as const;
220
+ }
221
+
222
+ type DesktopBlock = MarkdownLine | { kind: "code-block"; lines: string[] };
223
+
224
+ /** A code block's lines as one block, so it selects and copies as one. */
225
+ function desktopBlocks(text: string): DesktopBlock[] {
226
+ const blocks: DesktopBlock[] = [];
227
+ for (const line of parseMarkdownLines(text)) {
228
+ const previous = blocks[blocks.length - 1];
229
+ if (line.kind === "code" && previous?.kind === "code-block") previous.lines.push(line.text);
230
+ else blocks.push(line.kind === "code" ? { kind: "code-block", lines: [line.text] } : line);
231
+ }
232
+ return blocks;
233
+ }
234
+
235
+ /**
236
+ * A message's text on the desktop and the web: its markdown drawn as lines of
237
+ * running text that the browser wraps at the width of the row, so a resize
238
+ * reflows it smoothly instead of a cell at a time.
239
+ */
240
+ export function DesktopChatMessageText({ text, ...props }: ChatTextProps & { text: string }) {
241
+ const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
242
+ const blocks = useMemo(() => desktopBlocks(text), [text]);
243
+ const { textColor, mutedColor } = props;
244
+ const tokens = (lineText: string, keyPrefix: string, mark?: ChatTextStyle) => (
245
+ <ChatTokens
246
+ {...props}
247
+ tokens={mark ? chatInlineTokens(lineText).map((token) => ({ ...token, style: { ...token.style, ...mark } })) : chatInlineTokens(lineText)}
248
+ keyPrefix={keyPrefix}
249
+ inline
250
+ prewrapped={false}
251
+ hoveredSymbol={hoveredSymbol}
252
+ setHoveredSymbol={setHoveredSymbol}
253
+ />
254
+ );
255
+
256
+ return (
257
+ <Box flexDirection="column" style={{ minWidth: 0, width: "100%" }}>
258
+ {blocks.map((block, index) => {
259
+ const key = `${block.kind}:${index}`;
260
+ switch (block.kind) {
261
+ case "blank":
262
+ return <Text key={key} fg={textColor}>{" "}</Text>;
263
+ case "code-block":
264
+ return <Text key={key} fg={mutedColor} style={lineStyle()}>{block.lines.join("\n")}</Text>;
265
+ case "heading":
266
+ return <Text key={key} fg={textColor} style={lineStyle()}>{tokens(block.text, key, { bold: true })}</Text>;
267
+ case "quote":
268
+ // The bar is the kit's rule, a cell wide as the terminal's.
269
+ return (
270
+ <Box key={key} flexDirection="row" style={{ minWidth: 0 }}>
271
+ <Divider orientation="vertical" />
272
+ <Box paddingLeft={1} flexGrow={1} style={{ minWidth: 0 }}>
273
+ <Text fg={textColor} style={lineStyle()}>{tokens(block.text, key)}</Text>
274
+ </Box>
275
+ </Box>
276
+ );
277
+ case "list": {
278
+ const marker = `${" ".repeat(block.indent)}${/^\d/.test(block.marker) ? block.marker : "\u2022"} `;
279
+ return (
280
+ <Text key={key} fg={textColor} style={lineStyle(marker.length)}>
281
+ <Span fg={mutedColor} style={INLINE_TEXT_STYLE}>{marker}</Span>
282
+ {tokens(block.text, key)}
283
+ </Text>
284
+ );
285
+ }
286
+ case "text":
287
+ return <Text key={key} fg={textColor} style={lineStyle()}>{tokens(block.text, key)}</Text>;
288
+ }
172
289
  })}
173
290
  </Box>
174
291
  );
@@ -44,6 +44,8 @@ export interface ChatMessageRenderState {
44
44
  authorColor: string;
45
45
  authorAttributes: number;
46
46
  bodyColor: string;
47
+ /** Code, list markers and a quote's bar in the body. */
48
+ bodyMutedColor: string;
47
49
  }
48
50
 
49
51
  export function getChatMessageRenderState({
@@ -117,5 +119,6 @@ export function getChatMessageRenderState({
117
119
  authorColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : chatAuthorNameColor(msg.user),
118
120
  authorAttributes: (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD,
119
121
  bodyColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : isSending ? colors.textDim : colors.text,
122
+ bodyMutedColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : colors.textDim,
120
123
  };
121
124
  }
@@ -10,7 +10,7 @@ import { ChatMessageActions, ChatMessageHeader } from "./header";
10
10
  import { Button } from "../../../../components/ui";
11
11
  import { canReactToMessage } from "../reactions";
12
12
  import { TerminalChatReactions } from "./reactions";
13
- import { ResponsiveTickerBadgeText } from "./inline-tokens";
13
+ import { TerminalChatMessageRow } from "./inline-tokens";
14
14
  import { TerminalChatPaneChip } from "./pane-chip";
15
15
  import { chatMessagePaneLinks } from "../pane-links/model";
16
16
  import { useSlowPendingSend } from "./pending-send";
@@ -149,11 +149,11 @@ export function TerminalChatMessage({
149
149
  position={state.grouped ? "relative" : undefined}
150
150
  >
151
151
  <Box width={messageBodyWidth} height={1}>
152
- <ResponsiveTickerBadgeText
153
- tokens={line}
154
- prewrapped
152
+ <TerminalChatMessageRow
153
+ line={line}
155
154
  catalog={catalog}
156
155
  textColor={state.bodyColor}
156
+ mutedColor={state.bodyMutedColor}
157
157
  openTicker={openTicker}
158
158
  userByUsername={userByUsername}
159
159
  onUserHover={onUserHover}
@@ -1,4 +1,4 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useRef } from "react";
2
2
  import { useFxRatesMap } from "../../../../market-data/hooks";
3
3
  import { buildPortfolioFinancialsMap } from "../../../../market-data/portfolio-financials";
4
4
  import { useAppSelector, usePaneAppConfig } from "../../../../state/app/context";
@@ -84,6 +84,7 @@ export function useBookExposure(collectionId: string | null): BookExposure {
84
84
  [baseCurrency, financials, tickers],
85
85
  );
86
86
  const exchangeRates = useFxRatesMap(trackedCurrencies);
87
+ const rankedRef = useRef<BookExposure | null>(null);
87
88
  return useMemo(() => {
88
89
  const priced = scope.kind !== "watchlist";
89
90
  const totals = priced
@@ -110,7 +111,20 @@ export function useBookExposure(collectionId: string | null): BookExposure {
110
111
  : 0;
111
112
  bySymbol.set(symbol, { symbol, value, optionNotional, hasOptions: options });
112
113
  }
113
- return {
114
+ const previous = rankedRef.current;
115
+ const visibleUnchanged = previous != null
116
+ && previous.scope === scope
117
+ && previous.baseCurrency === baseCurrency
118
+ && previous.tickers === tickers
119
+ && previous.scopes === scopes
120
+ && previous.bookValue === (totals?.totalMktValue ?? 0)
121
+ && previous.bySymbol.size === bySymbol.size
122
+ && [...bySymbol].every(([symbol, exposure]) => {
123
+ const prior = previous.bySymbol.get(symbol);
124
+ return prior != null && prior.value === exposure.value && prior.optionNotional === exposure.optionNotional && prior.hasOptions === exposure.hasOptions;
125
+ });
126
+ if (visibleUnchanged) return previous;
127
+ const ranked: BookExposure = {
114
128
  scope,
115
129
  bySymbol,
116
130
  bookValue: totals?.totalMktValue ?? 0,
@@ -122,5 +136,7 @@ export function useBookExposure(collectionId: string | null): BookExposure {
122
136
  },
123
137
  scopes,
124
138
  };
139
+ rankedRef.current = ranked;
140
+ return ranked;
125
141
  }, [baseCurrency, exchangeRates, financials, portfolioId, scope, scopes, tickers, tickersBySymbol]);
126
142
  }
@@ -3,7 +3,7 @@ import type { DataTableColumn } from "../../../components";
3
3
  import type { AppAttentionPayload, AppChart } from "../../../api-client/app-attention";
4
4
  import type { HiringBoard, HiringPayload } from "../../../api-client/hiring";
5
5
  import { fetchAppRank, fetchAttention } from "./client";
6
- import { appsModel, attentionNumber, hiringModel, remoteText, type AttentionKind } from "./model";
6
+ import { appsModel, appsPreviewNote, attentionNumber, hiringModel, hiringPreviewNote, remoteText, type AttentionKind } from "./model";
7
7
 
8
8
  /**
9
9
  * Numbers print as the pane draws them: shares to a tenth, ratings and
@@ -50,11 +50,14 @@ export function attentionHeadless(kind: AttentionKind): HeadlessPaneDefinition<"
50
50
  const data = await fetchAttention(kind, args.symbols[0], { limit: Number(args.options.limit) || 100, offset: Number(args.options.offset) || 0, ...(typeof args.options.country === "string" ? { country: args.options.country } : {}), ...(typeof args.options.chart === "string" ? { chart: args.options.chart as AppChart } : {}), days: Number(args.options.days) || 90 }, ctx.apiClient);
51
51
  const model = kind === "hiring" ? hiringModel(data as HiringPayload | HiringBoard) : appsModel(data as AppAttentionPayload);
52
52
  const offset = Number(args.options.offset) || 0;
53
- const nextOffset = kind === "hiring" && "total" in data ? offset + data.companies.length < data.total ? offset + data.companies.length : null : (data as AppAttentionPayload).page?.nextOffset ?? null;
53
+ // The free preview ignores the offset, so it has no next page to point at.
54
+ const nextOffset = kind === "hiring" && "total" in data ? !data.preview && offset + data.companies.length < data.total ? offset + data.companies.length : null : (data as AppAttentionPayload).page?.nextOffset ?? null;
55
+ const lock = kind === "hiring" ? hiringPreviewNote(data as HiringPayload | HiringBoard) : appsPreviewNote(data as AppAttentionPayload);
56
+ const notes = [...model.notices, ...(nextOffset !== null ? [`Additional observations available with --offset ${nextOffset}.`] : [])];
54
57
  return {
55
- complete: !model.preview && nextOffset === null,
56
- errors: [...model.notices, ...(model.preview ? ["Additional rows and history require Gloom Pro."] : [])],
57
- ...(nextOffset !== null ? { notes: [`Additional observations available with --offset ${nextOffset}.`] } : {}),
58
+ complete: !model.preview && !model.partial && nextOffset === null,
59
+ ...(lock ? { errors: [lock] } : {}),
60
+ ...(notes.length ? { notes } : {}),
58
61
  sections: Object.entries(model.sections).map(([key, section]) => ({ title: key === "mix" ? kind === "hiring" ? "Role family" : "Countries" : key,
59
62
  columns: section.columns.map(reportColumn), empty: section.empty,
60
63
  rows: section.rows.map((row) => ({ ...row.values, ...(row.url ? { sourceUrl: row.url } : {}), ...(row.details ? { evidence: row.details } : {}) })) })),
@@ -4,6 +4,7 @@ import type { HiringBoard, HiringPayload, HiringSummary } from "../../../api-cli
4
4
  import type { AppAttentionPayload, AppCompany } from "../../../api-client/app-attention";
5
5
  import { humanLabel } from "../shared/research-cells";
6
6
  import { formatNumber } from "../../../utils/format";
7
+ import { countOf, freePreviewNote } from "../shared/free-preview";
7
8
 
8
9
  export type AttentionKind = "hiring" | "apps";
9
10
  export type AttentionTab = "table" | "chart" | "mix" | "peers" | "evidence";
@@ -26,7 +27,10 @@ export interface AttentionModel {
26
27
  asOf: string | null;
27
28
  preview: boolean;
28
29
  locked: number;
30
+ /** Caveats about the capture, such as incomplete coverage; they never fail a report. */
29
31
  notices: string[];
32
+ /** The latest capture is incomplete or stale, so a report of it is not the whole picture even on Pro. */
33
+ partial: boolean;
30
34
  figures: StatItem[];
31
35
  sections: Record<AttentionTab, AttentionSection>;
32
36
  /** `since`: the first capture, so a chart still collecting says when it began. */
@@ -57,6 +61,51 @@ const HISTORY_COLUMNS = [column("date", "Week", 12), column("open", "Open roles"
57
61
  /** `remoteKnown` rides along for the remote cell; it is not a column of its own. */
58
62
  const companyRow = (row: HiringSummary): AttentionRow => ({ id: row.symbol, symbol: row.symbol, values: { symbol: row.symbol, name: row.name, open: row.latest?.openCount ?? null, net: row.latest?.netChange ?? null, remote: row.latest?.remoteShare == null ? null : row.latest.remoteShare * 100, remoteKnown: row.latest?.remoteKnown ?? null, z: row.zScore, signal: humanLabel(row.signal ?? row.status), date: row.latest?.observedAt.slice(0, 10) ?? null } });
59
63
 
64
+ /** The weeks of history a free preview holds back; the board has none to hold. */
65
+ const hiringHistoryLocked = (data: HiringBoard | HiringPayload) => !data.preview ? 0 : typeof data.locked === "number" ? data.locked : data.locked.history;
66
+
67
+ /** "a, b and c". */
68
+ const joinList = (items: readonly string[]) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
69
+ const grouped = (value: number) => value.toLocaleString("en-US");
70
+
71
+ /**
72
+ * The lock line of a free HIRE preview: how many of its rows free shows, what
73
+ * Pro adds and when its weekly history starts. Null when the preview holds
74
+ * nothing back (a company with no captures has nothing to unlock).
75
+ */
76
+ export function hiringPreviewNote(data: HiringBoard | HiringPayload): string | null {
77
+ if (!data.preview) return null;
78
+ if ("companies" in data) {
79
+ if (data.locked <= 0) return null;
80
+ return freePreviewNote({ shows: `${data.companies.length} of ${grouped(data.total)} companies`, adds: `the other ${grouped(data.locked)}` });
81
+ }
82
+ const { evidence, history, peers } = data.locked;
83
+ if (evidence + history + peers <= 0) return null;
84
+ const roles = data.evidence.length;
85
+ return freePreviewNote({
86
+ shows: evidence > 0 ? `${roles} of ${grouped(roles + evidence)} open roles` : countOf(roles, "open role"),
87
+ adds: joinList([evidence > 0 ? `the other ${grouped(evidence)} roles` : "", history > 0 ? `${grouped(history)} ${history === 1 ? "week" : "weeks"} of history` : "", peers > 0 ? `${grouped(peers)} more peers` : ""].filter(Boolean)),
88
+ historyFrom: history > 0 ? data.coverage.firstObservedAt : null,
89
+ });
90
+ }
91
+
92
+ /**
93
+ * The lock line of a free APPS preview: how many app ranks free shows, what
94
+ * Pro adds and when its daily history starts. Null when nothing is held back.
95
+ */
96
+ export function appsPreviewNote(data: AppAttentionPayload): string | null {
97
+ if (data.access !== "preview") return null;
98
+ const { apps, history } = data.locked;
99
+ if (apps + history <= 0) return null;
100
+ const ranks = data.apps.length;
101
+ const days = data.history.length;
102
+ return freePreviewNote({
103
+ shows: `${apps > 0 ? `${ranks} of ${grouped(ranks + apps)} app ranks` : countOf(ranks, "app rank")}${days ? " and the latest day of history" : ""}`,
104
+ adds: joinList([apps > 0 ? `the other ${grouped(apps)} ranks` : "", history > 0 ? `${grouped(history)} earlier ${history === 1 ? "day" : "days"} of history` : ""].filter(Boolean)),
105
+ historyFrom: history > 0 ? data.summary.firstObservedAt : null,
106
+ });
107
+ }
108
+
60
109
  export function hiringModel(data: HiringBoard | HiringPayload, mix = "functions"): AttentionModel {
61
110
  const detail = "series" in data ? data : null;
62
111
  const companies = "companies" in data ? data.companies : [data];
@@ -71,6 +120,7 @@ export function hiringModel(data: HiringBoard | HiringPayload, mix = "functions"
71
120
  locked: typeof data.locked === "number" ? data.locked : Object.values(data.locked).reduce((a, b) => a + b, 0),
72
121
  // How counts compare is methodology (docs/hiring-app-attention.md); the footer keeps only what is wrong with this capture.
73
122
  notices: detail ? [...(latest?.completeness !== "complete" ? ["The latest capture has incomplete or unknown coverage."] : []), ...(detail.status === "stale" ? ["The latest hiring capture is stale."] : [])] : [],
123
+ partial: !!detail && (latest?.completeness !== "complete" || detail.status === "stale"),
74
124
  // A figure the first capture cannot give yet (a weekly change, a z-score) is left out, not shown as a dash.
75
125
  figures: latest ? [
76
126
  { label: "Open roles", value: count(latest.openCount) },
@@ -82,7 +132,7 @@ export function hiringModel(data: HiringBoard | HiringPayload, mix = "functions"
82
132
  ] : [],
83
133
  sections: {
84
134
  table: { columns: COMPANY_COLUMNS, rows: companies.map(companyRow), empty: "Hiring observations are not available yet." },
85
- chart: { columns: HISTORY_COLUMNS, rows: [...history].reverse().map((row): AttentionRow => ({ id: row.week, date: row.week, values: { date: row.week, open: row.openCount, added: row.added, removed: row.removed, net: row.netChange, change: row.changePct, remote: row.remoteShare == null ? null : row.remoteShare * 100, remoteKnown: row.remoteKnown, coverage: row.completeness }, details: [{ label: "Observed", value: row.observedAt }, { label: "Confidence", value: percent(row.confidence) }, { label: "Capture", value: row.kind }, { label: "Evergreen roles", value: count(row.evergreenCount) }] })), empty: data.preview ? "Full hiring history requires Pro." : "History is accumulating from the first capture." },
135
+ chart: { columns: HISTORY_COLUMNS, rows: [...history].reverse().map((row): AttentionRow => ({ id: row.week, date: row.week, values: { date: row.week, open: row.openCount, added: row.added, removed: row.removed, net: row.netChange, change: row.changePct, remote: row.remoteShare == null ? null : row.remoteShare * 100, remoteKnown: row.remoteKnown, coverage: row.completeness }, details: [{ label: "Observed", value: row.observedAt }, { label: "Confidence", value: percent(row.confidence) }, { label: "Capture", value: row.kind }, { label: "Evergreen roles", value: count(row.evergreenCount) }] })), empty: hiringHistoryLocked(data) ? "Weekly history is part of Gloom Pro." : "History is accumulating from the first capture." },
86
136
  mix: { columns: [column("label", mixKey === "signals" ? "Signal" : "Group", 28, false, true), column("count", mixKey === "signals" ? "Value" : "Roles", 12, true), column("share", mixKey === "signals" ? "Confidence %" : "Share %", mixKey === "signals" ? 14 : 30, true), ...(mixKey === "signals" ? [column("date", "Observed", 12)] : [])], rows, empty: "No classified observations for this group yet." },
87
137
  peers: { columns: COMPANY_COLUMNS, rows: (detail?.peers ?? []).map(companyRow), empty: "No comparable mapped peers yet." },
88
138
  evidence: { columns: [column("title", "Role", 32, false, true), column("location", "Location", 24), column("function", "Function", 18), column("seniority", "Seniority", 13), column("date", "Observed", 12), column("confidence", "Confidence %", 13, true)], rows: (detail?.evidence ?? []).map((row): AttentionRow => ({ id: `${row.snapshotId}:${row.id}`, url: row.url ?? row.sourceUrl, values: { title: row.title, location: row.location, function: roleFamily(row.jobFunction), seniority: row.seniority ? humanLabel(row.seniority) : null, date: row.observedAt.slice(0, 10), confidence: row.confidence * 100 }, details: [{ label: "Posting title", value: row.title }, { label: "Location", value: row.location ?? "--" }, { label: "Country", value: row.country ?? "Unknown" }, { label: "Function", value: row.jobFunction ?? "Unclassified" }, { label: "Seniority", value: row.seniority ?? "Unclassified" }, { label: "Remote", value: row.remote === null ? "Unknown" : row.remote ? "Yes" : "No" }, { label: "Observed", value: row.observedAt }, { label: "Revision", value: String(row.revision) }, { label: "Confidence", value: percent(row.confidence) }, { label: "Reporting ticker", value: row.sourceTicker }] })), empty: "No primary posting evidence is available yet." },
@@ -127,6 +177,7 @@ export function appsModel(data: AppAttentionPayload, mix = "countries"): Attenti
127
177
  preview: data.access === "preview",
128
178
  locked: Object.values(data.locked).reduce((a, b) => a + b, 0),
129
179
  notices: [...data.coverage.limitations, ...(data.apps.some((row) => row.stale) ? ["Some app observations are stale."] : [])],
180
+ partial: data.apps.some((row) => row.stale),
130
181
  // Seven-day figures appear once a week of captures can give them.
131
182
  figures: [
132
183
  { label: "Attention score", value: data.summary.attentionScore?.toFixed(1) ?? "--", detail: "/ 100" },
@@ -137,7 +188,7 @@ export function appsModel(data: AppAttentionPayload, mix = "countries"): Attenti
137
188
  ],
138
189
  sections: {
139
190
  table: { columns: APP_COLUMNS, rows: data.apps.map((row): AttentionRow => ({ id: `${row.sourceId}:${row.appId}`, app: { store: row.store, appId: row.appId, name: row.name, country: row.country, chart: row.chart }, symbol: row.symbol ?? undefined, url: row.appUrl, values: { name: row.name, symbol: row.symbol, country: row.country, chart: row.chart, rank: row.rank, change: row.rankChange7d, rating: row.rating, ratings: row.ratingCount, drift: row.ratingChange7d, growth: row.ratingCountGrowth7d }, details: [{ label: "App", value: row.name }, { label: "Developer", value: row.developer }, { label: "Parent ticker", value: row.symbol ?? "Unmapped" }, { label: "Genres", value: row.genres.join(", ") || "--" }, { label: "Observed", value: row.observedAt }, { label: "Ratings observed", value: row.ratingObservedAt ?? "Unavailable" }, { label: "Mapping confidence", value: percent(row.confidence) }, { label: "Mapping revision", value: row.mappingRevisionId ?? "Unmapped" }, { label: "Capture revision", value: row.revisionId }] })), empty: "No app observations match these filters yet." },
140
- chart: { columns: [column("date", "Date", 12), column("score", "Score / 100", 14, true), column("change", "Change", 10, true), column("apps", "Apps", 9, true), column("countries", "Countries", 12, true), column("rating", "Rating / 5", 12, true), column("ratings", "Ratings", 13, true)], rows: data.history.map((point, i, all): AttentionRow => ({ id: point.date, date: point.date, values: { date: point.date, score: point.attentionScore, change: point.attentionScore != null && all[i - 1]?.attentionScore != null ? point.attentionScore - all[i - 1]!.attentionScore! : null, apps: point.appCount, countries: point.countryCount, rating: point.rating, ratings: point.ratingCount } })).reverse(), empty: data.access === "preview" ? "Full app history requires Pro." : "History is accumulating from the first capture." },
191
+ chart: { columns: [column("date", "Date", 12), column("score", "Score / 100", 14, true), column("change", "Change", 10, true), column("apps", "Apps", 9, true), column("countries", "Countries", 12, true), column("rating", "Rating / 5", 12, true), column("ratings", "Ratings", 13, true)], rows: data.history.map((point, i, all): AttentionRow => ({ id: point.date, date: point.date, values: { date: point.date, score: point.attentionScore, change: point.attentionScore != null && all[i - 1]?.attentionScore != null ? point.attentionScore - all[i - 1]!.attentionScore! : null, apps: point.appCount, countries: point.countryCount, rating: point.rating, ratings: point.ratingCount } })).reverse(), empty: data.access === "preview" && data.locked.history > 0 ? "Daily history is part of Gloom Pro." : "History is accumulating from the first capture." },
141
192
  mix: mix === "spreads" ? { columns: [column("name", "App", 28, false, true), column("chart", "Chart", 10), column("best", "Best country", 15), column("bestRank", "Best rank", 11, true), column("worst", "Worst country", 15), column("worstRank", "Worst rank", 12, true), column("spread", "Rank spread", 13, true)], rows: data.spreads.map((row): AttentionRow => ({ id: `${row.appId}:${row.chart}:${row.category}`, values: { name: row.name, chart: row.chart, best: row.bestCountry, bestRank: row.bestRank, worst: row.worstCountry, worstRank: row.worstRank, spread: row.spread } })), empty: "Comparable country observations are accumulating." }
142
193
  : { columns: [column("country", "Country", 16, false, true), column("apps", "Apps", 9, true), column("ranked", "Ranked apps", 13, true), column("score", "Score / 100", 14, true), column("velocity", "Places / day", 15, true), column("date", "Observed", 12)], rows: data.countries.map((row): AttentionRow => ({ id: row.country, values: { country: row.country, apps: row.appCount, ranked: row.rankedApps, score: row.attentionScore, velocity: row.rankVelocity7d, date: row.observedAt.slice(0, 10) } })), empty: "Country observations are not available yet." },
143
194
  peers: { columns: APP_COMPANIES, rows: (data.symbol ? data.peers : data.companies).map(appCompanyRow), empty: "No comparable mapped companies yet." },
@@ -152,7 +152,7 @@ function AttentionView({ kind, symbol, width, height, focused }: Size & { kind:
152
152
  {strip}
153
153
  <PaneStatusBody loading={!data && resource.loading} error={!data ? resource.error : null} subject={kind === "hiring" ? "hiring momentum" : "app attention"}>
154
154
  {model && section ? <PageStackView focused={focused} detailOpen={detailOpen} onBack={() => { setOpenCompany(null); setOpenEvidence(null); setOpenApp(null); }} detailTitle={openCompany ?? app?.name ?? (evidence ? String(evidence.values.title ?? evidence.values.name ?? evidence.id) : undefined)}
155
- detailContent={openCompany ? <PaneFooterScope active><AttentionView key={openCompany} kind={kind} symbol={openCompany} width={width} height={bodyHeight - 1} focused={focused} /></PaneFooterScope> : app ? <AppRankView focus={app} accessKey={accessKey} width={width} height={bodyHeight - 1} focused={focused} /> : evidence ? <Evidence row={evidence} width={width} height={bodyHeight - 1} focused={focused} /> : null}
155
+ detailContent={openCompany ? <PaneFooterScope active><AttentionView key={openCompany} kind={kind} symbol={openCompany} width={width} height={bodyHeight - 1} focused={focused} /></PaneFooterScope> : app ? <PaneFooterScope active><AppRankView focus={app} accessKey={accessKey} width={width} height={bodyHeight - 1} focused={focused} /></PaneFooterScope> : evidence ? <Evidence row={evidence} width={width} height={bodyHeight - 1} focused={focused} /> : null}
156
156
  rootContent={<DataTableView columns={columns} items={rows} rootWidth={width} rootHeight={bodyHeight} focused={focused && !detailOpen} scrollRef={scrollRef} onBodyScrollActivity={scrollMore}
157
157
  rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={rows.length} tableColumns={columns} query={query} figures={model.figures}
158
158
  chart={tab === "chart" ? { series, ...chartSelection, formatValue: (value) => kind === "hiring" ? count(value) : value.toFixed(1), empty: series.length ? undefined : model.preview ? "Full history requires Pro."
@@ -1,9 +1,10 @@
1
1
  import { aggregateLoadedCongress } from "./aggregates";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { Box, ScrollBox, Text, useRendererHost, useUiCapabilities, type ScrollBoxRenderable } from "../../../ui";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
3
+ import { Box, Text, useRendererHost, useUiCapabilities, type ScrollBoxRenderable } from "../../../ui";
4
4
  import { ClippedText } from "../../../components/ui/display";
5
5
  import {
6
6
  DataTableView,
7
+ DetailScrollBody,
7
8
  KeyValueRow,
8
9
  StatGrid,
9
10
  usePaneFooter,
@@ -47,16 +48,18 @@ const DETAIL_ROW = { labelWidth: 16, emphasis: false } as const;
47
48
  export function TradeDetail({
48
49
  trade,
49
50
  width,
51
+ scrollRef,
50
52
  }: {
51
53
  trade: CloudCongressTradePayload;
52
54
  width: number;
55
+ scrollRef?: RefObject<ScrollBoxRenderable | null>;
53
56
  }) {
54
57
  const lineWidth = Math.max(1, width - 2);
55
58
  // The desktop's rows cut long values where they end, by CSS; the terminal at its columns.
56
59
  const desktop = !!useUiCapabilities().nativePaneChrome;
57
60
  const fit = (value: string) => desktop ? value : truncateWithEllipsis(value, Math.max(10, lineWidth - 16));
58
61
  return (
59
- <ScrollBox scrollY focusable={false} flexGrow={1} paddingX={1}>
62
+ <DetailScrollBody ref={scrollRef}>
60
63
  <Box flexDirection="column" width={lineWidth}>
61
64
  {/* The detail title already carries the member and ticker. */}
62
65
  <KeyValueRow {...DETAIL_ROW} label="chamber" value={`${trade.chamber === "senate" ? "Senate" : "House"}${trade.stateDistrict ? ` · ${trade.stateDistrict}` : ""}`} tone="muted" />
@@ -82,7 +85,7 @@ export function TradeDetail({
82
85
  </>
83
86
  ) : null}
84
87
  </Box>
85
- </ScrollBox>
88
+ </DetailScrollBody>
86
89
  );
87
90
  }
88
91