gloomberb 0.8.1 → 0.9.0

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 (196) hide show
  1. package/README.md +106 -54
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/api-client/chat.ts +8 -0
  5. package/src/api-client/index.test.ts +30 -0
  6. package/src/api-client/index.ts +4 -0
  7. package/src/api-client/normalizers.ts +1 -0
  8. package/src/api-client/types.ts +1 -0
  9. package/src/app/global-shortcuts.test.tsx +54 -4
  10. package/src/app/global-shortcuts.ts +11 -5
  11. package/src/capabilities/factories.ts +4 -0
  12. package/src/components/chart/comparison/data.test.ts +54 -0
  13. package/src/components/chart/comparison/data.ts +47 -24
  14. package/src/components/chart/core/projection.ts +51 -27
  15. package/src/components/command-bar/action-runtime.ts +1 -0
  16. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  17. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  18. package/src/components/command-bar/list/view.tsx +3 -1
  19. package/src/components/command-bar/pane-templates/items.ts +4 -1
  20. package/src/components/command-bar/panel/body.tsx +1 -0
  21. package/src/components/command-bar/routes/root/results.ts +0 -30
  22. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  23. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  24. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  25. package/src/components/command-bar/routing/launch-request.ts +14 -7
  26. package/src/components/command-bar/routing/list-state.ts +1 -3
  27. package/src/components/command-bar/surface/index.test.tsx +76 -4
  28. package/src/components/command-bar/surface/index.tsx +0 -2
  29. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  30. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  31. package/src/components/command-bar/view-model.ts +3 -3
  32. package/src/components/data-table/stack-view.test.tsx +14 -11
  33. package/src/components/data-table/stack-view.tsx +1 -2
  34. package/src/components/data-table/view.test.tsx +24 -18
  35. package/src/components/data-table/view.tsx +313 -83
  36. package/src/components/feed-data-table/stack-view.tsx +6 -8
  37. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  38. package/src/components/layout/pane/footer/index.tsx +14 -2
  39. package/src/components/layout/pane/title.ts +5 -1
  40. package/src/components/layout/shell/index.test.tsx +120 -2
  41. package/src/components/layout/shell/index.tsx +2 -0
  42. package/src/components/layout/shell/menu.ts +1 -7
  43. package/src/components/layout/shell/pane/actions.ts +8 -0
  44. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  45. package/src/components/layout/shell/shortcuts.ts +14 -6
  46. package/src/components/layout/shell/window-mode/index.ts +8 -3
  47. package/src/components/layout/window-edit/presentation.ts +4 -4
  48. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  49. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  50. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  51. package/src/components/ticker/list-table-view.tsx +17 -25
  52. package/src/components/ui/choice-dialog.tsx +12 -2
  53. package/src/components/ui/multi-select/dialog.tsx +17 -5
  54. package/src/components/ui/remote-image.tsx +1 -14
  55. package/src/components/ui/shortcut-hint.tsx +14 -2
  56. package/src/components/ui/table-layout.test.ts +2 -2
  57. package/src/components/ui/table-layout.ts +8 -2
  58. package/src/components/ui/tabs.tsx +115 -86
  59. package/src/components/ui/ui.test.tsx +83 -4
  60. package/src/core/state/app/types.ts +12 -4
  61. package/src/market-data/coordinator/auxiliary.ts +29 -1
  62. package/src/market-data/coordinator/index.ts +17 -1
  63. package/src/market-data/hooks.tsx +17 -1
  64. package/src/market-data/selectors.ts +4 -0
  65. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  66. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  67. package/src/plugins/builtin/account-management/model.ts +13 -3
  68. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  69. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  70. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  71. package/src/plugins/builtin/analytics/index.tsx +0 -3
  72. package/src/plugins/builtin/analytics/view.tsx +6 -5
  73. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  74. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  75. package/src/plugins/builtin/changelog/index.tsx +40 -27
  76. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  77. package/src/plugins/builtin/changelog/model.ts +98 -0
  78. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  79. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  80. package/src/plugins/builtin/chat/channels.ts +36 -19
  81. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  82. package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
  83. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  84. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  85. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  86. package/src/plugins/builtin/chat/content/index.tsx +66 -4
  87. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  88. package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
  89. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  90. package/src/plugins/builtin/chat/content/types.ts +1 -0
  91. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  92. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  93. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  94. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  95. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  96. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  97. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  98. package/src/plugins/builtin/chat/layout.ts +94 -31
  99. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  100. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  101. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  102. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  103. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  104. package/src/plugins/builtin/chat/message/types.ts +2 -0
  105. package/src/plugins/builtin/chat/pane.tsx +35 -7
  106. package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
  107. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  108. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  109. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  110. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  111. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  112. package/src/plugins/builtin/earnings/index.tsx +8 -7
  113. package/src/plugins/builtin/econ/index.tsx +6 -8
  114. package/src/plugins/builtin/help/components.tsx +1 -1
  115. package/src/plugins/builtin/help/index.test.tsx +105 -30
  116. package/src/plugins/builtin/help/index.tsx +326 -164
  117. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  118. package/src/plugins/builtin/holders/pane.tsx +7 -6
  119. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  120. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  121. package/src/plugins/builtin/options/view.tsx +16 -8
  122. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  123. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  124. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  125. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  126. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  127. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  128. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  129. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  130. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  131. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  132. package/src/plugins/builtin/research/index.test.ts +30 -1
  133. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  134. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  135. package/src/plugins/builtin/sec/index.tsx +132 -20
  136. package/src/plugins/builtin/sectors/index.tsx +13 -23
  137. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  138. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  139. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  140. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  141. package/src/plugins/builtin/substack/api/store.ts +186 -0
  142. package/src/plugins/builtin/substack/api/types.ts +40 -0
  143. package/src/plugins/builtin/substack/api.test.ts +298 -0
  144. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  145. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  146. package/src/plugins/builtin/substack/content.ts +478 -0
  147. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  148. package/src/plugins/builtin/substack/index.tsx +49 -0
  149. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  150. package/src/plugins/builtin/substack/model.test.ts +155 -0
  151. package/src/plugins/builtin/substack/normalize.ts +343 -0
  152. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  153. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  154. package/src/plugins/builtin/substack/pane.tsx +500 -0
  155. package/src/plugins/builtin/substack/table.ts +111 -0
  156. package/src/plugins/builtin/substack/types.ts +71 -0
  157. package/src/plugins/builtin/substack/utils.ts +131 -0
  158. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  161. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  162. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  163. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  164. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  165. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  166. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  167. package/src/plugins/catalog-ui.ts +2 -0
  168. package/src/plugins/pane-manager/layout-state.ts +11 -0
  169. package/src/plugins/pane-manager.ts +1 -1
  170. package/src/plugins/prediction-markets/pane.tsx +10 -9
  171. package/src/plugins/registry/index.ts +3 -0
  172. package/src/plugins/runtime/context.tsx +1 -0
  173. package/src/plugins/runtime/index.tsx +4 -0
  174. package/src/react/use-throttled-commit-value.ts +116 -0
  175. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  176. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  177. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  178. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  179. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  180. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  181. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  182. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  183. package/src/sources/gloomberb-cloud/index.ts +4 -0
  184. package/src/sources/provider-router/cache.ts +1 -0
  185. package/src/sources/provider-router/document-routes.ts +45 -0
  186. package/src/sources/provider-router/index.ts +4 -0
  187. package/src/sources/sec-edgar/content.ts +9 -3
  188. package/src/sources/sec-edgar.test.ts +108 -1
  189. package/src/sources/sec-edgar.ts +105 -1
  190. package/src/sources/yahoo-finance.ts +4 -0
  191. package/src/state/app/context/index.test.tsx +16 -0
  192. package/src/test-support/plugin-runtime.ts +1 -0
  193. package/src/tickers/search/index.ts +1 -0
  194. package/src/types/data-provider.ts +11 -0
  195. package/src/ui/host.tsx +1 -0
  196. package/src/version.ts +1 -1
@@ -1,10 +1,10 @@
1
1
  import { Text } from "../../../ui";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import type { GloomPlugin } from "../../../types/plugin";
4
- import type { SecFilingItem } from "../../../types/data-provider";
5
- import { useResolvedEntryValue, useSecFilingContent, useSecFilingsQuery } from "../../../market-data/hooks";
4
+ import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
5
+ import { useResolvedEntryValue, useSecFilingContent, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
6
6
  import { instrumentFromTicker } from "../../../market-data/request-types";
7
- import { usePluginPaneState } from "../../runtime";
7
+ import { useDebouncedPluginPaneState } from "../../runtime";
8
8
  import { usePaneTicker } from "../../../state/app/context";
9
9
  import { colors } from "../../../theme/colors";
10
10
  import { FeedDataTableStackView, Spinner, useExternalLinkFooter, type FeedDataTableItem } from "../../../components";
@@ -17,6 +17,14 @@ import {
17
17
  formatFilingMetaDate,
18
18
  renderFilingNotice,
19
19
  } from "./filing-display";
20
+ import {
21
+ documentContentKey,
22
+ documentContentTarget,
23
+ documentHeading,
24
+ formatCompactDocumentLabel,
25
+ isDefaultVisibleFilingDocument,
26
+ isInlineExhibitDocument,
27
+ } from "./filing-documents";
20
28
 
21
29
  const SEC_FILING_LIMIT = 50;
22
30
  const OWNERSHIP_FORMS = new Set(["3", "4", "5"]);
@@ -80,6 +88,50 @@ function buildDetailBody(filing: SecFilingItem): string {
80
88
  : "No additional SEC filing description is available for this entry.";
81
89
  }
82
90
 
91
+ function buildDetailBodyWithDocuments({
92
+ filing,
93
+ documents,
94
+ documentsLoading,
95
+ contentCache,
96
+ primaryContent,
97
+ }: {
98
+ filing: SecFilingItem;
99
+ documents: SecFilingDocument[];
100
+ documentsLoading: boolean;
101
+ contentCache: Map<string, string | null>;
102
+ primaryContent: string;
103
+ }): string {
104
+ const lines: string[] = [];
105
+ lines.push("Documents");
106
+ if (documentsLoading && documents.length === 0) {
107
+ lines.push("Loading filing documents...");
108
+ } else if (documents.length === 0) {
109
+ lines.push("No filing documents were listed for this filing.");
110
+ } else {
111
+ const visibleDocuments = documents.filter(isDefaultVisibleFilingDocument);
112
+ lines.push(...visibleDocuments.map(formatCompactDocumentLabel));
113
+ const hiddenCount = documents.length - visibleDocuments.length;
114
+ if (hiddenCount > 0) lines.push(`+ ${hiddenCount} support documents hidden`);
115
+ }
116
+
117
+ const exhibits = documents.filter(isInlineExhibitDocument);
118
+ if (exhibits.length > 0) {
119
+ lines.push("", "Inline Exhibits");
120
+ for (const document of exhibits) {
121
+ const key = documentContentKey(filing, document);
122
+ const hasContent = contentCache.has(key);
123
+ const content = contentCache.get(key);
124
+ lines.push("", documentHeading(document));
125
+ lines.push(hasContent
126
+ ? content || "Readable document content was not available for this exhibit."
127
+ : "Loading exhibit content...");
128
+ }
129
+ }
130
+
131
+ lines.push("", "Primary Filing Content", primaryContent);
132
+ return lines.join("\n");
133
+ }
134
+
83
135
  function buildForm4Preview(content: string | null): string | null {
84
136
  if (!content) return null;
85
137
  const tx = parseForm4Xml(content);
@@ -134,6 +186,8 @@ function toFeedItems(
134
186
  selectedAccessionNumber: string | undefined,
135
187
  contentCache: Map<string, string | null>,
136
188
  loadingContent: boolean,
189
+ selectedDocuments: SecFilingDocument[],
190
+ loadingDocuments: boolean,
137
191
  ): FeedDataTableItem[] {
138
192
  return filings.map((filing) => {
139
193
  const displayTitle = getFilingDisplayTitle(filing);
@@ -152,6 +206,19 @@ function toFeedItems(
152
206
  const form4Detail = isOwnership && hasFetchedContent
153
207
  ? buildForm4Detail(fetchedContent ?? null, filing)
154
208
  : null;
209
+ const selected = filing.accessionNumber === selectedAccessionNumber;
210
+ const primaryDetailBody = loadingContent && selected
211
+ ? "Loading filing content..."
212
+ : form4Detail ?? fetchedContent ?? fallbackBody;
213
+ const detailBody = selected
214
+ ? buildDetailBodyWithDocuments({
215
+ filing,
216
+ documents: selectedDocuments,
217
+ documentsLoading: loadingDocuments,
218
+ contentCache,
219
+ primaryContent: primaryDetailBody,
220
+ })
221
+ : form4Detail ?? fallbackBody;
155
222
 
156
223
  const enrichedTitle = formDesc
157
224
  ? `${displayTitle} — ${formDesc}`
@@ -170,13 +237,7 @@ function toFeedItems(
170
237
  `Accession ${filing.accessionNumber}`,
171
238
  ...(filing.items ? [`Items ${filing.items}`] : []),
172
239
  ],
173
- detailBody: filing.accessionNumber === selectedAccessionNumber
174
- ? (
175
- loadingContent
176
- ? "Loading filing content..."
177
- : form4Detail ?? fetchedContent ?? fallbackBody
178
- )
179
- : form4Detail ?? fallbackBody,
240
+ detailBody,
180
241
  };
181
242
  });
182
243
  }
@@ -184,7 +245,7 @@ function toFeedItems(
184
245
  function SecView({ width, height, focused }: { width: number; height: number; focused: boolean }) {
185
246
  const { ticker } = usePaneTicker();
186
247
  const selectionKey = `selectedIdx:${ticker?.metadata.ticker ?? "none"}`;
187
- const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>(selectionKey, 0);
248
+ const [selectedIdx, setSelectedIdx] = useDebouncedPluginPaneState<number>(selectionKey, 0);
188
249
  const [contentCache, setContentCache] = useState<Map<string, string | null>>(new Map());
189
250
  const [openItemId, setOpenItemId] = useState<string | null>(null);
190
251
  const contentFetchRef = useRef(0);
@@ -204,28 +265,67 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
204
265
  contentFetchRef.current += 1;
205
266
  }, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
206
267
 
207
- const selected = filings[selectedIdx];
208
268
  const openFiling = openItemId
209
269
  ? filings.find((filing) => filing.accessionNumber === openItemId) ?? null
210
270
  : null;
211
- const cachedSelectedContent = selected ? contentCache.get(selected.accessionNumber) : undefined;
271
+ const cachedOpenContent = openFiling ? contentCache.get(openFiling.accessionNumber) : undefined;
212
272
  const filingContentEntry = useSecFilingContent(
213
- selected && cachedSelectedContent === undefined ? selected : null,
273
+ openFiling && cachedOpenContent === undefined ? openFiling : null,
214
274
  );
215
275
  const filingContent = useResolvedEntryValue(filingContentEntry);
216
276
  const loadingContent = filingContentEntry?.phase === "loading" || filingContentEntry?.phase === "refreshing";
277
+ const documentsEntry = useSecFilingDocuments(openFiling ?? null);
278
+ const openDocuments = useResolvedEntryValue(documentsEntry) ?? [];
279
+ const loadingDocuments = !!openFiling && (
280
+ documentsEntry?.phase === "idle"
281
+ || documentsEntry?.phase === "loading"
282
+ || documentsEntry?.phase === "refreshing"
283
+ );
217
284
 
218
285
  useEffect(() => {
219
- if (!selected) return;
220
- if (cachedSelectedContent !== undefined) return;
286
+ if (!openFiling) return;
287
+ if (cachedOpenContent !== undefined) return;
221
288
  if (filingContentEntry?.phase === "error") {
222
- setContentCache((prev) => new Map(prev).set(selected.accessionNumber, null));
289
+ setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, null));
223
290
  return;
224
291
  }
225
292
  if (filingContent !== null) {
226
- setContentCache((prev) => new Map(prev).set(selected.accessionNumber, filingContent));
293
+ setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, filingContent));
294
+ }
295
+ }, [cachedOpenContent, filingContent, filingContentEntry?.phase, openFiling]);
296
+
297
+ const documentFetchStartedRef = useRef(new Set<string>());
298
+ useEffect(() => {
299
+ if (!openFiling || openDocuments.length === 0) return;
300
+ const coordinator = getSharedMarketDataCoordinator();
301
+ if (!coordinator) return;
302
+ const gen = contentFetchRef.current;
303
+ const documentsToFetch = openDocuments.filter((document) => {
304
+ if (!isInlineExhibitDocument(document)) return false;
305
+ const key = documentContentKey(openFiling, document);
306
+ return !contentCache.has(key) && !documentFetchStartedRef.current.has(key);
307
+ });
308
+ if (documentsToFetch.length === 0) return;
309
+
310
+ for (const document of documentsToFetch) {
311
+ documentFetchStartedRef.current.add(documentContentKey(openFiling, document));
227
312
  }
228
- }, [cachedSelectedContent, filingContent, filingContentEntry?.phase, selected]);
313
+
314
+ (async () => {
315
+ for (const document of documentsToFetch) {
316
+ if (contentFetchRef.current !== gen) return;
317
+ const key = documentContentKey(openFiling, document);
318
+ try {
319
+ const entry = await coordinator.loadSecFilingContent(documentContentTarget(openFiling, document));
320
+ if (contentFetchRef.current !== gen) return;
321
+ setContentCache((prev) => new Map(prev).set(key, entry.data ?? entry.lastGoodData ?? null));
322
+ } catch {
323
+ if (contentFetchRef.current !== gen) return;
324
+ setContentCache((prev) => new Map(prev).set(key, null));
325
+ }
326
+ }
327
+ })();
328
+ }, [contentCache, openFiling, openDocuments]);
229
329
 
230
330
  useEffect(() => {
231
331
  if (filings.length > 0 && selectedIdx >= filings.length) {
@@ -235,6 +335,11 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
235
335
 
236
336
  // Background prefetch Form 4 content for enriched previews
237
337
  const prefetchStartedRef = useRef(new Set<string>());
338
+ useEffect(() => {
339
+ prefetchStartedRef.current.clear();
340
+ documentFetchStartedRef.current.clear();
341
+ }, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
342
+
238
343
  useEffect(() => {
239
344
  if (filings.length === 0) return;
240
345
  const coordinator = getSharedMarketDataCoordinator();
@@ -288,7 +393,14 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
288
393
  width={width}
289
394
  height={height}
290
395
  focused={focused}
291
- items={toFeedItems(filings, selected?.accessionNumber, contentCache, loadingContent)}
396
+ items={toFeedItems(
397
+ filings,
398
+ openFiling?.accessionNumber,
399
+ contentCache,
400
+ loadingContent,
401
+ openDocuments,
402
+ loadingDocuments,
403
+ )}
292
404
  selectedIdx={selectedIdx}
293
405
  onSelect={setSelectedIdx}
294
406
  onOpenItemIdChange={setOpenItemId}
@@ -71,11 +71,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
71
71
  label: collection.label,
72
72
  value: collection.id,
73
73
  })), []);
74
- const selectedIdx = selectedEtf
75
- ? sortedRows.findIndex((row) => row.etf === selectedEtf)
76
- : -1;
77
- const activeIdx = selectedIdx >= 0 ? selectedIdx : (sortedRows.length > 0 ? 0 : -1);
78
-
79
74
  const fetchAll = useCallback(() => {
80
75
  fetchGenRef.current += 1;
81
76
  const gen = fetchGenRef.current;
@@ -189,19 +184,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
189
184
  setSortPreference((current) => nextSortPreference(current, columnId));
190
185
  }, [setSortPreference]);
191
186
 
192
- const selectIndex = useCallback((index: number) => {
193
- const row = sortedRows[index];
194
- if (row) setSelectedEtf(row.etf);
195
- }, [setSelectedEtf, sortedRows]);
196
-
197
- const selectRow = useCallback((row: SectorRow) => {
198
- if (row.etf === selectedEtf) {
199
- openRow(row);
200
- return;
201
- }
202
- setSelectedEtf(row.etf);
203
- }, [openRow, selectedEtf, setSelectedEtf]);
204
-
205
187
  const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
206
188
  if (event.name === "r") {
207
189
  event.preventDefault?.();
@@ -291,9 +273,19 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
291
273
  return (
292
274
  <DataTableView<SectorRow, SectorColumn>
293
275
  focused={focused}
294
- selectedIndex={activeIdx}
295
- onSelectIndex={selectIndex}
296
- onActivateIndex={(_index, row) => openRow(row)}
276
+ selection={{
277
+ kind: "id",
278
+ selectedId: selectedEtf,
279
+ getId: (row) => row.etf,
280
+ onChange: (id, row, _index, reason) => {
281
+ if (reason === "pointer" && id === selectedEtf) {
282
+ openRow(row);
283
+ return;
284
+ }
285
+ setSelectedEtf(id);
286
+ },
287
+ }}
288
+ onActivate={openRow}
297
289
  onRootKeyDown={handleTableKeyDown}
298
290
  rootBefore={rootBefore}
299
291
  rootWidth={width}
@@ -305,8 +297,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
305
297
  sortDirection={sortPreference.direction}
306
298
  onHeaderClick={handleHeaderClick}
307
299
  getItemKey={(row) => row.etf}
308
- isSelected={(row) => row.etf === selectedEtf}
309
- onSelect={selectRow}
310
300
  renderCell={renderCell}
311
301
  emptyStateTitle="No sector data available"
312
302
  showHorizontalScrollbar={false}
@@ -11,6 +11,7 @@ export function useBoundTicker() {
11
11
  const { symbol, ticker } = usePaneTicker();
12
12
  return {
13
13
  symbol,
14
+ ticker,
14
15
  exchange: ticker?.metadata.exchange ?? "",
15
16
  currency: ticker?.metadata.currency ?? "USD",
16
17
  };
@@ -0,0 +1,185 @@
1
+ import type { SubstackAuthState } from "./types";
2
+ import {
3
+ parseErrorText,
4
+ resolveSubstackUrl,
5
+ storeSubstackAuth,
6
+ substackFetch,
7
+ SUBSTACK_ORIGIN,
8
+ } from "./store";
9
+
10
+ export async function requestSubstackMagicLink(email: string): Promise<void> {
11
+ const normalizedEmail = email.trim();
12
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizedEmail)) {
13
+ throw new Error("Enter a valid email address");
14
+ }
15
+ const response = await substackFetch(`${SUBSTACK_ORIGIN}/api/v1/email-login`, {
16
+ method: "POST",
17
+ headers: {
18
+ "content-type": "application/json",
19
+ },
20
+ body: JSON.stringify({ email: normalizedEmail }),
21
+ });
22
+ if (!response.ok) {
23
+ const detail = await parseErrorText(response);
24
+ throw new Error(`Substack login link failed (${response.status})${detail ? `: ${detail}` : ""}`);
25
+ }
26
+ }
27
+
28
+ export function splitSetCookieHeader(header: string): string[] {
29
+ const parts: string[] = [];
30
+ let start = 0;
31
+ let inExpires = false;
32
+ for (let i = 0; i < header.length; i += 1) {
33
+ const char = header[i];
34
+ if (char === ",") {
35
+ const fragment = header.slice(start, i);
36
+ inExpires = /expires=/i.test(fragment) && !/;\s*(?:max-age|domain|path|secure|httponly|samesite)\b/i.test(fragment);
37
+ if (!inExpires) {
38
+ parts.push(fragment.trim());
39
+ start = i + 1;
40
+ }
41
+ } else if (char === ";") {
42
+ inExpires = false;
43
+ }
44
+ }
45
+ const tail = header.slice(start).trim();
46
+ if (tail) parts.push(tail);
47
+ return parts;
48
+ }
49
+
50
+ function getSetCookieHeaders(headers: Headers): string[] {
51
+ const withGetSetCookie = headers as Headers & { getSetCookie?: () => string[] };
52
+ const direct = withGetSetCookie.getSetCookie?.();
53
+ if (direct && direct.length > 0) return direct;
54
+ const header = headers.get("set-cookie");
55
+ return header ? splitSetCookieHeader(header) : [];
56
+ }
57
+
58
+ export function parseCookiesFromSetCookie(headers: string[]): Map<string, string> {
59
+ const cookies = new Map<string, string>();
60
+ for (const header of headers) {
61
+ const first = header.split(";")[0] ?? "";
62
+ const eq = first.indexOf("=");
63
+ if (eq <= 0) continue;
64
+ const name = first.slice(0, eq).trim();
65
+ const value = first.slice(eq + 1).trim();
66
+ if (!name) continue;
67
+ cookies.set(name, value);
68
+ }
69
+ return cookies;
70
+ }
71
+
72
+ function cookieHeaderFromJar(jar: Map<string, string>): string {
73
+ return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
74
+ }
75
+
76
+ function mergeSetCookies(cookieJar: Map<string, string>, response: Response): void {
77
+ for (const [name, value] of parseCookiesFromSetCookie(getSetCookieHeaders(response.headers))) {
78
+ cookieJar.set(name, value);
79
+ }
80
+ }
81
+
82
+ async function followRedirectsForCookies(startUrl: string, cookieJar: Map<string, string>): Promise<void> {
83
+ let currentUrl = startUrl;
84
+ for (let redirectCount = 0; redirectCount < 8; redirectCount += 1) {
85
+ const response = await substackFetch(currentUrl, {
86
+ method: "GET",
87
+ redirect: "manual",
88
+ headers: cookieJar.size > 0 ? { cookie: cookieHeaderFromJar(cookieJar) } : undefined,
89
+ });
90
+ mergeSetCookies(cookieJar, response);
91
+
92
+ if (response.status < 300 || response.status >= 400) break;
93
+ const location = response.headers.get("location");
94
+ if (!location) break;
95
+ const nextUrl = resolveSubstackUrl(location, currentUrl);
96
+ if (!nextUrl) break;
97
+ currentUrl = nextUrl;
98
+ }
99
+ }
100
+
101
+ export async function completeSubstackMagicLink(link: string, email: string): Promise<SubstackAuthState> {
102
+ const currentUrl = resolveSubstackUrl(link);
103
+ if (!currentUrl) throw new Error("Paste the full Substack magic-link URL");
104
+
105
+ const cookieJar = new Map<string, string>();
106
+ await followRedirectsForCookies(currentUrl, cookieJar);
107
+
108
+ const sid = cookieJar.get("substack.sid");
109
+ if (!sid) {
110
+ throw new Error("Substack did not return a session cookie. Request a new login link and paste the full URL.");
111
+ }
112
+ const auth: SubstackAuthState = {
113
+ email: email.trim(),
114
+ sid,
115
+ lli: cookieJar.get("substack.lli") ?? "1",
116
+ loggedInAt: Date.now(),
117
+ };
118
+ storeSubstackAuth(auth);
119
+ return auth;
120
+ }
121
+
122
+ export async function completeSubstackOtpLogin(code: string, email: string): Promise<SubstackAuthState> {
123
+ const normalizedCode = code.trim();
124
+ const normalizedEmail = email.trim();
125
+ if (!/^\d{6}$/.test(normalizedCode)) {
126
+ throw new Error("Enter the 6-digit Substack email code");
127
+ }
128
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizedEmail)) {
129
+ throw new Error("Enter a valid email address");
130
+ }
131
+
132
+ const cookieJar = new Map<string, string>();
133
+ const response = await substackFetch(`${SUBSTACK_ORIGIN}/api/v1/email-otp-login/complete`, {
134
+ method: "POST",
135
+ redirect: "manual",
136
+ credentials: "include",
137
+ headers: {
138
+ "content-type": "application/json",
139
+ },
140
+ body: JSON.stringify({
141
+ code: normalizedCode,
142
+ email: normalizedEmail,
143
+ redirect: "",
144
+ for_pub: null,
145
+ island_magic_signin: false,
146
+ }),
147
+ });
148
+ mergeSetCookies(cookieJar, response);
149
+
150
+ const isRedirect = response.status >= 300 && response.status < 400;
151
+ if (!response.ok && !isRedirect) {
152
+ const detail = await parseErrorText(response);
153
+ throw new Error(`Substack code login failed (${response.status})${detail ? `: ${detail}` : ""}`);
154
+ }
155
+
156
+ let redirectUrl = resolveSubstackUrl(response.headers.get("location"));
157
+ if (!redirectUrl && response.ok) {
158
+ try {
159
+ const payload = await response.clone().json() as { redirect?: unknown };
160
+ redirectUrl = resolveSubstackUrl(payload.redirect);
161
+ } catch {
162
+ redirectUrl = null;
163
+ }
164
+ }
165
+
166
+ if (!cookieJar.get("substack.sid") && redirectUrl) {
167
+ await followRedirectsForCookies(redirectUrl, cookieJar);
168
+ }
169
+
170
+ const sid = cookieJar.get("substack.sid");
171
+ if (!sid) {
172
+ const capturedCookieNames = [...cookieJar.keys()];
173
+ throw new Error(
174
+ `Substack accepted the code but did not return a session cookie. status=${response.status} redirect=${redirectUrl ? "yes" : "no"} cookies=${capturedCookieNames.length ? capturedCookieNames.join(",") : "none"}`,
175
+ );
176
+ }
177
+ const auth: SubstackAuthState = {
178
+ email: normalizedEmail,
179
+ sid,
180
+ lli: cookieJar.get("substack.lli") ?? "1",
181
+ loggedInAt: Date.now(),
182
+ };
183
+ storeSubstackAuth(auth);
184
+ return auth;
185
+ }
@@ -0,0 +1,78 @@
1
+ import type { PersistedResourceValue } from "../../../../types/persistence";
2
+ import {
3
+ sortSubscriptionsByLatest,
4
+ } from "../normalize";
5
+ import type {
6
+ SubstackArticleDetail,
7
+ SubstackArticleSummary,
8
+ SubstackPublication,
9
+ } from "../types";
10
+ import type { SubstackCachedData, SubstackHomeData, SubstackPublicationFeedPage } from "./types";
11
+ import { readResource } from "./store";
12
+
13
+ const PUBLICATION_ARCHIVE_MAX_CACHED_PAGES = 20;
14
+
15
+ export function publicationCacheKey(publication: SubstackPublication, offset: number): string {
16
+ return `${publication.baseUrl ?? publication.subdomain ?? publication.id}:offset:${offset}`;
17
+ }
18
+
19
+ function cachedDataFromResource<T>(entry: PersistedResourceValue<T> | null): SubstackCachedData<T> | null {
20
+ if (!entry) return null;
21
+ return {
22
+ data: entry.value,
23
+ fetchedAt: entry.fetchedAt,
24
+ stale: Boolean(entry.stale),
25
+ };
26
+ }
27
+
28
+ export function getCachedSubstackHome(): SubstackHomeData | null {
29
+ const subscriptionsEntry = readResource<SubstackPublication[]>("subscriptions", "me", false);
30
+ const feedEntry = readResource<SubstackArticleSummary[]>("feed", "subscribed", false);
31
+ if (!subscriptionsEntry || !feedEntry) return null;
32
+ return {
33
+ subscriptions: sortSubscriptionsByLatest(subscriptionsEntry.value, feedEntry.value),
34
+ feed: feedEntry.value,
35
+ fetchedAt: Math.max(subscriptionsEntry.fetchedAt, feedEntry.fetchedAt),
36
+ stale: Boolean(subscriptionsEntry.stale) || Boolean(feedEntry.stale),
37
+ };
38
+ }
39
+
40
+ export function getCachedSubstackPublicationFeed(
41
+ publication: SubstackPublication,
42
+ ): SubstackCachedData<SubstackPublicationFeedPage> | null {
43
+ let offset: number | null = 0;
44
+ let pageCount = 0;
45
+ let merged: SubstackPublicationFeedPage | null = null;
46
+ let fetchedAt = 0;
47
+ let stale = false;
48
+
49
+ while (offset != null && pageCount < PUBLICATION_ARCHIVE_MAX_CACHED_PAGES) {
50
+ const entry: PersistedResourceValue<SubstackPublicationFeedPage> | null = readResource<SubstackPublicationFeedPage>(
51
+ "publication",
52
+ publicationCacheKey(publication, offset),
53
+ false,
54
+ );
55
+ if (!entry) break;
56
+ const itemsById = new Map<string, SubstackArticleSummary>();
57
+ for (const article of merged?.items ?? []) itemsById.set(article.id, article);
58
+ for (const article of entry.value.items) itemsById.set(article.id, article);
59
+ merged = {
60
+ items: [...itemsById.values()],
61
+ nextOffset: entry.value.nextOffset,
62
+ hasMore: entry.value.hasMore,
63
+ };
64
+ fetchedAt = Math.max(fetchedAt, entry.fetchedAt);
65
+ stale = stale || Boolean(entry.stale);
66
+ offset = entry.value.nextOffset;
67
+ pageCount += 1;
68
+ }
69
+
70
+ if (!merged) return null;
71
+ return { data: merged, fetchedAt, stale };
72
+ }
73
+
74
+ export function getCachedSubstackArticleDetail(
75
+ article: Pick<SubstackArticleSummary, "id">,
76
+ ): SubstackCachedData<SubstackArticleDetail> | null {
77
+ return cachedDataFromResource(readResource<SubstackArticleDetail>("post", article.id, false));
78
+ }