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
package/src/i18n/zh-cn.ts CHANGED
@@ -1020,6 +1020,28 @@ export const zhCN: Record<string, string> = {
1020
1020
  "Checking for updates...": "正在检查更新...",
1021
1021
  "Downloading v{version}: {percent}%": "正在下载 v{version}:{percent}%",
1022
1022
  "Update failed: {error}": "更新失败:{error}",
1023
+ "Update failed": "更新失败",
1024
+ "Update check failed: {reason}": "检查更新失败:{reason}",
1025
+ "no connection": "无网络连接",
1026
+ "connection timed out": "连接超时",
1027
+ "GitHub rate limit": "GitHub 请求限制",
1028
+ "release server error": "发布服务器错误",
1029
+ "unreadable release data": "无法读取发布信息",
1030
+ "no build for this system": "没有适用于此系统的版本",
1031
+ "download interrupted": "下载中断",
1032
+ "download corrupted": "下载已损坏",
1033
+ "no permission to replace the app": "没有替换应用的权限",
1034
+ "move the app to Applications": "请将应用移到“应用程序”文件夹",
1035
+ "app and update on different disks": "应用与更新位于不同的磁盘",
1036
+ "install folder is read-only": "安装文件夹为只读",
1037
+ "disk full": "磁盘已满",
1038
+ "app files in use": "应用文件正在使用",
1039
+ "downloaded update missing": "找不到已下载的更新",
1040
+ "check failed": "检查失败",
1041
+ "download failed": "下载失败",
1042
+ "could not verify the download": "无法验证下载",
1043
+ "could not install": "无法安装",
1044
+ "could not restart": "无法重新启动",
1023
1045
  "v{version} available — run {command}": "v{version} 可用 — 执行 {command}",
1024
1046
  "v{version} available, update through the package manager that installed Gloomberb": "v{version} 可用,请通过安装 Gloomberb 的包管理器更新",
1025
1047
  "v{version} available — starting download...": "v{version} 可用 — 开始下载...",
package/src/i18n/zh-tw.ts CHANGED
@@ -1020,6 +1020,28 @@ export const zhTW: Record<string, string> = {
1020
1020
  "Checking for updates...": "正在檢查更新...",
1021
1021
  "Downloading v{version}: {percent}%": "正在下載 v{version}:{percent}%",
1022
1022
  "Update failed: {error}": "更新失敗:{error}",
1023
+ "Update failed": "更新失敗",
1024
+ "Update check failed: {reason}": "檢查更新失敗:{reason}",
1025
+ "no connection": "無網路連線",
1026
+ "connection timed out": "連線逾時",
1027
+ "GitHub rate limit": "GitHub 請求限制",
1028
+ "release server error": "發布伺服器錯誤",
1029
+ "unreadable release data": "無法讀取發布資訊",
1030
+ "no build for this system": "沒有適用於此系統的版本",
1031
+ "download interrupted": "下載中斷",
1032
+ "download corrupted": "下載已損毀",
1033
+ "no permission to replace the app": "沒有取代應用程式的權限",
1034
+ "move the app to Applications": "請將應用程式移到「應用程式」資料夾",
1035
+ "app and update on different disks": "應用程式與更新位於不同的磁碟",
1036
+ "install folder is read-only": "安裝資料夾為唯讀",
1037
+ "disk full": "磁碟已滿",
1038
+ "app files in use": "應用程式檔案正在使用中",
1039
+ "downloaded update missing": "找不到已下載的更新",
1040
+ "check failed": "檢查失敗",
1041
+ "download failed": "下載失敗",
1042
+ "could not verify the download": "無法驗證下載",
1043
+ "could not install": "無法安裝",
1044
+ "could not restart": "無法重新啟動",
1023
1045
  "v{version} available — run {command}": "v{version} 可用 — 執行 {command}",
1024
1046
  "v{version} available, update through the package manager that installed Gloomberb": "v{version} 可用,請透過安裝 Gloomberb 的套件管理器更新",
1025
1047
  "v{version} available — starting download...": "v{version} 可用 — 開始下載...",
@@ -1,4 +1,5 @@
1
1
  import type { AlertCondition, AlertRule } from "./types";
2
+ import { logFailure } from "../../../utils/log-failure";
2
3
 
3
4
  export type { AlertRule };
4
5
 
@@ -97,7 +98,8 @@ export function deserializeAlerts(json: string): AlertRule[] {
97
98
  return parsed.filter((a: any) =>
98
99
  a?.id && a?.symbol && a?.condition && typeof a?.targetPrice === "number"
99
100
  );
100
- } catch {
101
+ } catch (error) {
102
+ logFailure("alerts.alert-engine", "Read saved price alerts failed")(error);
101
103
  return [];
102
104
  }
103
105
  }
@@ -135,6 +135,7 @@ export function readEventAlerts(json: string): { rules: EventAlertRule[]; error:
135
135
  }
136
136
  return { rules, error: null };
137
137
  } catch {
138
+ // Intentional ignore: invalid saved rules return the error displayed by callers.
138
139
  return { rules: [], error: "Saved event alerts could not be read." };
139
140
  }
140
141
  }
@@ -230,6 +230,7 @@ export function normalizeResearchRule(
230
230
  }
231
231
  return JSON.stringify(result)
232
232
  } catch {
233
+ // Intentional ignore: malformed research input is rejected with the null validation result.
233
234
  return null
234
235
  }
235
236
  }
@@ -632,7 +632,7 @@ export function PortfolioRiskPane({ focused, width, height }: PaneProps) {
632
632
  };
633
633
  // The view strip takes a row when it is not in the title bar; the portfolio
634
634
  // bar, the holdings summary and the chart sit on the root view only.
635
- const { strip: tabStrip, rows: tabRows } = usePaneTabs(portfolio && model ? {
635
+ const { strip: tabStrip, rows: tabRows } = usePaneTabs(portfolio ? {
636
636
  tabs,
637
637
  activeValue: view,
638
638
  onSelect: selectView,
@@ -650,12 +650,15 @@ export function PortfolioRiskPane({ focused, width, height }: PaneProps) {
650
650
  );
651
651
  if (!model)
652
652
  return (
653
- <PaneStatusBody
654
- loading={resource.loading}
655
- error={resource.error}
656
- loadingLabel="Loading portfolio risk"
657
- emptyTitle="Risk data unavailable"
658
- />
653
+ <Box width={width} height={height} flexDirection="column">
654
+ {tabStrip}
655
+ <PaneStatusBody
656
+ loading={resource.loading}
657
+ error={resource.error}
658
+ loadingLabel="Loading portfolio risk"
659
+ emptyTitle="Risk data unavailable"
660
+ />
661
+ </Box>
659
662
  );
660
663
  return (
661
664
  <Box width={width} height={height} flexDirection="column">
@@ -123,7 +123,8 @@ export function AttentionPane({ width, height, focused }: PaneProps) {
123
123
  priceChangePct: null, relativeVolume: null, marketAsOf: null, relativeVolumeAsOf: null, news: [],
124
124
  } : undefined);
125
125
  useAttentionEvidence((detailMode ? detailData : data) ?? null, tab, selectedSymbol, tab === "history" ? selected?.history.length ?? 0 : rows.length);
126
- const { strip, rows: tabRows } = usePaneTabs((data || detailData) && (data?.rows.length || data?.status === "ready" || !!initialSymbol || detailMode && !!selectedSymbol) ? { tabs: [...ATTENTION_TABS], activeValue: tab, onSelect: setTab, focused, dense: true } : null);
126
+ const signInWall = !data && isCloudSessionRequired(resource.error);
127
+ const { strip, rows: tabRows } = usePaneTabs(!signInWall ? { tabs: [...ATTENTION_TABS], activeValue: tab, onSelect: setTab, focused, dense: true } : null);
127
128
  useAutoRefresh(resource.updatedAt, resource.load);
128
129
  usePaneRefreshKey(() => { void resource.reload(); if (detailMode) void detail.reload(); }, { focused });
129
130
  const hints: PaneHint[] = [
@@ -142,7 +143,7 @@ export function AttentionPane({ width, height, focused }: PaneProps) {
142
143
  usePaneNoticeFooter({ registrationId: "attention:notices", focused, notices: [resource.data?.refreshError, detailMode ? detail.data?.refreshError : null,
143
144
  data?.status === "ready" && selected?.zScore === null ? "The selected ticker has insufficient qualified history for an abnormal-attention score." : null,
144
145
  ].filter((value): value is string => !!value) });
145
- if (!data && isCloudSessionRequired(resource.error)) return <SignInWall placement="attention-signin" action="view research attention" needsVerification={session.needsVerification} />;
146
+ if (signInWall) return <SignInWall placement="attention-signin" action="view research attention" needsVerification={session.needsVerification} />;
146
147
  const bodyHeight = Math.max(3, height - tabRows);
147
148
  const queryBar = <QueryBar width={width} filters={[{ id: "window", label: "Period", inline: true, value: window, options: WINDOWS,
148
149
  onChange: (value: string) => setWindow(value) }, ...(detailMode && data?.rows.length ? [{ id: "ticker", label: "Ticker", value: selectedSymbol,
@@ -190,24 +191,25 @@ export function AttentionPane({ width, height, focused }: PaneProps) {
190
191
  ...(topSector ? [{ id: "sector", label: "Top sector", ...topSector }] : []),
191
192
  ...(topCountry ? [{ id: "country", label: "Top country", ...topCountry }] : []),
192
193
  ] : [];
194
+ // The strip and the period filter stay while a new period loads; only the body waits.
193
195
  return <Box width={width} height={height} flexDirection="column">{strip}
194
- <PaneStatusBody loading={!data && resource.loading} error={!data ? resource.error : null} empty={!detailMode && !!data && data.status !== "ready" && !data.rows.length}
195
- emptyTitle={emptyAttention(data)} subject="research attention">
196
- {data ? detailMode ? <Box flexGrow={1} flexBasis={0} flexDirection="column">{queryBar}
197
- <PaneStatusBody loading={!detailData && detail.loading} error={!detailData ? detail.error : null} empty={!!detailData && !selected}
198
- emptyTitle={data.entitlement === "preview" ? "This ticker is outside the current preview." : "No privacy-qualified hours for this ticker."} subject="ticker attention">
199
- {selected ? tab === "history" ? <History row={selected} width={width} height={bodyHeight - 1} focused={focused} preview={data.entitlement === "preview"} />
200
- : <Evidence row={selected} data={detailData ?? data} width={width} height={bodyHeight - 1} /> : null}
201
- </PaneStatusBody>
202
- </Box> : <DataTableView columns={columns} items={items} rootWidth={width} rootHeight={bodyHeight} focused={focused && !search.active}
203
- rootBefore={figures.length ? <ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={queryBar} figures={figures} /> : queryBar}
196
+ {detailMode ? <Box flexGrow={1} flexBasis={0} flexDirection="column">{queryBar}
197
+ <PaneStatusBody loading={!data ? resource.loading : !detailData && detail.loading} error={!data ? resource.error : !detailData ? detail.error : null}
198
+ empty={!!data && !!detailData && !selected} subject={!data ? "research attention" : "ticker attention"}
199
+ emptyTitle={data?.entitlement === "preview" ? "This ticker is outside the current preview." : "No privacy-qualified hours for this ticker."}>
200
+ {data && selected ? tab === "history" ? <History row={selected} width={width} height={bodyHeight - 1} focused={focused} preview={data.entitlement === "preview"} />
201
+ : <Evidence row={selected} data={detailData ?? data} width={width} height={bodyHeight - 1} /> : null}
202
+ </PaneStatusBody>
203
+ </Box> : <DataTableView columns={columns} items={data ? items : []} rootWidth={width} rootHeight={bodyHeight} focused={focused && !search.active}
204
+ status={!data ? { loading: resource.loading, error: resource.error, subject: "research attention" }
205
+ : data.status !== "ready" && !data.rows.length ? { empty: true, emptyTitle: emptyAttention(data), subject: "research attention" } : undefined}
206
+ rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={queryBar} figures={figures} />}
204
207
  getItemKey={rowId} selection={{ kind: "id", selectedId: groupMode ? groupSelectedId : selected?.symbol ?? null, getId: rowId, onChange: (id) => { if (groupMode) setGroupSelected(id); else if (!id.startsWith("locked:")) setSelected(id); } }}
205
208
  onActivate={(row) => { if (isLocked(row)) openUpgrade(); else if ("symbol" in row) { setSelected(row.symbol); setTab("history"); } else { setGroupFilter({ field: tab === "countries" ? "country" : "sector", value: row.name }); setQuery(""); setSelected(null); setTab("ranking"); } }}
206
209
  renderCell={cell} selectedTextOverridesCellColor showHorizontalScrollbar
207
210
  sortColumnId={!groupMode ? tab === "abnormal" ? "zScore" : sort.column : null} sortDirection={tab === "abnormal" ? "desc" : sort.direction}
208
211
  onHeaderClick={!groupMode && tab !== "abnormal" ? (column) => setSort((current) => ({ column, direction: current.column === column && current.direction === "desc" ? "asc" : "desc" })) : undefined}
209
212
  emptyStateTitle={tab === "abnormal" ? "Collecting enough qualified history for abnormal attention." : "No published research matches."}
210
- bodyAfter={hidden && desktop ? <LockedOverlay rows={Math.min(3, hidden)} text="Pro unlocks the full attention graph" onPress={openUpgrade} /> : undefined} /> : null}
211
- </PaneStatusBody>
213
+ bodyAfter={hidden && desktop ? <LockedOverlay rows={Math.min(3, hidden)} text="Pro unlocks the full attention graph" onPress={openUpgrade} /> : undefined} />}
212
214
  </Box>;
213
215
  }
@@ -221,15 +221,16 @@ function AwardsView({ width, height, focused, symbol }: PaneProps & { symbol: st
221
221
  if (denied || !data && isCloudSessionRequired(failed?.message)) return <SignInWall placement="awards-signin" action="view government awards" needsVerification={session.needsVerification} />;
222
222
  return <Box width={width} height={height} flexDirection="column">
223
223
  {strip}
224
- <PaneStatusBody loading={paged.loading && !data} error={!data && failed?.message !== AWARDS_UNAVAILABLE ? failed?.message : null}
225
- empty={!data && failed?.message === AWARDS_UNAVAILABLE} emptyTitle={AWARDS_UNAVAILABLE} subject="government awards">
226
- {data ? <PageStackView focused={focused} detailOpen={!!openId} onBack={() => setOpen(null)} detailTitle={openRow ? truncateToDisplayWidth(openRow.title, Math.max(20, width - 16)) : "Award evidence"}
224
+ <PageStackView focused={focused} detailOpen={!!openId} onBack={() => setOpen(null)} detailTitle={openRow ? truncateToDisplayWidth(openRow.title, Math.max(20, width - 16)) : "Award evidence"}
227
225
  detailContent={openRow ? <AwardDetail key={openRow.id} row={openRow} accessKey={accessKey} width={width} height={bodyHeight - 1} focused={focused} openAward={setOpen} openUpgrade={openUpgrade}
228
226
  openChildren={(type) => { setParentId(openRow.id); setTicker(""); setSource(""); setQuery(""); setAgency(""); setSector(""); setCurrency(""); setJurisdiction(""); setRange("all"); setType(type); setOpen(null); setTab("feed"); }} />
229
227
  : <PaneStatusBody loading={detail.loading} error={detail.error} empty={!detail.loading && !detail.error} emptyTitle="Award no longer available." subject="award evidence" />}
230
228
  rootContent={<DataTableView<Item> columns={columns} items={tab === "company" && !ticker ? [] : items} rootWidth={width} rootHeight={bodyHeight}
229
+ // A filter change loads under the query bar, which stays.
230
+ status={data ? undefined : { loading: paged.loading, error: failed?.message !== AWARDS_UNAVAILABLE ? failed?.message : null,
231
+ empty: failed?.message === AWARDS_UNAVAILABLE, emptyTitle: AWARDS_UNAVAILABLE, subject: "government awards" }}
231
232
  focused={focused && !openId && !search.active && !tickerSearch.active} scrollRef={tableScrollRef} onBodyScrollActivity={loadMore}
232
- rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={queryBar} chart={chart}
233
+ rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={items.length} tableColumns={columns} query={queryBar} chart={data ? chart : null}
233
234
  figures={tab === "company" && ticker && latest ? [{ id: "total", label: `Obligated ${chartCurrency}`, value: money(latest.cumulativeObligatedAmount) },
234
235
  { id: "period", label: "Latest cohort", value: latest.month.slice(0, 7), detail: awardScope(chartSource) }] : undefined} />}
235
236
  selection={{ kind: "id", selectedId: selected ? itemId(selected) : null, getId: itemId, onChange: setSelected }} getItemKey={itemId}
@@ -277,7 +278,6 @@ function AwardsView({ width, height, focused, symbol }: PaneProps & { symbol: st
277
278
  return { text: award.revenueComparison?.periodEnd ?? "--", color: colors.textDim };
278
279
  }}
279
280
  emptyStateTitle={tab === "company" && !ticker ? "Enter a company ticker." : tab === "events" ? "No revenue-comparable awards in this scope." : "No awards match these filters."}
280
- bodyAfter={data.locked && desktop ? <LockedOverlay rows={3} text="Upgrade for complete awards and history" onPress={openUpgrade} /> : undefined} />} /> : null}
281
- </PaneStatusBody>
281
+ bodyAfter={data?.locked && desktop ? <LockedOverlay rows={3} text="Upgrade for complete awards and history" onPress={openUpgrade} /> : undefined} />} />
282
282
  </Box>;
283
283
  }
@@ -15,7 +15,7 @@ export function backtestEquityCsv(report: HeadlessBundleResult, symbol: string,
15
15
  const tables = report.sections.flatMap((section): CliReportTable[] => {
16
16
  if (!section.rows) return [];
17
17
  // The curve's one section, identified as `headlessReportTables` identifies it (no per-row units).
18
- const table = exportRowsTable(section.title, section.columns ?? [], section.rows);
18
+ const table = exportRowsTable(section.title, section.columns ?? [], section.rows, { status: true });
19
19
  return [{
20
20
  ...table,
21
21
  identity: { ...(section.unit ? { currencyOrUnit: section.unit } : {}), ...(section.interval ? { interval: section.interval } : {}) },
@@ -89,8 +89,10 @@ export function ResearchTradeWorkspace({ width, height, focused, symbol, tabId,
89
89
  }, [captureTicket, selectTab, tabId]);
90
90
  const split = visible && docked;
91
91
  const ticketFocused = split && panelFocused;
92
- const context = useMemo(() => ({ ...projection, symbol, available, docked, visible, ticketFocused, open: openTicket, close, publish, ...(available ? { orders } : {}) }),
93
- [projection, symbol, available, docked, visible, ticketFocused, openTicket, close, orders]);
92
+ // The chart consumes its own presses, so the box below never hears them: it says so itself.
93
+ const focusChart = useCallback(() => setPanelFocused(false), []);
94
+ const context = useMemo(() => ({ ...projection, symbol, available, docked, visible, ticketFocused, open: openTicket, close, focusChart, publish, ...(available ? { orders } : {}) }),
95
+ [projection, symbol, available, docked, visible, ticketFocused, openTicket, close, focusChart, orders]);
94
96
  useShortcut((event) => {
95
97
  if (event.name !== "f6" || event.repeat) return;
96
98
  event.preventDefault(); event.stopPropagation(); captureTicket(false); setPanelFocused((value) => !value);
@@ -174,13 +174,15 @@ function ProfileTicket({ broker, instance, contract, width, height, focused, ord
174
174
  const timer = setInterval(refresh, 30_000);
175
175
  return () => { active = false; clearInterval(timer); if (refreshResources.current === refresh) refreshResources.current = () => {}; };
176
176
  }, [broker, instance, connected, visible, selectAccount, allowAutoAccount, statusVersion]);
177
- // A new ticket's limit defaults to its side of the quote, from the first quote that has a price, asked for
178
- // or streamed, until the trader sets one. A stop is the trader's: at the touch it would trigger at once.
177
+ // A new ticket's limit defaults to its side of the quote, from the first current quote that has a price,
178
+ // asked for or streamed, until the trader sets one. A stop is the trader's: at the touch it would trigger at
179
+ // once. A stale quote (before the open, the last session's book) is no price to trade at: the ticket shows
180
+ // its age and waits for a current one, which a broker's stream brings within seconds.
179
181
  const pricePending = useRef(false);
180
182
  const prefillPrice = useCallback(() => {
181
183
  const latest = controller.getSnapshot();
182
184
  const currentDraft = latest.draft, q = latest.quote?.quote;
183
- if (!pricePending.current || !currentDraft || !q || latest.phase !== "editing" || latest.modifying) return;
185
+ if (!pricePending.current || !currentDraft || !q || latest.quote?.stale !== false || latest.phase !== "editing" || latest.modifying) return;
184
186
  const source = currentDraft.action === "BUY" && q.ask !== undefined ? "ask" : currentDraft.action === "SELL" && q.bid !== undefined ? "bid" : "last";
185
187
  try {
186
188
  if ((currentDraft.orderType === "LMT" || currentDraft.orderType === "STP LMT") && currentDraft.limitPrice === undefined) {
@@ -392,7 +394,7 @@ function ProfileTicket({ broker, instance, contract, width, height, focused, ord
392
394
  brokerName: broker.name, symbol: orderInstrumentLabel(receipt?.request ?? draft ?? { contract: ticketContract }), accounts, accountId,
393
395
  accountChoices, accountChoiceValue: accountChoices && accountId ? ticketAccountChoiceKey(instance.id, accountId) : undefined,
394
396
  accountType: accounts.find((account) => account.accountId === accountId)?.accountType,
395
- quote: state.quote?.quote, legQuotes: state.quote?.legs, quoteData: connection?.quoteData,
397
+ quote: state.quote?.quote, quoteStale: state.quote?.stale, legQuotes: state.quote?.legs, quoteData: connection?.quoteData,
396
398
  phase: page === "enable" ? "enable" : state.phase, draft: receipt?.request ?? draft, preview: state.review?.preview ?? receipt?.preview, warnings: state.review?.warnings ?? [], result: state.result, resultQuantities: state.resultQuantities,
397
399
  error: connection?.state === "error" && connection.message ? connection.message : error ?? state.error, reconnecting,
398
400
  position: position?.quantity, avgCost: position?.avgCost, positionPnl: position?.pnl, tradingEnabled: caps?.enabled === true,
@@ -20,6 +20,8 @@ export interface TicketViewModel {
20
20
  brokerName: string; symbol: string; accounts: BrokerAccount[]; accountId?: string;
21
21
  accountChoices?: readonly BrokerAccountChoice[]; accountChoiceValue?: string;
22
22
  quote?: Quote; quoteData?: "realtime" | "delayed";
23
+ /** The quote is not current (older than the ticket accepts, or of unknown age): it shows its age beside it. */
24
+ quoteStale?: boolean;
23
25
  /** A spread's legs' quotes, in leg order. `quote` is then the spread's own. */
24
26
  legQuotes?: Quote[];
25
27
  phase: "enable" | "editing" | "previewing" | "review" | "submitting" | "result" | "cancel-review" | "cancelling";
@@ -119,10 +121,11 @@ function TicketNumberInput({ value, focused, onChange, ...props }: {
119
121
  /**
120
122
  * The docked quote: last price and change first, then bid in green and ask in
121
123
  * red with their sizes, as a broker's quote panel reads. A delayed quote shows
122
- * a clock whose tooltip names the source and its age.
124
+ * a clock whose tooltip names the source and its age; a stale one says its age
125
+ * beside the price, so the last session's book never reads as the market.
123
126
  */
124
- function DockedQuote({ quote, delayed, source, updated, price, change, width }: {
125
- quote?: Quote; delayed: boolean; source: string; updated: string; price(value: number | undefined): string; change: string; width: number;
127
+ function DockedQuote({ quote, delayed, stale, source, updated, price, change, width }: {
128
+ quote?: Quote; delayed: boolean; stale?: string; source: string; updated: string; price(value: number | undefined): string; change: string; width: number;
126
129
  }) {
127
130
  const c = useThemeColors();
128
131
  const native = useUiCapabilities().nativePaneChrome;
@@ -139,6 +142,7 @@ function DockedQuote({ quote, delayed, source, updated, price, change, width }:
139
142
  <FigureText>{quote?.price === undefined ? "--" : price(quote.price)}</FigureText>
140
143
  <Box flexGrow={1} minWidth={0}><FigureText part="sub" change={quote?.change}>{change}</FigureText></Box>
141
144
  {delayed ? <Box flexShrink={0} title={`${source} · ${updated}`}><Icon name="clock" color={c.warning} /></Box> : null}
145
+ {stale ? <Text fg={c.warning} flexShrink={0}>{stale}</Text> : null}
142
146
  </Box>
143
147
  <Box flexDirection="row" gap={2}>
144
148
  {side("Bid", quote?.bid, quote?.bidSize, c.positive)}
@@ -377,6 +381,7 @@ export function BrokerTicketView({ model: m, width, height, focused, onEdit, onA
377
381
  const updated = sourceAt && Number.isFinite(sourceAt) ? `${new Date(sourceAt).toISOString().slice(11, 19)} UTC` : "unknown";
378
382
  const delayed = m.quoteData !== "realtime" || m.quote?.dataSource !== "live";
379
383
  const age = ticketQuoteAge(m.quote, m.now).replace(" old", "");
384
+ const stale = m.quote && m.quoteStale ? ticketQuoteAge(m.quote, m.now) : undefined;
380
385
  const source = `${m.brokerName}, ${delayed ? "delayed" : "real-time"} ${age}`;
381
386
  const accountPicker = (pickerWidth: number) => <BrokerAccountPicker
382
387
  accounts={m.accountChoices ?? m.accounts.map((a) => ({ value: a.accountId, label: a.name, tradingMode: a.tradingMode }))}
@@ -402,7 +407,7 @@ export function BrokerTicketView({ model: m, width, height, focused, onEdit, onA
402
407
  <Box ref={nodeRef("account")} flexShrink={1} minWidth={0}>{accountPicker(width - (onClose ? 6 : 2))}</Box>
403
408
  {onClose ? <IconButton icon="close" label="Close trade panel" shortcut="Esc" onPress={onClose} /> : null}
404
409
  </Box>
405
- <DockedQuote quote={m.quote} delayed={delayed} source={source} updated={updated} price={quotePrice} change={change} width={width - 2} />
410
+ <DockedQuote quote={m.quote} delayed={delayed} stale={stale} source={source} updated={updated} price={quotePrice} change={change} width={width - 2} />
406
411
  </Box> : <Box flexShrink={0}>
407
412
  <Box flexDirection="column" data-gloom-selectable="true" paddingX={native ? 3 : 1} paddingY={native ? wide ? 1 : 0.5 : 0} gap={native ? wide ? 0.65 : 0.2 : 0} >
408
413
  <Box flexDirection={narrow ? "column" : "row"} justifyContent="space-between" alignItems={narrow ? "flex-start" : "center"} gap={narrow ? 0.4 : 1}>
@@ -429,7 +434,7 @@ export function BrokerTicketView({ model: m, width, height, focused, onEdit, onA
429
434
  <Box flexDirection="row" flexWrap={native ? "wrap" : "nowrap"} alignItems="center" gap={1}>
430
435
  {/* A delayed quote is the yellow clock, as in pane footers, with the detail on hover. */}
431
436
  {delayed ? <Box flexShrink={0} title={source}><Icon name="clock" color={c.warning} /></Box> : null}
432
- <Text fg={c.textDim}>{delayed ? `${m.brokerName} ${age}` : source}</Text>
437
+ <Text fg={stale ? c.warning : c.textDim}>{stale ? `${m.brokerName}, ${stale}` : delayed ? `${m.brokerName} ${age}` : source}</Text>
433
438
  <Text fg={c.textDim}>{`${updated}${m.synthetic && !wide && !narrow ? " · SYNTHETIC" : ""}`}</Text>
434
439
  </Box>
435
440
  </Box>
@@ -379,12 +379,15 @@ export function BuildoutPane({ focused, width, height }: PaneProps) {
379
379
  variant: "bare",
380
380
  });
381
381
 
382
- if (state.status === "loading") {
383
- return <PaneStatusBody loading subject={BUILDOUT_NAME} width={width} height={height} />;
384
- }
385
-
386
- if (state.status === "error") {
387
- return <PaneStatusBody error={state.message} subject={BUILDOUT_NAME} width={width} height={height} retry={{ onRetry: refresh }} />;
382
+ // The strip stays over the first load and a failed one, as over every tab's own.
383
+ if (state.status === "loading" || state.status === "error") {
384
+ return (
385
+ <Box flexDirection="column" width={width} height={height} overflow="hidden">
386
+ {tabStrip && <Box height={1}>{tabStrip}</Box>}
387
+ {state.status === "loading" ? <PaneStatusBody loading subject={BUILDOUT_NAME} />
388
+ : <PaneStatusBody error={state.message} subject={BUILDOUT_NAME} retry={{ onRetry: refresh }} />}
389
+ </Box>
390
+ );
388
391
  }
389
392
 
390
393
  const listOpen = activeTab === "companies" && !!selectedList;
@@ -23,6 +23,6 @@ export function validateCatalystDetail(payload: CatalystDetail, id?: string): Ca
23
23
  export const fetchCatalysts = async (query: CatalystFilters, client: Pick<typeof apiClient, "getCloudCatalysts"> = apiClient, signal?: AbortSignal) => validateCatalysts(await client.getCloudCatalysts(query, signal));
24
24
  export const fetchCatalystDetail = async (id: string, client: Pick<typeof apiClient, "getCloudCatalystEvent"> = apiClient, signal?: AbortSignal, query: { offset?: number; limit?: number } = {}) => validateCatalystDetail(await client.getCloudCatalystEvent(id, signal, query), id);
25
25
  export const loadCatalysts = (query: CatalystFilters, accessKey: string, force = false, signal?: AbortSignal) =>
26
- loadCloudResource(catalystCache, `${accessKey}:${JSON.stringify(query)}`, () => fetchCatalysts(query, apiClient, signal), { force, validate: validateCatalysts });
26
+ loadCloudResource(catalystCache, `${accessKey}:${JSON.stringify(query)}`, () => fetchCatalysts(query, apiClient, signal), { force, validate: validateCatalysts, signal });
27
27
  export const loadCatalystDetail = (id: string, accessKey: string, force = false, offset = 0, signal?: AbortSignal) =>
28
- loadCloudResource(catalystDetailCache, `${accessKey}:${id}:${offset}`, () => fetchCatalystDetail(id, apiClient, signal, { offset, limit: 100 }), { force, validate: (payload) => validateCatalystDetail(payload, id) });
28
+ loadCloudResource(catalystDetailCache, `${accessKey}:${id}:${offset}`, () => fetchCatalystDetail(id, apiClient, signal, { offset, limit: 100 }), { force, validate: (payload) => validateCatalystDetail(payload, id), signal });
@@ -147,10 +147,11 @@ export function CatalystView({ width, height, focused, symbol, litigation = fals
147
147
  if (isCloudSessionRequired(error?.message)) return <SignInWall placement="catalysts-signin" action="view catalysts" needsVerification={session.needsVerification} />;
148
148
  return <Box width={width} height={height} flexDirection="column">
149
149
  {strip}
150
- <PaneStatusBody loading={!openId && !data && resource.loading} error={!openId && (!data || denied) ? error?.message : null} subject={litigation ? "company litigation" : "catalysts"}>
151
- <PageStackView focused={focused} detailOpen={!!openId} onBack={() => setOpenId("")} detailTitle={open?.title ?? "Catalyst"}
150
+ <PageStackView focused={focused} detailOpen={!!openId} onBack={() => setOpenId("")} detailTitle={open?.title ?? "Catalyst"}
152
151
  detailContent={open ? <CatalystEventDetail key={open.id} event={open} accessKey={accessKey} snapshot={detailSnapshots[open.id]} width={width} height={Math.max(3, height - 1)} focused={focused} initialTab={detailTab} openUpgrade={openUpgrade} /> : <PaneStatusBody loading={externalOpen.loading} error={externalOpen.error} subject="catalyst event"><EmptyState title="Event unavailable." /></PaneStatusBody>}
153
152
  rootContent={<DataTableView<Row> rootWidth={width} rootHeight={Math.max(3, height - tabRows)} rootBefore={queryBar} focused={focused && !openId && !searchActive && !dateFieldActive}
153
+ // A tab or filter change loads under the query bar, which stays.
154
+ status={{ loading: !data && resource.loading, error: !data || denied ? error?.message : null, subject: litigation ? "company litigation" : "catalysts" }}
154
155
  items={items} sortColumnId={null} sortDirection="asc" columns={columns} getItemKey={rowKey}
155
156
  isNavigable={isSelectable} renderSectionHeader={(row) => row.kind === "locked" ? null : renderSectionedRowHeader(row)}
156
157
  selection={{ kind: "id", selectedId: selected?.id ?? null, getId: rowKey, onChange: (id) => { if (!id.startsWith("locked:")) setSelectedId(id); } }}
@@ -160,6 +161,5 @@ export function CatalystView({ width, height, focused, symbol, litigation = fals
160
161
  emptyStateTitle={catalystEmptyTitle(data, query, tab === "changes")}
161
162
  bodyAfter={lockedRows && desktop ? <LockedOverlay rows={lockedRows} text="Upgrade for every catalyst and its history" onPress={openUpgrade} role="catalysts-upgrade" /> : undefined} />}
162
163
  />
163
- </PaneStatusBody>
164
164
  </Box>;
165
165
  }
@@ -1188,6 +1188,7 @@ function ChartComposerSurface({
1188
1188
  showLatestChangePercent={!spec.viewport.dateWindow && spec.viewport.range === "1D"}
1189
1189
  onViewportChange={handleChartViewportChange}
1190
1190
  onActivate={activatePane}
1191
+ onPress={trade?.focusChart}
1191
1192
  onToggleSeries={toggleSeries}
1192
1193
  isSeriesToggleable={isSeriesToggleable}
1193
1194
  timePick={timePick}
@@ -70,8 +70,7 @@ export function CommodityBalancesPane({ width, height, focused }: PaneProps) {
70
70
  const [balanceId, setBalanceId] = usePluginPaneState<string | null>("selectedBalance", null);
71
71
  const [priceId, setPriceId] = usePluginPaneState<string | null>("selectedPrice", null);
72
72
  const prices = data?.prices ?? EMPTY_PRICES;
73
- const showPrices = prices.length > 0;
74
- const activeTab = showPrices && tab === "prices" ? "prices" : "balances";
73
+ const activeTab = tab === "prices" ? "prices" : "balances";
75
74
  const balanceRows = useMemo(
76
75
  () => sortBalanceRows(data?.rows ?? EMPTY_ROWS, balanceSort),
77
76
  [balanceSort, data?.rows],
@@ -84,7 +83,7 @@ export function CommodityBalancesPane({ width, height, focused }: PaneProps) {
84
83
  const [shownRequest, setShownRequest] = usePluginPaneState("cropRequest", "");
85
84
  // The row the request scrolls to the top, until the reader sorts or moves on.
86
85
  const [cropAnchor, setCropAnchor] = useState<{ id: string; version: number } | null>(null);
87
- const { strip, rows: tabRows } = usePaneTabs(showPrices ? {
86
+ const { strip, rows: tabRows } = usePaneTabs({
88
87
  tabs: TABS,
89
88
  activeValue: activeTab,
90
89
  onSelect: (value) => setTab(value),
@@ -92,7 +91,7 @@ export function CommodityBalancesPane({ width, height, focused }: PaneProps) {
92
91
  compact: true,
93
92
  variant: "underline",
94
93
  queryBarWidth: width,
95
- } : null);
94
+ });
96
95
  const updatedAgo = useUpdatedAgo(updatedAt);
97
96
  const info = useMemo<PaneFooterSegment[]>(() => {
98
97
  const year = data?.marketYear;
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, useRef } from "react";
2
2
  import type { AppRankPayload } from "../../../api-client/app-attention";
3
3
  import { isAccessDenied } from "../../../api-client/errors";
4
- import { ChartTableHeader, DataTableView, PaneStatusBody, QueryBar, useChartTableSelection, usePagedRows, usePaneStatusFooter, usePaneTabs, useTableLoadMore, type DataTableColumn } from "../../../components";
4
+ import { ChartTableHeader, DataTableView, QueryBar, useChartTableSelection, usePagedRows, usePaneStatusFooter, usePaneTabs, useTableLoadMore, type DataTableColumn } from "../../../components";
5
5
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
6
6
  import { useAsyncResource, usePaneSettingValue, usePluginPaneState } from "../../../public/react";
7
7
  import { useThemeColors } from "../../../theme/theme-context";
@@ -56,14 +56,14 @@ export function AppRankView({ focus, accessKey, width, height, focused }: { focu
56
56
  const tableColumns = tab === "evidence" ? [...columns, { id: "present", label: "In chart", width: 10, align: "left" as const }, { id: "sourceUpdatedAt", label: "Published (UTC)", width: 17, align: "left" as const }, { id: "revisionId", label: "Revision", width: 30, align: "left" as const }] : columns;
57
57
  return <Box flexDirection="column" width={width} height={height}>
58
58
  {strip}
59
- <PaneStatusBody loading={!data && resource.loading} error={!data ? resource.error : null} subject="app rank history">
60
- <DataTableView columns={tableColumns} items={points} sortColumnId="date" sortDirection="desc" getItemKey={id} rootWidth={width} rootHeight={height - tabRows} focused={focused}
59
+ {/* A window change reloads under the query bar, which stays. */}
60
+ <DataTableView columns={tableColumns} items={points} status={data ? undefined : { loading: resource.loading, error: resource.error, subject: "app rank history" }} sortColumnId="date" sortDirection="desc" getItemKey={id} rootWidth={width} rootHeight={height - tabRows} focused={focused}
61
61
  scrollRef={scroll} onBodyScrollActivity={more} selection={{ kind: "id", getId: id, selectedId: selected ? id(selected) : null, onChange: setSelected }}
62
62
  onActivate={(point) => void host.openExternal(point.sourceUrl)}
63
63
  rootBefore={<ChartTableHeader width={width} height={height - tabRows} tableRows={points.length} tableColumns={tableColumns}
64
64
  query={<QueryBar width={width} meta={`${focus.country} · ${focus.chart}`} filters={[{ id: "days", label: "Window", value: days, options: ["30", "90", "365", "730"].map((value) => ({ value, label: `${value}D` })), onChange: setDays }]} />}
65
65
  figures={points[0] ? [{ label: "Rank", value: count(points[0].rank) }, { label: "Rating", value: points[0].rating?.toFixed(2) ?? "--", detail: "/ 5" }, { label: "Ratings", value: count(points[0].ratingCount) }] : []}
66
- chart={tab === "chart" ? { series, ...selection, formatValue: (value) => `#${count(value)}`, empty: series.length ? undefined : focus.chart === "unranked" ? "Rank unavailable for this source" : "Collecting rank history", remoteKind: "app-rank-history" } : null} />}
66
+ chart={data && tab === "chart" ? { series, ...selection, formatValue: (value) => `#${count(value)}`, empty: series.length ? undefined : focus.chart === "unranked" ? "Rank unavailable for this source" : "Collecting rank history", remoteKind: "app-rank-history" } : null} />}
67
67
  renderCell={(point, column) => {
68
68
  const value = point[column.id as keyof Point];
69
69
  if (value == null) return { text: "--", value: null, color: colors.textMuted };
@@ -74,6 +74,5 @@ export function AppRankView({ focus, accessKey, width, height, focused }: { focu
74
74
  color: column.id === "rank" ? colors.textBright : column.id === "revisionId" ? colors.textDim : colors.text };
75
75
  }}
76
76
  emptyStateTitle="No observations for this app and chart yet." selectedTextOverridesCellColor showHorizontalScrollbar />
77
- </PaneStatusBody>
78
77
  </Box>;
79
78
  }
@@ -3,7 +3,7 @@ import { isAccessDenied } from "../../../api-client/errors";
3
3
  import { usePlanAccess } from "../../../api-client/plan-access";
4
4
  import type { AppAttentionFilter, AppAttentionPayload, AppChart } from "../../../api-client/app-attention";
5
5
  import type { HiringBoard, HiringPayload } from "../../../api-client/hiring";
6
- import { ChartTableHeader, DataTableView, EmptyState, KeyValueRow, PageStackView, PaneFooterScope, PaneStatusBody, QueryBar, useChartTableSelection, usePagedRows, useTableLoadMore, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type PaneHint } from "../../../components";
6
+ import { ChartTableHeader, DataTableView, KeyValueRow, PageStackView, PaneFooterScope, QueryBar, useChartTableSelection, usePagedRows, useTableLoadMore, usePaneNoticeFooter, usePaneStatusFooter, usePaneTabs, type PaneHint } from "../../../components";
7
7
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
8
8
  import { useAsyncResource, useAutoRefresh, usePaneSettingValue, usePluginAppActions, usePluginPaneState } from "../../../public/react";
9
9
  import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
@@ -119,7 +119,8 @@ function AttentionView({ kind, symbol, width, height, focused }: Size & { kind:
119
119
  const detailOpen = !!openCompany || !!evidence || !!app;
120
120
  const tabLabels: Record<AttentionTab, string> = { table: "Table", chart: "Chart", mix: kind === "hiring" ? "Mix" : "Markets", peers: kind === "apps" && !symbol ? "Companies" : "Peers", evidence: "Evidence" };
121
121
  const tabs = attentionTabs(kind, symbol).map((value) => ({ value, label: tabLabels[value] }));
122
- const { strip, rows: tabRows } = usePaneTabs(model && !openCompany && !app ? { tabs, activeValue: tab, onSelect: (value) => { setTab(value); setOpenEvidence(null); }, focused, dense: true } : null);
122
+ const signInWall = !data && isCloudSessionRequired(resource.error);
123
+ const { strip, rows: tabRows } = usePaneTabs(!signInWall && !openCompany && !app ? { tabs, activeValue: tab, onSelect: (value) => { setTab(value); setOpenEvidence(null); }, focused, dense: true } : null);
123
124
  const bodyHeight = Math.max(3, height - tabRows);
124
125
  const series = useMemo(() => model ? attentionSeries(model, colors.textBright) : [], [model, colors.textBright]);
125
126
  const chartSelection = useChartTableSelection({ rows, getId: rowId, getDate: rowDate, selectedId: selected?.id ?? null, onSelect: setSelected, focused: focused && !detailOpen, enabled: tab === "chart" });
@@ -142,7 +143,7 @@ function AttentionView({ kind, symbol, width, height, focused }: Size & { kind:
142
143
  usePaneStatusFooter({ registrationId: `${kind}:status`, enabled: !openCompany && !app, loading: resource.loading || paged.loadingMore, error: data ? resource.error ?? paged.moreError?.message ?? paged.error?.message : null, stale: resource.data?.stale,
143
144
  info: [...(model?.asOf ? [{ id: "observed", asOf: true, parts: [{ text: `as of ${model.asOf.slice(0, 10)}`, tone: "muted" as const }] }] : []), ...(model?.preview ? [{ id: "preview", parts: [{ text: "Pro preview", tone: "warning" as const }] }] : [])], hints });
144
145
  usePaneNoticeFooter({ registrationId: `${kind}:notices`, enabled: !openCompany && !app, focused, notices: [...(model?.notices ?? []), ...(resource.data?.refreshError ? [resource.data.refreshError] : [])] });
145
- if (!data && isCloudSessionRequired(resource.error)) return <SignInWall placement={kind === "hiring" ? "hiring-momentum-signin" : "app-attention-signin"} action={kind === "hiring" ? "view hiring momentum" : "view app attention"} needsVerification={session.needsVerification} />;
146
+ if (signInWall) return <SignInWall placement={kind === "hiring" ? "hiring-momentum-signin" : "app-attention-signin"} action={kind === "hiring" ? "view hiring momentum" : "view app attention"} needsVerification={session.needsVerification} />;
146
147
  const countryOptions = data && kind === "apps" ? (data as AppAttentionPayload).coverage.configuredCountries : [];
147
148
  const query = kind === "hiring" && tab !== "mix" ? undefined : <QueryBar width={width} filters={kind === "apps" ? [
148
149
  { id: "country", label: "Country", value: country, defaultValue: "", options: [{ value: "", label: "All countries" }, ...countryOptions.map((value) => ({ value, label: value }))], onChange: setCountry },
@@ -152,12 +153,14 @@ function AttentionView({ kind, symbol, width, height, focused }: Size & { kind:
152
153
  ] : tab === "mix" ? [{ id: "mix", label: "Group", value: mix, options: [{ value: "functions", label: "Role family" }, { value: "seniority", label: "Seniority" }, { value: "countries", label: "Countries" }, { value: "locations", label: "Locations" }, { value: "signals", label: "Signals" }], onChange: setMix }] : []} />;
153
154
  return <Box width={width} height={height} flexDirection="column">
154
155
  {strip}
155
- <PaneStatusBody loading={!data && resource.loading} error={!data ? resource.error : null} subject={kind === "hiring" ? "hiring momentum" : "app attention"}>
156
- {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)}
156
+ <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)}
157
157
  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}
158
- rootContent={<DataTableView columns={columns} items={rows} rootWidth={width} rootHeight={bodyHeight} focused={focused && !detailOpen} scrollRef={scrollRef} onBodyScrollActivity={scrollMore}
159
- rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={rows.length} tableColumns={columns} query={query} figures={model.figures}
160
- chart={tab === "chart" ? { series, ...chartSelection, formatValue: (value) => kind === "hiring" ? count(value) : value.toFixed(1), empty: series.length ? undefined : model.unmapped ?? (model.preview ? "Full history requires Pro."
158
+ rootContent={<DataTableView columns={columns} items={model && section ? rows : []} rootWidth={width} rootHeight={bodyHeight} focused={focused && !detailOpen} scrollRef={scrollRef} onBodyScrollActivity={scrollMore}
159
+ // A filter change reloads under the query bar, which stays.
160
+ status={!data ? { loading: resource.loading, error: resource.error, subject: kind === "hiring" ? "hiring momentum" : "app attention" }
161
+ : !model || !section ? { empty: true, emptyTitle: "Observations are not available yet." } : undefined}
162
+ rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={rows.length} tableColumns={columns} query={query} figures={model?.figures}
163
+ chart={model && tab === "chart" ? { series, ...chartSelection, formatValue: (value) => kind === "hiring" ? count(value) : value.toFixed(1), empty: series.length ? undefined : model.unmapped ?? (model.preview ? "Full history requires Pro."
161
164
  : model.chart.since ? `Collecting history since ${model.chart.since.slice(0, 10)}` : "Collecting history"), remoteKind: `${kind}-history` } : null} />}
162
165
  selection={{ kind: "id", selectedId: selected?.id ?? null, getId: rowId, onChange: setSelected }} getItemKey={rowId}
163
166
  sortColumnId={sort.column} sortDirection={sort.direction} onHeaderClick={(column) => setSort((old) => ({ column, direction: old.column === column && old.direction === "desc" ? "asc" : "desc" }))}
@@ -181,8 +184,7 @@ function AttentionView({ kind, symbol, width, height, focused }: Size & { kind:
181
184
  return { text, value, color: numeric && SIGNED_COLUMNS.has(column.id) ? signedColor(value, colors)
182
185
  : column.id === "name" || column.id === "title" ? colors.textBright : column.id === "date" || column.id === "observed" || column.id === "confidence" ? colors.textDim : colors.text };
183
186
  }} selectedTextOverridesCellColor showHorizontalScrollbar resetScrollKey={`${kind}:${symbol}:${tab}:${mix}:${country}:${chart}`}
184
- emptyStateTitle={model.unmapped ?? section.empty} bodyAfter={lockedRows && desktop ? <LockedOverlay rows={lockedRows} text="Upgrade for all observations" onPress={upgrade} role="attention-upgrade" /> : undefined} />}
185
- /> : <EmptyState title="Observations are not available yet." />}
186
- </PaneStatusBody>
187
+ emptyStateTitle={model?.unmapped ?? section?.empty ?? ""} bodyAfter={lockedRows && desktop ? <LockedOverlay rows={lockedRows} text="Upgrade for all observations" onPress={upgrade} role="attention-upgrade" /> : undefined} />}
188
+ />
187
189
  </Box>;
188
190
  }
@@ -100,7 +100,7 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
100
100
  const guidance = data && "guidance" in data ? data : null;
101
101
  const [openingTab] = usePaneSettingValue("tab", "table");
102
102
  const [tabValue, setTab] = usePluginPaneState<string>("company:tab", openingTab);
103
- const tab = ["table", "chart", "evidence", ...(guidance ? ["history"] : [])].includes(tabValue) ? tabValue : "table";
103
+ const tab = ["table", "chart", "evidence", ...(mode === "guidance" ? ["history"] : [])].includes(tabValue) ? tabValue : "table";
104
104
  const [openingMetric] = usePaneSettingValue("metric", "all");
105
105
  const [metric, setMetric] = usePluginPaneState<string>("company:metric", openingMetric);
106
106
  const [plotKey, setPlotKey] = usePluginPaneState<string>("company:plot", "");
@@ -166,7 +166,8 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
166
166
  : guidanceSeries(guides, selected && isGuidance(selected) ? selected : undefined, { low: colors.textMuted, high: colors.warning, actual: colors.positive }), [tab, mode, kpiChart, guides, selected, colors]);
167
167
  const link = useChartTableSelection({ rows, getId: (row) => row.id, getDate: (row) => isGuidance(row) ? new Date(row.issuedDate) : kpiChart.dates.get(row.id) ?? null,
168
168
  selectedId: selected?.id ?? null, onSelect: setSelected, focused: focused && !openRow, enabled: tab === "chart" });
169
- const { strip, rows: tabRows } = usePaneTabs(data ? { tabs: [{ value: "table", label: mode === "guidance" ? "Tracker" : "Table" }, { value: "chart", label: "Chart" },
169
+ const signInWall = !data && isCloudSessionRequired(resource.error);
170
+ const { strip, rows: tabRows } = usePaneTabs(symbol && !signInWall ? { tabs: [{ value: "table", label: mode === "guidance" ? "Tracker" : "Table" }, { value: "chart", label: "Chart" },
170
171
  ...(mode === "guidance" ? [{ value: "history", label: "History" }] : []), { value: "evidence", label: "Evidence" }], activeValue: tab,
171
172
  onSelect: (value) => { setTab(value); setOpen(null); }, focused, dense: true } : null);
172
173
  useAutoRefresh(resource.updatedAt, resource.load);
@@ -190,7 +191,7 @@ function CompanyView({ symbol, exchange, name, mode, width, height, focused }: P
190
191
  data?.truncated && data.access === "full" ? "The response reached its stored-record limit. Narrow the metric or date range in a CLI or REST query." : null,
191
192
  data?.coverage.conflicts ? "Conflicting disclosures are retained in Evidence and excluded from comparable trends." : null].filter((value): value is string => !!value) });
192
193
  if (!symbol) return <EmptyState title="Select a ticker." />;
193
- if (!data && isCloudSessionRequired(resource.error)) return <SignInWall placement="company-kpis-signin" action={mode === "guidance" ? "view company guidance" : "view company KPIs"} needsVerification={session.needsVerification} />;
194
+ if (signInWall) return <SignInWall placement="company-kpis-signin" action={mode === "guidance" ? "view company guidance" : "view company KPIs"} needsVerification={session.needsVerification} />;
194
195
  const bodyHeight = Math.max(3, height - tabRows);
195
196
  const query = <QueryBar width={width} filters={[{ id: "metric", label: "Metric", value: metric, defaultValue: "all", options: metricOptions,
196
197
  onChange: (value: string) => { setMetric(value); setSelected(null); setOpen(null); } }, ...(tab === "chart" && plotOptions.length ? [{ id: "series", label: "Series", value: activePlot,
@@ -1,6 +1,7 @@
1
1
  import type { HeadlessPaneDefinition } from "../../../types/headless";
2
2
  import type { TimeRange } from "../../../time-series/range";
3
3
  import { formatNumber, formatSignificant } from "../../../utils/format";
4
+ import { logFailure } from "../../../utils/log-failure";
4
5
  import { mixedSessionCloseNote } from "../../../market-data/market/session-close-note";
5
6
  import { resolveHeadlessInstrument, loadHeadlessSymbols } from "../shared/headless-market-data";
6
7
  import { buildCorrelationMatrix, buildCorrelationSeries, buildGeoCorrelationSeries, pairKey, type CorrelationSeries } from "./matrix/model";
@@ -27,7 +28,7 @@ async function loadListedHistory(ctx: HeadlessPaneContext, key: string, range: T
27
28
  const { symbol, exchange } = await resolveHeadlessInstrument(ctx, key);
28
29
  const [points, quote] = await Promise.all([
29
30
  loadHistory(ctx, key, range),
30
- exchange ? null : (async () => ctx.marketData.getQuote(symbol, "", ctx.refresh ? { cacheMode: "refresh" } : undefined))().catch(() => null),
31
+ exchange ? null : (async () => ctx.marketData.getQuote(symbol, "", ctx.refresh ? { cacheMode: "refresh" } : undefined))().catch(logFailure("correlation", "Listing exchange quote failed", null)),
31
32
  ]);
32
33
  return { exchange: exchange || quote?.listingExchangeName || quote?.exchangeName || "", points };
33
34
  }