gloomberb 0.18.2 → 0.18.3

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 (157) hide show
  1. package/package.json +1 -1
  2. package/patches/electrobun@1.18.1.patch +106 -5
  3. package/src/api-client/data.ts +15 -0
  4. package/src/api-client/index.ts +2 -0
  5. package/src/api-client/request.ts +5 -3
  6. package/src/api-client/telemetry.ts +17 -0
  7. package/src/app/global-shortcuts.ts +1 -16
  8. package/src/app/runtime/update.ts +28 -8
  9. package/src/architecture/pane-tabs-allowlist.json +65 -0
  10. package/src/cli/commands/market.ts +73 -33
  11. package/src/cli/commands/ticker.ts +82 -25
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +8 -0
  14. package/src/cli/issuer-membership.ts +103 -0
  15. package/src/cli/listing-arg.ts +15 -2
  16. package/src/cli/name-entries.ts +51 -12
  17. package/src/cli/pane-functions/capabilities.ts +2 -0
  18. package/src/cli/pane-functions/catalog.ts +16 -0
  19. package/src/cli/pane-functions/dom.ts +2 -1
  20. package/src/cli/pane-functions/headless.ts +25 -6
  21. package/src/cli/pane-functions/index.ts +64 -5
  22. package/src/cli/pane-functions/resolver.ts +19 -5
  23. package/src/cli/report-tables.ts +136 -18
  24. package/src/cli/result.ts +12 -4
  25. package/src/cli/symbol-list.ts +47 -11
  26. package/src/components/chart/composite/composite-chart.tsx +14 -5
  27. package/src/components/chart/composite/drawing-store.ts +26 -6
  28. package/src/components/chart/composite/interactions.ts +2 -1
  29. package/src/components/chart/composite/panel-surface.tsx +11 -0
  30. package/src/components/chart/composite/types.ts +6 -0
  31. package/src/components/chart/composite/use-composite-chart-keyboard.ts +1 -1
  32. package/src/components/command-bar/routes/root/command-items.ts +32 -6
  33. package/src/components/data-table/view.tsx +23 -3
  34. package/src/components/index.ts +1 -0
  35. package/src/components/layout/detached-pane-shell.tsx +1 -0
  36. package/src/components/layout/floating-pane.tsx +1 -0
  37. package/src/components/layout/header.tsx +228 -55
  38. package/src/components/layout/pane/header.tsx +10 -3
  39. package/src/components/layout/pane/index.tsx +1 -0
  40. package/src/components/layout/pane/pane-tabs.tsx +9 -1
  41. package/src/components/ui/stat-grid.tsx +28 -3
  42. package/src/components/ui/status.tsx +3 -1
  43. package/src/core/app-services.ts +4 -0
  44. package/src/i18n/es.ts +22 -0
  45. package/src/i18n/ja.ts +22 -0
  46. package/src/i18n/ko.ts +22 -0
  47. package/src/i18n/zh-cn.ts +22 -0
  48. package/src/i18n/zh-tw.ts +22 -0
  49. package/src/plugins/builtin/alerts/alert-engine.ts +3 -1
  50. package/src/plugins/builtin/alerts/events.ts +1 -0
  51. package/src/plugins/builtin/alerts/research-rules.ts +1 -0
  52. package/src/plugins/builtin/analytics/risk-pane.tsx +10 -7
  53. package/src/plugins/builtin/attention/pane.tsx +16 -14
  54. package/src/plugins/builtin/awards/pane.tsx +6 -6
  55. package/src/plugins/builtin/backtest/export.ts +1 -1
  56. package/src/plugins/builtin/broker-trading/dock.tsx +4 -2
  57. package/src/plugins/builtin/broker-trading/pane.tsx +6 -4
  58. package/src/plugins/builtin/broker-trading/ticket.tsx +10 -5
  59. package/src/plugins/builtin/buildout/pane/index.tsx +9 -6
  60. package/src/plugins/builtin/catalysts/client.ts +2 -2
  61. package/src/plugins/builtin/catalysts/pane.tsx +3 -3
  62. package/src/plugins/builtin/chart-composer/pane.tsx +1 -0
  63. package/src/plugins/builtin/commodity-balances/pane.tsx +3 -4
  64. package/src/plugins/builtin/company-attention/app-rank.tsx +4 -5
  65. package/src/plugins/builtin/company-attention/pane.tsx +13 -11
  66. package/src/plugins/builtin/company-kpis/pane.tsx +4 -3
  67. package/src/plugins/builtin/correlation/headless.ts +2 -1
  68. package/src/plugins/builtin/correlation/relationship/model.ts +1 -0
  69. package/src/plugins/builtin/correlation/settings.ts +1 -0
  70. package/src/plugins/builtin/cot/pane.tsx +4 -3
  71. package/src/plugins/builtin/credit-documents/pane.tsx +8 -7
  72. package/src/plugins/builtin/debt-maturities/pane.tsx +4 -3
  73. package/src/plugins/builtin/doe/pane.tsx +1 -1
  74. package/src/plugins/builtin/equity-screener/headless.ts +10 -0
  75. package/src/plugins/builtin/equity-screener/model.ts +28 -0
  76. package/src/plugins/builtin/equity-screener/pane.tsx +105 -98
  77. package/src/plugins/builtin/exposure/pane.tsx +10 -9
  78. package/src/plugins/builtin/gpu/board.tsx +4 -1
  79. package/src/plugins/builtin/gpu/changes.tsx +10 -10
  80. package/src/plugins/builtin/gpu/equities.tsx +4 -1
  81. package/src/plugins/builtin/gpu/history.tsx +4 -2
  82. package/src/plugins/builtin/gpu/pane.tsx +5 -3
  83. package/src/plugins/builtin/holders/client.ts +2 -1
  84. package/src/plugins/builtin/holders/headless.ts +2 -1
  85. package/src/plugins/builtin/holders/thirteenf-match.ts +3 -1
  86. package/src/plugins/builtin/insider/base-values.ts +11 -7
  87. package/src/plugins/builtin/insider/client.ts +3 -0
  88. package/src/plugins/builtin/insider/headless.ts +19 -6
  89. package/src/plugins/builtin/insider/index.tsx +17 -8
  90. package/src/plugins/builtin/insider/issuer.ts +34 -4
  91. package/src/plugins/builtin/insider/signals-client.ts +2 -2
  92. package/src/plugins/builtin/insider/table-model.ts +35 -13
  93. package/src/plugins/builtin/insider/table.tsx +5 -2
  94. package/src/plugins/builtin/iv-history/events.ts +145 -0
  95. package/src/plugins/builtin/iv-history/format.ts +3 -0
  96. package/src/plugins/builtin/iv-history/headless.ts +91 -7
  97. package/src/plugins/builtin/jodi/pane.tsx +4 -4
  98. package/src/plugins/builtin/market-movers/client.ts +4 -3
  99. package/src/plugins/builtin/market-movers/screener.ts +4 -3
  100. package/src/plugins/builtin/notes/cli.ts +1 -1
  101. package/src/plugins/builtin/notes/export.ts +17 -6
  102. package/src/plugins/builtin/notes/files.ts +16 -5
  103. package/src/plugins/builtin/options/enrichment-model.ts +10 -9
  104. package/src/plugins/builtin/options/model-spot.ts +70 -4
  105. package/src/plugins/builtin/options/view.tsx +41 -13
  106. package/src/plugins/builtin/options-calculator/pane.tsx +3 -2
  107. package/src/plugins/builtin/options-positioning/headless.ts +11 -3
  108. package/src/plugins/builtin/options-positioning/model.ts +24 -0
  109. package/src/plugins/builtin/options-positioning/pane.tsx +10 -12
  110. package/src/plugins/builtin/options-scenario/client.ts +19 -1
  111. package/src/plugins/builtin/options-scenario/headless.ts +13 -6
  112. package/src/plugins/builtin/options-scenario/pane.tsx +13 -5
  113. package/src/plugins/builtin/plugin-marketplace/pane.tsx +5 -4
  114. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  115. package/src/plugins/builtin/power/client.ts +2 -2
  116. package/src/plugins/builtin/power/pane.tsx +36 -31
  117. package/src/plugins/builtin/reverse-dcf/client.ts +2 -1
  118. package/src/plugins/builtin/reverse-dcf/model.ts +4 -3
  119. package/src/plugins/builtin/seasonality/client.ts +2 -1
  120. package/src/plugins/builtin/sectors/client.ts +4 -3
  121. package/src/plugins/builtin/shared/cloud-resource.ts +15 -2
  122. package/src/plugins/builtin/shared/research-trading.tsx +2 -0
  123. package/src/plugins/builtin/shared/series-list-detail.tsx +3 -1
  124. package/src/plugins/builtin/shared/volatility/options.ts +2 -1
  125. package/src/plugins/builtin/social-mentions/client.ts +2 -1
  126. package/src/plugins/builtin/vol-surface/client.ts +23 -3
  127. package/src/plugins/builtin/vol-surface/headless.ts +33 -14
  128. package/src/plugins/builtin/vol-surface/model.ts +30 -10
  129. package/src/plugins/builtin/vol-surface/pane.tsx +33 -13
  130. package/src/plugins/builtin/vol-surface/stored.ts +34 -3
  131. package/src/plugins/builtin/vol-surface/tables.ts +14 -0
  132. package/src/renderers/dom/desktop/controls.tsx +1 -1
  133. package/src/renderers/dom/desktop/query-bar.tsx +2 -0
  134. package/src/renderers/dom/host/tabs.tsx +16 -5
  135. package/src/renderers/dom/host/top-surface.ts +127 -0
  136. package/src/renderers/dom/styles.css +39 -0
  137. package/src/renderers/electrobun/bun/core-capabilities.ts +9 -1
  138. package/src/renderers/electrobun/bun/desktop/backend.ts +10 -0
  139. package/src/renderers/electrobun/bun/desktop/update.ts +210 -30
  140. package/src/renderers/electrobun/view/news-source.ts +4 -2
  141. package/src/renderers/electrobun/view/remote/asset-data-client.ts +8 -1
  142. package/src/renderers/opentui/start.tsx +7 -0
  143. package/src/sources/gloomberb-cloud/index.ts +7 -0
  144. package/src/sources/provider-router/index.ts +16 -0
  145. package/src/telemetry/crash-reports.ts +4 -0
  146. package/src/telemetry/update-failures-node.ts +59 -0
  147. package/src/telemetry/update-failures.ts +307 -0
  148. package/src/tickers/company-name-listing.ts +9 -7
  149. package/src/tickers/company-name.ts +43 -0
  150. package/src/tickers/listing-fate.ts +30 -0
  151. package/src/types/data-provider.ts +6 -0
  152. package/src/types/headless.ts +7 -0
  153. package/src/types/issuer-listings.ts +28 -0
  154. package/src/updater/failure.ts +163 -0
  155. package/src/updater/index.ts +87 -23
  156. package/src/utils/currency-pair.ts +26 -0
  157. package/src/utils/sec.ts +68 -0
@@ -242,6 +242,7 @@ export function relationshipTemplateSymbols(
242
242
  try {
243
243
  return normalizeRelationshipSymbols(parseTickerListInput(raw));
244
244
  } catch {
245
+ // Intentional ignore: invalid template input disables creation through canCreate and creates no pane.
245
246
  return null;
246
247
  }
247
248
  }
@@ -34,6 +34,7 @@ function coerceStoredSymbols(value: unknown): string[] {
34
34
  try {
35
35
  return parseCorrelationSymbolsInput(text, MAX_CORRELATION_TICKERS);
36
36
  } catch {
37
+ // Intentional ignore: malformed legacy symbols fall back to saved text or the default preset.
37
38
  return [];
38
39
  }
39
40
  }
@@ -82,8 +82,8 @@ function CotBoard({ width, height, focused, family, initialCode }: PaneProps & {
82
82
  }, [data, query, scope, sort]);
83
83
  useAutoRefresh(resource.updatedAt, resource.load);
84
84
  // The class, scope and search controls live in the board's query bar, so
85
- // their keys only work once the board is on screen.
86
- const boardShown = !!data && !open;
85
+ // their keys work while it is on screen: over the board and while a class loads.
86
+ const boardShown = !open;
87
87
  useShortcut((event) => {
88
88
  if (!focused || open || event.targetEditable || event.ctrl || event.meta || event.alt || event.super) return;
89
89
  if (event.name === "r") { event.preventDefault(); void resource.reload(); }
@@ -99,8 +99,9 @@ function CotBoard({ width, height, focused, family, initialCode }: PaneProps & {
99
99
  usePaneNoticeFooter({ registrationId: "cot:board-notices", focused, enabled: !open, notices: data?.gaps ?? [] });
100
100
  usePaneStatusFooter({ registrationId: "cot:board", enabled: !open, loading: resource.loading, error: resource.error,
101
101
  info: data ? [{ id: "as-of", parts: [{ text: `CFTC futures only · ${data.asOf ?? "--"} · contracts`, tone: "muted" }] }] : [] });
102
- if (!data && !open) return <PaneStatusBody loading={resource.loading} error={resource.error} empty={!resource.loading && !resource.error} subject="COT history" />;
102
+ // A class change reloads under the query bar, which stays.
103
103
  return <DataTableStackView<CotBoardRow> columns={BOARD_COLUMNS} items={rows} focused={focused && !searching}
104
+ status={data ? undefined : { loading: resource.loading, error: resource.error, empty: !resource.loading && !resource.error, subject: "COT history" }}
104
105
  rootWidth={width} rootHeight={height} selection={{ kind: "id", selectedId: selected, getId: (row) => row.contractCode, onChange: setSelected }}
105
106
  getItemKey={(row) => row.contractCode} onActivate={(row) => setOpen(row.contractCode)} freezeFirstColumn
106
107
  sortable sortColumnId={sort.columnId} sortDirection={sort.direction} onHeaderClick={(id) => setSort((current) => nextHeaderSort(current, id, { firstDirection: "desc" }))}
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
2
2
  import { isAccessDenied } from "../../../api-client/errors";
3
3
  import { usePlanAccess } from "../../../api-client/plan-access";
4
4
  import type { CreditDocumentsPayload, CreditHeadroom, CreditInstrument, CreditScreenPayload, CreditScreenRow } from "../../../api-client/credit-documents";
5
- import { ChartTableHeader, DataTableView, EmptyState, PageStackView, PaneStatusBody, QueryBar, useChartTableSelection, usePaneFooter, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type DataTableCell, type DataTableColumn, type PaneHint } from "../../../components";
5
+ import { ChartTableHeader, DataTableView, EmptyState, PageStackView, QueryBar, useChartTableSelection, usePaneFooter, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type DataTableCell, type DataTableColumn, type PaneHint } from "../../../components";
6
6
  import { formatCompactAxis } from "../../../components/chart-table";
7
7
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
8
8
  import { useAsyncResource, useAutoRefresh, usePaneSettingValue, usePluginAppActions, usePluginPaneState } from "../../../public/react";
@@ -170,18 +170,19 @@ function CreditView({ symbol, width, height, focused, accessKey, needsVerificati
170
170
  return { text, value, color: column.id === columns[0]!.id ? colors.textBright : colors.text };
171
171
  };
172
172
  return <Box width={width} height={height} flexDirection="column">{strip}
173
- <PaneStatusBody loading={!available && loading} error={!available && currentError !== CREDIT_UNAVAILABLE ? currentError : null}
174
- empty={!available && currentError === CREDIT_UNAVAILABLE} emptyTitle={CREDIT_UNAVAILABLE} subject="credit documents">
175
- {available ? <PageStackView focused={focused && !openFactId} detailOpen={!!openInstrument} onBack={() => setOpen(null)} detailTitle={openInstrument?.name}
173
+ <PageStackView focused={focused && !openFactId} detailOpen={!!openInstrument} onBack={() => setOpen(null)} detailTitle={openInstrument?.name}
176
174
  detailContent={openInstrument && activeSymbol ? <CreditInstrumentDetail key={`${activeSymbol}:${openInstrument.id}`} symbol={activeSymbol} instrument={openInstrument} width={width} height={bodyHeight - 1} focused={focused} accessKey={accessKey} view={view} setView={setView} covenant={activeCovenant} openId={openFactId} setOpen={setOpenFact} captured={instrumentSnapshot?.id === openInstrument.id ? instrumentSnapshot : null} pro={data?.access === "full"} onUpgrade={openUpgrade} /> : null}
177
- rootContent={<DataTableView<Row> columns={columns} items={items} sortColumnId={sort.column} sortDirection={sort.direction} onHeaderClick={(column) => setSort((current) => ({column,direction:current.column === column && current.direction === "asc" ? "desc" : "asc"}))} focused={focused && !openInstrument} rootWidth={width} rootHeight={bodyHeight} rootBefore={rootBefore}
175
+ rootContent={<DataTableView<Row> columns={columns} items={available ? items : []}
176
+ // The Screen tab and its thresholds load under the query bar, which stays.
177
+ status={available ? undefined : { loading, error: currentError !== CREDIT_UNAVAILABLE ? currentError : null,
178
+ empty: currentError === CREDIT_UNAVAILABLE, emptyTitle: CREDIT_UNAVAILABLE, subject: "credit documents" }}
179
+ sortColumnId={sort.column} sortDirection={sort.direction} onHeaderClick={(column) => setSort((current) => ({column,direction:current.column === column && current.direction === "asc" ? "desc" : "asc"}))} focused={focused && !openInstrument} rootWidth={width} rootHeight={bodyHeight} rootBefore={rootBefore}
178
180
  getItemKey={rowId} selection={{ kind: "id", selectedId: selected?.id ?? null, getId: rowId, onChange: setSelected }}
179
181
  onActivate={(row) => row.locked ? openUpgrade() : setOpen(row.screen ? row.id : row.instrumentId ?? null)}
180
182
  renderCell={renderCell}
181
183
  emptyStateTitle={tab === "screen" ? "No supported credit signals match these thresholds." : tab === "covenants" ? "No financial maintenance covenants documented." : tab === "maturities" ? "No supported instrument maturity amounts." : CREDIT_UNAVAILABLE}
182
184
  emptyStateHint={tab === "screen" && (headroom < 100 || months < 36) ? "Raise either threshold to widen the screen." : undefined}
183
185
  selectedTextOverridesCellColor showHorizontalScrollbar freezeFirstColumn resetScrollKey={`${symbol}:${tab}:${selectedCurrency}`}
184
- bodyAfter={locked > 0 && desktop ? <LockedOverlay rows={Math.min(3, locked)} text={lockedText} onPress={openUpgrade} /> : undefined} />} /> : null}
185
- </PaneStatusBody>
186
+ bodyAfter={locked > 0 && desktop ? <LockedOverlay rows={Math.min(3, locked)} text={lockedText} onPress={openUpgrade} /> : undefined} />} />
186
187
  </Box>;
187
188
  }
@@ -410,8 +410,9 @@ export function DebtMaturitiesPane({ width, height, focused }: PaneProps) {
410
410
  const detailOpen =
411
411
  tab === "maturities" ? !!openBucketRow : tab === "history" && !!openHistoryRow;
412
412
  const tabsFocused = focused && !detailOpen;
413
+ const signInWall = !data && isCloudSessionRequired(resource.error);
413
414
  const { strip: tabStrip, rows: tabRows } = usePaneTabs(
414
- latest
415
+ symbol && !signInWall
415
416
  ? { tabs: TABS, activeValue: tab, onSelect: setTab, focused: tabsFocused, dense: true }
416
417
  : null,
417
418
  );
@@ -522,7 +523,7 @@ export function DebtMaturitiesPane({ width, height, focused }: PaneProps) {
522
523
  message="Select a ticker to view debt maturities."
523
524
  />
524
525
  );
525
- if (!data && isCloudSessionRequired(resource.error))
526
+ if (signInWall)
526
527
  return (
527
528
  <SignInWall
528
529
  placement="debt-maturities-signin"
@@ -532,6 +533,7 @@ export function DebtMaturitiesPane({ width, height, focused }: PaneProps) {
532
533
  );
533
534
  return (
534
535
  <Box width={width} height={height} flexDirection="column">
536
+ {tabStrip}
535
537
  <PaneStatusBody
536
538
  loading={resource.loading && !data}
537
539
  error={!data ? resource.error : null}
@@ -542,7 +544,6 @@ export function DebtMaturitiesPane({ width, height, focused }: PaneProps) {
542
544
  >
543
545
  {latest ? (
544
546
  <>
545
- {tabStrip}
546
547
  {tab === "filing" ? (
547
548
  <FilingDetail latest={latest} quarter={quarter} width={width} height={bodyHeight} />
548
549
  ) : tab === "maturities" ? (
@@ -129,7 +129,7 @@ export function DoePane({ width, height, focused }: PaneProps) {
129
129
  const data = resource.data?.payload ?? null;
130
130
  // The server not serving the board yet is a state of the data, not a failure.
131
131
  const notAvailable = !data && resource.error === DOE_NOT_AVAILABLE;
132
- const { strip, rows: tabRows } = usePaneTabs(data ? { tabs: [...DOE_TABS], activeValue: tab, onSelect: setTab, focused, dense: true } : null);
132
+ const { strip, rows: tabRows } = usePaneTabs({ tabs: [...DOE_TABS], activeValue: tab, onSelect: setTab, focused, dense: true });
133
133
  useAutoRefresh(resource.updatedAt, resource.load);
134
134
  usePaneRefreshKey(() => void resource.reload(), { focused });
135
135
  usePaneNoticeFooter({ registrationId: "doe:notices", focused,
@@ -21,6 +21,7 @@ import {
21
21
  parseScreenDefinition,
22
22
  screenColumnLabel,
23
23
  screenDefinitionSyntax,
24
+ screenCurrencyEmpty,
24
25
  screenLabel,
25
26
  screenYtdBase,
26
27
  withheldMetricNote,
@@ -141,6 +142,14 @@ export const equityScreenerHeadless: HeadlessPaneDefinition<"bundle"> = {
141
142
  const kpiGaps = !withheld && missing > 0 && (KPI_FIELDS as readonly string[]).includes(metric)
142
143
  ? [`${missing} of ${rows.length} ${rows.length === 1 ? "row has" : "rows have"} no ${screenLabel(metric)}. ${KPI_COVERAGE_NOTE}`]
143
144
  : [];
145
+ // A CAD screen of listings quoted in USD is empty for its currency, not for want of companies: say so, and give the screen in USD.
146
+ const currencyEmpty = rows.length === 0 ? screenCurrencyEmpty(data.definition, data.universe) : null;
147
+ const empty = currencyEmpty
148
+ ? [
149
+ `${currencyEmpty.title} ${currencyEmpty.detail}`,
150
+ `In the base currency, ${currencyEmpty.base.currency}: gloomberb fn EQS --definition '${spacedJson(currencyEmpty.base)}'${metric === "marketCap" ? "" : ` --metric ${metric}`}`,
151
+ ].join("\n")
152
+ : null;
144
153
  // Payload status also counts ordinary per-field sparsity; only generation
145
154
  // warnings are failures, and more pages are not an error.
146
155
  return {
@@ -151,6 +160,7 @@ export const equityScreenerHeadless: HeadlessPaneDefinition<"bundle"> = {
151
160
  sections: [
152
161
  {
153
162
  title: "Matching equities",
163
+ ...(empty ? { empty } : {}),
154
164
  columns: [
155
165
  { key: "symbol", header: "Symbol" },
156
166
  { key: "exchange", header: "Exchange" },
@@ -192,6 +192,34 @@ export const DEFAULT_SCREEN: ScreenDefinition = {
192
192
  criteria: [{ field: "marketCap", op: "gte", value: 10_000_000_000 }],
193
193
  sort: { field: "marketCap", direction: "desc" },
194
194
  };
195
+
196
+ /**
197
+ * Why a screen with a currency found nothing, when the currency may be why:
198
+ * `currency` keeps the listings quoted in it and converts nothing, so a CAD
199
+ * screen of listings quoted in USD reads as no company at all. `title` says
200
+ * which listings it kept, `detail` that it converts nothing and what the
201
+ * covered listings are quoted in, and `base` is the same screen in the base
202
+ * currency the default screen uses (USD), to run instead. Null for a screen
203
+ * in that currency or with none.
204
+ */
205
+ export function screenCurrencyEmpty(
206
+ definition: ScreenDefinition,
207
+ universe: Pick<ScreenPayload["universe"], "covered" | "currencies">,
208
+ ): { title: string; detail: string; base: ScreenDefinition } | null {
209
+ const currency = definition.currency;
210
+ const baseCurrency = DEFAULT_SCREEN.currency!;
211
+ if (!currency || currency === baseCurrency) return null;
212
+ const quoted = universe.currencies.filter(Boolean);
213
+ const none = quoted.length > 0 && !quoted.includes(currency);
214
+ return {
215
+ title: none
216
+ ? `None of the ${universe.covered.toLocaleString("en-US")} covered listings is quoted in ${currency}.`
217
+ : `No listing quoted in ${currency} matches.`,
218
+ detail: `Currency ${currency} keeps only the listings quoted in ${currency} and converts nothing${none ? `; the covered listings are quoted in ${quoted.join(", ")}` : ""}.`,
219
+ base: { ...definition, currency: baseCurrency },
220
+ };
221
+ }
222
+
195
223
  export const screenRowId = (row: Pick<ScreenRow, "symbol" | "exchange">) =>
196
224
  `${canonicalExchange(row.exchange)}:${row.symbol}`;
197
225
  export const screenLabel = (field: string) =>
@@ -69,6 +69,7 @@ import {
69
69
  resultFields,
70
70
  SIGN_COLORED,
71
71
  screenColumnLabel,
72
+ screenCurrencyEmpty,
72
73
  screenLabel,
73
74
  screenRowId,
74
75
  screenYtdBase,
@@ -354,6 +355,8 @@ function EquityScreenView({
354
355
  const extendedSessions = screenExtendedSessions(rows);
355
356
  // YTD names the close it is measured from, `31 Dec`.
356
357
  const ytdBase = screenYtdBase(snapshotRows, data?.snapshot?.assembledAt);
358
+ // A screen its currency emptied says so, not that no company matches.
359
+ const currencyEmpty = data && data.rows.length === 0 ? screenCurrencyEmpty(data.definition, data.universe) : null;
357
360
  const columns = resultColumns(width, definition, metric, staleFocus, extendedSessions, ytdBase);
358
361
  const apply = (next: ScreenDefinition) => {
359
362
  try {
@@ -889,11 +892,8 @@ function EquityScreenView({
889
892
  </PaneStatusBody>
890
893
  )
891
894
  ) : (
892
- <PaneStatusBody
893
- loading={results.loading && !data}
894
- error={!data ? results.error : null}
895
- subject="equity screen"
896
- >
895
+ // The metric stays above the results while a new sort loads.
896
+ <>
897
897
  {!opened ? (
898
898
  <QueryBar
899
899
  width={width}
@@ -918,101 +918,108 @@ function EquityScreenView({
918
918
  }]}
919
919
  />
920
920
  ) : null}
921
- <DataTableStackView
922
- columns={columns}
923
- items={rows}
924
- focused={focused && !saveForm}
925
- rootWidth={width}
926
- rootHeight={bodyHeight - 1}
927
- scrollRef={tableScroll}
928
- onBodyScrollActivity={loadMore}
929
- resetScrollKey={JSON.stringify(definition)}
930
- visibleRangeKey={JSON.stringify(definition)}
931
- onVisibleRangeChange={setVisibleRange}
932
- getItemKey={screenRowId}
933
- selection={{
934
- kind: "id",
935
- selectedId: selectedId ?? (rows[0] ? screenRowId(rows[0]) : null),
936
- getId: screenRowId,
937
- onChange: setSelectedId,
938
- }}
939
- onActivate={(row) => setOpenId(screenRowId(row))}
940
- detailOpen={!!opened}
941
- onBack={() => setOpenId(null)}
942
- detailTitle={opened?.name ?? opened?.symbol}
943
- detailContent={
944
- opened ? (
945
- <ScreenDetail
946
- row={opened}
947
- width={width}
948
- height={bodyHeight - 2}
949
- />
950
- ) : null
951
- }
952
- renderCell={(row, column) => {
953
- if (column.id.startsWith(EXTENDED_COLUMN_PREFIX)) {
954
- // The extended print's move from the close in PRICE and CHG%.
955
- const move = screenExtendedMove(row, column.id.slice(EXTENDED_COLUMN_PREFIX.length) as ExtendedSession);
956
- return move == null
957
- ? { text: "--", color: colors.textDim }
958
- : { text: formatPercentRaw(move), color: priceColor(move, colors) };
921
+ <PaneStatusBody
922
+ loading={results.loading && !data}
923
+ error={!data ? results.error : null}
924
+ subject="equity screen"
925
+ >
926
+ <DataTableStackView
927
+ columns={columns}
928
+ items={rows}
929
+ focused={focused && !saveForm}
930
+ rootWidth={width}
931
+ rootHeight={bodyHeight - 1}
932
+ scrollRef={tableScroll}
933
+ onBodyScrollActivity={loadMore}
934
+ resetScrollKey={JSON.stringify(definition)}
935
+ visibleRangeKey={JSON.stringify(definition)}
936
+ onVisibleRangeChange={setVisibleRange}
937
+ getItemKey={screenRowId}
938
+ selection={{
939
+ kind: "id",
940
+ selectedId: selectedId ?? (rows[0] ? screenRowId(rows[0]) : null),
941
+ getId: screenRowId,
942
+ onChange: setSelectedId,
943
+ }}
944
+ onActivate={(row) => setOpenId(screenRowId(row))}
945
+ detailOpen={!!opened}
946
+ onBack={() => setOpenId(null)}
947
+ detailTitle={opened?.name ?? opened?.symbol}
948
+ detailContent={
949
+ opened ? (
950
+ <ScreenDetail
951
+ row={opened}
952
+ width={width}
953
+ height={bodyHeight - 2}
954
+ />
955
+ ) : null
959
956
  }
960
- const field = column.id.startsWith("m:")
961
- ? (column.id.slice(2) as NumericField)
962
- : null;
963
- if (field) {
964
- const observation = row.metrics[field];
965
- const change = SIGN_COLORED.has(field) && observation.value != null
966
- // A move that prints 0.0 stays neutral.
967
- ? priceColor(Math.abs(observation.value) < 0.05 ? 0 : observation.value, colors)
968
- : undefined;
969
- return {
970
- text: formatScreenValue(field, observation.value),
971
- color: observation.state === "stale" ? colors.warning : change,
972
- };
957
+ renderCell={(row, column) => {
958
+ if (column.id.startsWith(EXTENDED_COLUMN_PREFIX)) {
959
+ // The extended print's move from the close in PRICE and CHG%.
960
+ const move = screenExtendedMove(row, column.id.slice(EXTENDED_COLUMN_PREFIX.length) as ExtendedSession);
961
+ return move == null
962
+ ? { text: "--", color: colors.textDim }
963
+ : { text: formatPercentRaw(move), color: priceColor(move, colors) };
964
+ }
965
+ const field = column.id.startsWith("m:")
966
+ ? (column.id.slice(2) as NumericField)
967
+ : null;
968
+ if (field) {
969
+ const observation = row.metrics[field];
970
+ const change = SIGN_COLORED.has(field) && observation.value != null
971
+ // A move that prints 0.0 stays neutral.
972
+ ? priceColor(Math.abs(observation.value) < 0.05 ? 0 : observation.value, colors)
973
+ : undefined;
974
+ return {
975
+ text: formatScreenValue(field, observation.value),
976
+ color: observation.state === "stale" ? colors.warning : change,
977
+ };
978
+ }
979
+ const focus = row.metrics[metric];
980
+ if (column.id === "percentile")
981
+ return { text: rank(focus.percentile.value) };
982
+ if (column.id === "date")
983
+ return focus.state === "stale"
984
+ ? { text: metricDate(focus).text, color: colors.warning }
985
+ : { text: "" };
986
+ if (column.id === "name") return { text: row.name ?? "--" };
987
+ return { text: String(row[column.id as "symbol"] ?? "--") };
988
+ }}
989
+ sortColumnId={
990
+ (NUMERIC_FIELDS as readonly string[]).includes(definition.sort.field)
991
+ ? `m:${definition.sort.field}`
992
+ : definition.sort.field
973
993
  }
974
- const focus = row.metrics[metric];
975
- if (column.id === "percentile")
976
- return { text: rank(focus.percentile.value) };
977
- if (column.id === "date")
978
- return focus.state === "stale"
979
- ? { text: metricDate(focus).text, color: colors.warning }
980
- : { text: "" };
981
- if (column.id === "name") return { text: row.name ?? "--" };
982
- return { text: String(row[column.id as "symbol"] ?? "--") };
983
- }}
984
- sortColumnId={
985
- (NUMERIC_FIELDS as readonly string[]).includes(definition.sort.field)
986
- ? `m:${definition.sort.field}`
987
- : definition.sort.field
988
- }
989
- sortDirection={definition.sort.direction}
990
- onHeaderClick={(id) => {
991
- const field = id.startsWith("m:")
992
- ? (id.slice(2) as NumericField)
993
- : id === "percentile" || id === "date"
994
- ? metric
995
- : ["symbol", "sector", "exchange"].includes(id)
996
- ? (id as ScreenField)
997
- : null;
998
- if (!field) return;
999
- if ((NUMERIC_FIELDS as readonly string[]).includes(field))
1000
- setMetric(field as NumericField);
1001
- apply({
1002
- ...definition,
1003
- sort: {
1004
- field,
1005
- direction:
1006
- definition.sort.field === field &&
1007
- definition.sort.direction === "desc"
1008
- ? "asc"
1009
- : "desc",
1010
- },
1011
- });
1012
- }}
1013
- emptyStateTitle="No equities match this screen."
1014
- />
1015
- </PaneStatusBody>
994
+ sortDirection={definition.sort.direction}
995
+ onHeaderClick={(id) => {
996
+ const field = id.startsWith("m:")
997
+ ? (id.slice(2) as NumericField)
998
+ : id === "percentile" || id === "date"
999
+ ? metric
1000
+ : ["symbol", "sector", "exchange"].includes(id)
1001
+ ? (id as ScreenField)
1002
+ : null;
1003
+ if (!field) return;
1004
+ if ((NUMERIC_FIELDS as readonly string[]).includes(field))
1005
+ setMetric(field as NumericField);
1006
+ apply({
1007
+ ...definition,
1008
+ sort: {
1009
+ field,
1010
+ direction:
1011
+ definition.sort.field === field &&
1012
+ definition.sort.direction === "desc"
1013
+ ? "asc"
1014
+ : "desc",
1015
+ },
1016
+ });
1017
+ }}
1018
+ emptyStateTitle={currencyEmpty?.title ?? "No equities match this screen."}
1019
+ emptyStateHint={currencyEmpty ? `${currencyEmpty.detail} Choose ${currencyEmpty.base.currency} to screen in the base currency.` : undefined}
1020
+ />
1021
+ </PaneStatusBody>
1022
+ </>
1016
1023
  );
1017
1024
  return (
1018
1025
  <Box width={width} height={height} flexDirection="column">
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from "react";
2
2
  import { isAccessDenied } from "../../../api-client/errors";
3
3
  import { usePlanAccess } from "../../../api-client/plan-access";
4
4
  import type { ExposureEvidence, ExposurePayload } from "../../../api-client/exposure";
5
- import { ActionRow, Button, ChartTableHeader, DataTableView, EmptyState, KeyValueRow, PageStackView, PaneStatusBody, QueryBar, SectionHeading, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type DataTableCell, type DataTableColumn, type PaneHint, type StatItem } from "../../../components";
5
+ import { ActionRow, Button, ChartTableHeader, DataTableView, EmptyState, KeyValueRow, PageStackView, QueryBar, SectionHeading, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type DataTableCell, type DataTableColumn, type PaneHint, type StatItem } from "../../../components";
6
6
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
7
7
  import { useAppSelector, useAsyncResource, useAutoRefresh, useMarketData, usePaneInstance, usePaneSettingValue, usePluginAppActions, usePluginPaneState } from "../../../public/react";
8
8
  import { useThemeColors } from "../../../theme/theme-context";
@@ -251,20 +251,21 @@ export function ExposurePane({ width, height, focused }: PaneProps) {
251
251
  <PageStackView focused={focused && !editing} detailOpen={detailOpen} onBack={() => { setDetail(null); setEditing(false); }}
252
252
  detailTitle={editing ? "Scenario and holdings" : detail ? `${detail.symbol} · ${detail.label}` : "What we could not see"}
253
253
  detailContent={editing && !savedSnapshot.error ? <ExposureEditor initial={{ ...inputs, scenario: scenario ?? inputs.scenario }} initialCustom={savedScenario.error ? customSetting : undefined} sources={sources} focused={focused} width={width} onCancel={() => setEditing(false)} onSave={value => { if (repairingCustomSetting.current && customSetting) setCustomSetting(JSON.stringify(value.scenario)); setInputs(value); setScenarioId("custom"); setEditing(false); setDetail(null); }} /> : data ? <ExposureDetail row={detail} data={data} width={width} /> : null}
254
- rootContent={<PaneStatusBody loading={!data && resource.loading} error={inputError ?? (!data ? resource.error : null)} subject="exposure analysis"
255
- actions={savedSnapshot.error ? <Button label="Reset saved snapshot" onPress={() => { setSnapshotSetting(null); setEditing(false); setDetail(null); }} />
256
- : savedScenario.error ? <><Button label="Edit scenario" onPress={() => setEditing(true)} /><Button label="Reset custom scenario" onPress={() => { setCustomSetting(""); setInputs({ ...inputs, scenario: DEFAULT_SCENARIO }); setScenarioId(DEFAULT_SCENARIO.id!); setEditing(false); setDetail(null); }} /></> : undefined}>
257
- {data ? <DataTableView<ExposureRow> columns={columns} items={items} getItemKey={r => r.id} rootWidth={width} rootHeight={bodyHeight} focused={focused && !detailOpen}
254
+ rootContent={!data && !resource.loading && !resource.error && !inputError
255
+ ? <EmptyState title="Choose holdings and a scenario." actions={<ActionRow label="Configure exposure" onPress={() => setEditing(true)} />} />
256
+ // A scenario change reloads under the query bar, which stays.
257
+ : <DataTableView<ExposureRow> columns={columns} items={data ? items : []} getItemKey={r => r.id} rootWidth={width} rootHeight={bodyHeight} focused={focused && !detailOpen}
258
+ status={data && !inputError ? undefined : { loading: !inputError && resource.loading, error: inputError ?? resource.error, subject: "exposure analysis",
259
+ actions: savedSnapshot.error ? <Button label="Reset saved snapshot" onPress={() => { setSnapshotSetting(null); setEditing(false); setDetail(null); }} />
260
+ : savedScenario.error ? <><Button label="Edit scenario" onPress={() => setEditing(true)} /><Button label="Reset custom scenario" onPress={() => { setCustomSetting(""); setInputs({ ...inputs, scenario: DEFAULT_SCENARIO }); setScenarioId(DEFAULT_SCENARIO.id!); setEditing(false); setDetail(null); }} /></> : undefined }}
258
261
  selection={{ kind: "id", selectedId: selected?.id ?? null, getId: r => r.id, onChange: (id) => { if (!id.startsWith("locked:")) setSelected(id); } }}
259
262
  onActivate={row => row.id.startsWith("locked:") ? upgrade() : setDetail(row.id)} selectedTextOverridesCellColor showHorizontalScrollbar
260
263
  sortColumnId={sort.column} sortDirection={sort.direction} onHeaderClick={column => setSort(old => ({ column, direction: old.column === column && old.direction === "desc" ? "asc" : "desc" }))}
261
264
  rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={query} figures={figures}
262
- chart={tab === "paths" ? !selected?.components[0]?.path.length ? null : { render: size => <ExposurePathDiagram component={selected?.components[0] ?? null} {...size} />, minRows: 5, maxRows: 5, strip: null }
265
+ chart={!data ? null : tab === "paths" ? !selected?.components[0]?.path.length ? null : { render: size => <ExposurePathDiagram component={selected?.components[0] ?? null} {...size} />, minRows: 5, maxRows: 5, strip: null }
263
266
  : rows.some(r => r.exposure) ? { render: size => <ExposureRanges rows={rows} showTargets={tab === "portfolio" || data.scenario.shocks.length > 1} selectedId={selected?.id ?? null} onSelect={setSelected} onOpen={setDetail} {...size} />, minRows: 1, maxRows: Math.max(1, Math.min(7, rows.filter(r => r.exposure && r.shockId === selected?.shockId && r.basis === selected?.basis && r.period === selected?.period).length)), strip: null } : null} />}
264
267
  renderCell={renderCell}
265
268
  emptyStateTitle={tab === "drivers" ? "No supported company drivers are available for these holdings." : tab === "paths" ? "No supported exposure paths for this scenario." : "No quantified exposure in this view."}
266
- bodyAfter={lockedRows && desktop ? <LockedOverlay rows={lockedRows} text="Pro: full holdings and deeper exposure paths" onPress={upgrade} role="exposure-upgrade" /> : undefined} />
267
- : !resource.loading && !resource.error ? <EmptyState title="Choose holdings and a scenario." actions={<ActionRow label="Configure exposure" onPress={() => setEditing(true)} />} /> : null}
268
- </PaneStatusBody>} />
269
+ bodyAfter={lockedRows && desktop ? <LockedOverlay rows={lockedRows} text="Pro: full holdings and deeper exposure paths" onPress={upgrade} role="exposure-upgrade" /> : undefined} />} />
269
270
  </Box>;
270
271
  }
@@ -79,9 +79,11 @@ function boardColumns(width: number, options: { windows: GpuChangeKey[]; trend:
79
79
  ];
80
80
  }
81
81
 
82
- export function GpuBoard({ rows, models, asOf, model, setModel, selectedId, select, emptyTitle, width, height, focused }: {
82
+ export function GpuBoard({ rows, models, asOf, model, setModel, selectedId, select, emptyTitle, width, height, focused, pending = false }: {
83
83
  rows: GpuBoardRow[]; models: readonly string[]; asOf: string | null; model: string; setModel: (value: string) => void; selectedId: string; select: (row: GpuBoardRow, open?: boolean) => void;
84
84
  emptyTitle: string; width: number; height: number; focused: boolean;
85
+ /** The picked GPU's free preview is still loading. */
86
+ pending?: boolean;
85
87
  }) {
86
88
  const colors = useThemeColors();
87
89
  const sections = useMemo(() => gpuBoardSections(rows, model), [rows, model]);
@@ -153,6 +155,7 @@ export function GpuBoard({ rows, models, asOf, model, setModel, selectedId, sele
153
155
 
154
156
  const query = <GpuModelQuery models={models} model={model} setModel={setModel} width={width} />;
155
157
  return <DataTableView<BoardRow> columns={columns} items={items} rootWidth={width} rootHeight={height} focused={focused}
158
+ status={pending && !items.length ? { loading: true, subject: "GPU rental prices" } : undefined}
156
159
  rootBefore={ladder.length ? <ChartTableHeader width={width} height={height} tableRows={items.length} tableColumns={columns} query={query}
157
160
  chart={{ maxRows: ladder.length + 1, minRows: Math.min(3, ladder.length + 1), strip: null,
158
161
  render: (size) => <GpuPriceLadder rows={ladder} width={size.width} height={size.height}
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo } from "react";
2
2
  import type { GpuEvent } from "../../../api-client/gpu";
3
3
  import {
4
- Badge, buildSectionedRows, DataTableView, EMPTY_TABLE_CELL, isSectionedItemRow, PaneStatusBody, renderSectionedRowHeader,
4
+ Badge, buildSectionedRows, DataTableView, EMPTY_TABLE_CELL, isSectionedItemRow, renderSectionedRowHeader,
5
5
  usePaneNoticeFooter, usePaneStatusFooter, type DataTableCell, type DataTableColumn, type SectionedRow,
6
6
  } from "../../../components";
7
7
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
@@ -82,13 +82,13 @@ export function GpuChanges({ models, model, setModel, reloadBoard, width, height
82
82
  }
83
83
  };
84
84
 
85
- return <PaneStatusBody loading={resource.loading && !resource.data} error={!resource.data ? resource.error : null} subject="GPU price changes">
86
- <DataTableView<ChangeRow> columns={columns} items={items} rootWidth={width} rootHeight={height} focused={focused}
87
- rootBefore={<GpuModelQuery models={models} model={model} setModel={setModel} width={width} />}
88
- selection={{ kind: "id", selectedId: selected && items.some((item) => item.key === selected) ? selected : firstId, getId: rowKey,
89
- onChange: (id) => setSelected(String(id)) }}
90
- isNavigable={isSectionedItemRow} renderSectionHeader={renderSectionedRowHeader}
91
- getItemKey={rowKey} onActivate={(row) => setSelected(row.key)} sortColumnId={null} sortDirection="desc"
92
- selectedTextOverridesCellColor renderCell={renderCell} emptyStateTitle={resource.data?.payload.access?.locked ? "Full change history is available with Pro." : "No price or availability changes recorded yet."} />
93
- </PaneStatusBody>;
85
+ // A GPU picked from the filter loads under it; the filter stays.
86
+ return <DataTableView<ChangeRow> columns={columns} items={items} rootWidth={width} rootHeight={height} focused={focused}
87
+ rootBefore={<GpuModelQuery models={models} model={model} setModel={setModel} width={width} />}
88
+ status={resource.data ? undefined : { loading: resource.loading, error: resource.error, subject: "GPU price changes" }}
89
+ selection={{ kind: "id", selectedId: selected && items.some((item) => item.key === selected) ? selected : firstId, getId: rowKey,
90
+ onChange: (id) => setSelected(String(id)) }}
91
+ isNavigable={isSectionedItemRow} renderSectionHeader={renderSectionedRowHeader}
92
+ getItemKey={rowKey} onActivate={(row) => setSelected(row.key)} sortColumnId={null} sortDirection="desc"
93
+ selectedTextOverridesCellColor renderCell={renderCell} emptyStateTitle={resource.data?.payload.access?.locked ? "Full change history is available with Pro." : "No price or availability changes recorded yet."} />;
94
94
  }
@@ -39,9 +39,11 @@ function equityColumns(width: number): DataTableColumn[] {
39
39
  }
40
40
 
41
41
  /** Related shares by what ties them to GPU rents, each beside the list price that ties it. */
42
- export function GpuEquities({ board, models, locked, model, setModel, reloadBoard, width, height, focused }: {
42
+ export function GpuEquities({ board, models, locked, model, setModel, reloadBoard, width, height, focused, pending = false }: {
43
43
  board: GpuBoardRow[]; models: readonly string[]; locked: boolean; model: string; setModel: (value: string) => void; reloadBoard: () => void;
44
44
  width: number; height: number; focused: boolean;
45
+ /** The picked GPU's free preview is still loading. */
46
+ pending?: boolean;
45
47
  }) {
46
48
  const colors = useThemeColors();
47
49
  const provider = useAssetData();
@@ -110,6 +112,7 @@ export function GpuEquities({ board, models, locked, model, setModel, reloadBoar
110
112
  }, [colors, desktop]);
111
113
  return <DataTableView<Row> columns={equityColumns(width)} items={items} rootWidth={width} rootHeight={height} focused={focused}
112
114
  rootBefore={<GpuModelQuery models={models} model={model || "H100"} setModel={setModel} width={width} allowAll={false} />}
115
+ status={pending ? { loading: true, subject: "GPU rental prices" } : undefined}
113
116
  selection={{ kind: "id", selectedId: selected, getId: rowKey, onChange: (id) => setSelected(String(id)) }} getItemKey={rowKey}
114
117
  isNavigable={isSectionedItemRow} renderSectionHeader={renderSectionedRowHeader}
115
118
  getRowVersion={(row) => isSectionedItemRow(row) ? [quotes.get(row.item.symbol)?.quote?.lastUpdated, quotes.get(row.item.symbol)?.loading,
@@ -92,9 +92,11 @@ function HistoryDetail({ row, peers, width, height, refreshVersion }: {
92
92
  </PaneStatusBody>;
93
93
  }
94
94
 
95
- export function GpuHistory({ rows, models: boardModels, model, setModel, selectedId, onSelect, reloadBoard, width, height, focused }: {
95
+ export function GpuHistory({ rows, models: boardModels, model, setModel, selectedId, onSelect, reloadBoard, width, height, focused, pending = false }: {
96
96
  rows: GpuBoardRow[]; models: readonly string[]; model: string; setModel: (value: string) => void; selectedId: string; onSelect: (id: string) => void;
97
97
  reloadBoard: () => void; width: number; height: number; focused: boolean;
98
+ /** The picked GPU's free preview is still loading. */
99
+ pending?: boolean;
98
100
  }) {
99
101
  const colors = useThemeColors();
100
102
  const [basis, setBasis] = usePluginPaneState<string>("historyBasis", "all");
@@ -133,7 +135,7 @@ export function GpuHistory({ rows, models: boardModels, model, setModel, selecte
133
135
  searchPlaceholder="Provider or variant" columns={listColumns} rows={listRows} getRowId={listRowId}
134
136
  filters={[{ id: "gpu", label: "GPU", value: activeModel, options: models.map((value) => ({ value, label: value })), onChange: setModel }]}
135
137
  isNavigable={isSectionedItemRow} renderSectionHeader={renderSectionedRowHeader}
136
- renderCell={renderCell} emptyStateTitle="No series match."
138
+ renderCell={renderCell} emptyStateTitle="No series match." status={pending && !listRows.length ? { loading: true, subject: "GPU rental prices" } : undefined}
137
139
  renderDetail={(size) => list.selected ? <HistoryDetail key={list.selected.id} row={list.selected} peers={modelRows} refreshVersion={refreshVersion} {...size} />
138
140
  : <PaneStatusBody empty emptyTitle={`No ${gpuBasisLabel(basis === "all" ? "list" : basis as GpuBasis)} series yet.`} />} />;
139
141
  }
@@ -61,13 +61,15 @@ function GpuView({ width, height, focused }: PaneProps) {
61
61
  usePaneFooter("gpu:actions", () => ({ info: data?.access?.preview ? [{ id: "preview", parts: [{ text: "Free preview", tone: "muted" }] }] : [],
62
62
  hints: [...(data?.access?.locked ? [{ id: "upgrade", key: CLOUD_PLAN_KEY, label: "upgrade", title: "Unlock full GPU history with Pro", onPress: upgrade }] : []), { id: "buildout", key: "t", label: "BO", title: "Open TheBuildout",
63
63
  onPress: () => createPaneFromTemplate("buildout-pane") }] }), [createPaneFromTemplate, data?.access, upgrade]);
64
- const { strip, rows: tabRows } = usePaneTabs(data ? { tabs: [...GPU_TABS], activeValue: tab, onSelect: setTab, focused, dense: true } : null);
65
- const body = { width, height: Math.max(3, height - tabRows), focused };
64
+ const { strip, rows: tabRows } = usePaneTabs(!signIn ? { tabs: [...GPU_TABS], activeValue: tab, onSelect: setTab, focused, dense: true } : null);
65
+ // Another GPU's free preview waits in the table under the GPU filter, so picking a model never takes the filter away.
66
+ const pending = !!previewKey && preview.loading && !preview.data;
67
+ const body = { width, height: Math.max(3, height - tabRows), focused, pending };
66
68
  const reloadBoard = useCallback(() => { void resource.reload(); void preview.reload(); }, [resource.reload, preview.reload]);
67
69
  if (signIn) return <SignInWall placement="gpu" width={width} height={height} action="browse GPU rental prices" needsVerification={session.needsVerification} />;
68
70
  return <Box width={width} height={height} flexDirection="column">
69
71
  {strip}
70
- <PaneStatusBody loading={(resource.loading && !data) || (!!previewKey && preview.loading && !preview.data)} error={!data && !notAvailable ? resource.error : null}
72
+ <PaneStatusBody loading={resource.loading && !data} error={!data && !notAvailable ? resource.error : null}
71
73
  empty={notAvailable} emptyTitle={GPU_NOT_AVAILABLE} subject="GPU rental prices">
72
74
  {data ? tab === "board" ? <GpuBoard rows={rows} models={models} asOf={data.asOf} model={activeModel} setModel={setModel} selectedId={selectedId} emptyTitle={emptyTitle}
73
75
  select={(row, open) => { setSelectedId(row.id); if (open) setTab("history"); }} {...body} />
@@ -1,6 +1,7 @@
1
1
  import type { DataProvider, MarketDataRequestContext } from "../../../types/data-provider";
2
2
  import type { HolderData, TickerFinancials } from "../../../types/financials";
3
3
  import { holderStakeMarketCap, holderValueCurrency } from "./format";
4
+ import { logFailure } from "../../../utils/log-failure";
4
5
 
5
6
  export async function loadHolderData(
6
7
  provider: DataProvider,
@@ -34,7 +35,7 @@ export async function loadHolderSnapshot(
34
35
  ): Promise<{ data: HolderData; marketCap?: number; sharesOutstanding?: number | null }> {
35
36
  const [data, financials] = await Promise.all([
36
37
  loadHolderData(provider, symbol, exchange),
37
- provider.getTickerFinancials(symbol, exchange).catch(() => null),
38
+ provider.getTickerFinancials(symbol, exchange).catch(logFailure("holders.client", "Read holder reference financials failed", null)),
38
39
  ]);
39
40
  const quote = financials?.quote;
40
41
  return {