gloomberb 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (500) hide show
  1. package/README.md +91 -11
  2. package/package.json +12 -5
  3. package/src/app.tsx +414 -191
  4. package/src/architecture/import-boundaries.test.ts +118 -0
  5. package/src/assets/gloomberb-logo.png +0 -0
  6. package/src/assets/gloomberb-logo.svg +73 -0
  7. package/src/brokers/account-cache.ts +157 -0
  8. package/src/brokers/profile-form.test.ts +73 -0
  9. package/src/brokers/profile-form.ts +109 -0
  10. package/src/brokers/remote-broker-adapter.ts +91 -0
  11. package/src/brokers/sync-broker-instance.test.ts +124 -1
  12. package/src/brokers/sync-broker-instance.ts +113 -17
  13. package/src/capabilities/factories.ts +100 -0
  14. package/src/capabilities/index.ts +4 -0
  15. package/src/capabilities/registry.test.ts +137 -0
  16. package/src/capabilities/registry.ts +131 -0
  17. package/src/capabilities/service-capabilities.ts +27 -0
  18. package/src/capabilities/types.ts +84 -0
  19. package/src/cli/commands/ticker.ts +11 -3
  20. package/src/cli/context.ts +19 -13
  21. package/src/cli/helpers.ts +1 -0
  22. package/src/cli/types.ts +2 -2
  23. package/src/components/chart/chart-controller.ts +4 -24
  24. package/src/components/chart/chart-data.ts +3 -14
  25. package/src/components/chart/chart-indicator-selector.tsx +28 -6
  26. package/src/components/chart/chart-pane-settings.ts +15 -28
  27. package/src/components/chart/chart-renderer.ts +74 -99
  28. package/src/components/chart/chart-resolution.ts +10 -10
  29. package/src/components/chart/chart-scroll.test.ts +6 -5
  30. package/src/components/chart/chart-scroll.ts +2 -2
  31. package/src/components/chart/chart-types.ts +0 -1
  32. package/src/components/chart/chart-viewport.ts +2 -1
  33. package/src/components/chart/chart.test.ts +37 -0
  34. package/src/components/chart/comparison-chart-data.ts +3 -17
  35. package/src/components/chart/comparison-chart-renderer.test.ts +27 -4
  36. package/src/components/chart/comparison-chart-renderer.ts +1 -1
  37. package/src/components/chart/comparison-stock-chart.tsx +304 -108
  38. package/src/components/chart/cursor-motion.ts +3 -2
  39. package/src/components/chart/indicators/bands.test.ts +0 -14
  40. package/src/components/chart/indicators/moving-averages.test.ts +0 -15
  41. package/src/components/chart/indicators/options.ts +3 -3
  42. package/src/components/chart/indicators/oscillators.test.ts +0 -21
  43. package/src/components/chart/indicators/types.ts +1 -2
  44. package/src/components/chart/native/chart-rasterizer.ts +37 -44
  45. package/src/components/chart/native/kitty-adapter.ts +2 -5
  46. package/src/components/chart/native/kitty-manager.ts +1 -1
  47. package/src/components/chart/native/kitty-protocol.ts +0 -1
  48. package/src/components/chart/native/kitty-support.ts +1 -1
  49. package/src/components/chart/native/kitty.test.ts +30 -13
  50. package/src/components/chart/native/renderer-selection.ts +1 -1
  51. package/src/components/chart/native/surface-manager.ts +1 -1
  52. package/src/components/chart/static-chart-surface.test.tsx +51 -0
  53. package/src/components/chart/static-chart-surface.tsx +191 -0
  54. package/src/components/chart/stock-chart-renderer-switch.test.tsx +242 -35
  55. package/src/components/chart/stock-chart.tsx +438 -141
  56. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  57. package/src/components/command-bar/command-bar.test.tsx +269 -20
  58. package/src/components/command-bar/command-bar.tsx +830 -353
  59. package/src/components/command-bar/command-registry.ts +10 -20
  60. package/src/components/command-bar/helpers.test.ts +1 -1
  61. package/src/components/command-bar/helpers.ts +9 -11
  62. package/src/components/command-bar/pane-template-display.ts +4 -8
  63. package/src/components/command-bar/root-shortcuts.ts +12 -11
  64. package/src/components/command-bar/view-model.test.ts +8 -42
  65. package/src/components/command-bar/view-model.ts +8 -36
  66. package/src/components/command-bar/workflow-ops.test.ts +76 -2
  67. package/src/components/command-bar/workflow-ops.ts +40 -9
  68. package/src/components/command-bar/workflow-types.ts +2 -1
  69. package/src/components/data-table-stack-view.test.tsx +206 -0
  70. package/src/components/data-table-stack-view.tsx +152 -0
  71. package/src/components/data-table-view.test.tsx +188 -0
  72. package/src/components/data-table-view.tsx +279 -0
  73. package/src/components/feed-data-table-stack-view.test.tsx +225 -0
  74. package/src/components/feed-data-table-stack-view.tsx +365 -0
  75. package/src/components/index.ts +26 -31
  76. package/src/components/layout/detached-pane-shell.tsx +185 -0
  77. package/src/components/layout/floating-pane.tsx +87 -14
  78. package/src/components/layout/header.tsx +139 -49
  79. package/src/components/layout/pane-content.tsx +40 -0
  80. package/src/components/layout/pane-footer.test.tsx +195 -0
  81. package/src/components/layout/pane-footer.tsx +410 -0
  82. package/src/components/layout/pane-header.test.tsx +39 -0
  83. package/src/components/layout/pane-header.tsx +192 -14
  84. package/src/components/layout/pane-sizing.ts +9 -10
  85. package/src/components/layout/pane-title.ts +35 -0
  86. package/src/components/layout/pane.tsx +52 -11
  87. package/src/components/layout/shell.test.tsx +315 -107
  88. package/src/components/layout/shell.tsx +1010 -368
  89. package/src/components/layout/status-bar.test.tsx +17 -10
  90. package/src/components/layout/status-bar.tsx +367 -100
  91. package/src/components/layout/titlebar-overlay.ts +2 -0
  92. package/src/components/markdown-editor.tsx +29 -14
  93. package/src/components/markdown-text.tsx +25 -24
  94. package/src/components/onboarding/onboarding-wizard.test.tsx +8 -3
  95. package/src/components/onboarding/onboarding-wizard.tsx +347 -473
  96. package/src/components/pane-settings-dialog.tsx +521 -89
  97. package/src/components/pane-template-wizard.test.tsx +1 -1
  98. package/src/components/pane-template-wizard.tsx +38 -37
  99. package/src/components/price-selector-dialog.tsx +22 -21
  100. package/src/components/ticker-badge-list.tsx +53 -0
  101. package/src/components/ticker-badge-text.test.tsx +44 -1
  102. package/src/components/ticker-badge-text.tsx +78 -38
  103. package/src/components/ticker-badge.tsx +28 -7
  104. package/src/components/ticker-list-table-view.tsx +248 -0
  105. package/src/components/ticker-list-table.test.tsx +47 -1
  106. package/src/components/ticker-list-table.tsx +212 -41
  107. package/src/components/toggle-list.tsx +114 -8
  108. package/src/components/ui/button.tsx +24 -16
  109. package/src/components/ui/choice-dialog.tsx +89 -0
  110. package/src/components/ui/data-table.tsx +313 -114
  111. package/src/components/ui/external-link.tsx +52 -13
  112. package/src/components/ui/fields.tsx +44 -30
  113. package/src/components/ui/frame.tsx +24 -57
  114. package/src/components/ui/index.ts +15 -50
  115. package/src/components/ui/list-view.tsx +65 -25
  116. package/src/components/ui/loading.tsx +5 -60
  117. package/src/components/ui/message-composer.test.tsx +81 -0
  118. package/src/components/ui/message-composer.tsx +139 -0
  119. package/src/components/ui/multi-select-dialog.tsx +100 -32
  120. package/src/components/ui/multi-select.ts +112 -0
  121. package/src/components/ui/native-select.tsx +120 -0
  122. package/src/components/ui/page-stack-view.tsx +49 -19
  123. package/src/components/ui/remote-image.tsx +75 -0
  124. package/src/components/ui/row-value-cache.test.ts +17 -0
  125. package/src/components/ui/row-value-cache.ts +41 -0
  126. package/src/components/ui/shortcut-hint.tsx +55 -0
  127. package/src/components/ui/status.tsx +12 -67
  128. package/src/components/ui/table-layout.ts +59 -0
  129. package/src/components/ui/tabs.tsx +249 -27
  130. package/src/components/ui/toggle.tsx +19 -127
  131. package/src/components/ui/ui.test.tsx +266 -53
  132. package/src/components/use-double-click-activation.ts +7 -2
  133. package/src/components/use-external-link-footer.ts +79 -0
  134. package/src/core/app-services.ts +95 -0
  135. package/src/core/state/app-state.ts +886 -0
  136. package/src/{state → core/state}/session-persistence.ts +7 -6
  137. package/src/data/config-layout.ts +225 -0
  138. package/src/data/config-store-node.ts +390 -0
  139. package/src/data/config-store.test.ts +72 -0
  140. package/src/data/config-store.ts +35 -478
  141. package/src/data/sqlite-json.ts +2 -5
  142. package/src/data/sqlite-retry.ts +1 -0
  143. package/src/index.tsx +2 -68
  144. package/src/market-data/coordinator-subscriptions.test.ts +125 -0
  145. package/src/market-data/coordinator.test.ts +73 -4
  146. package/src/market-data/coordinator.ts +190 -24
  147. package/src/market-data/hooks.test.tsx +1 -1
  148. package/src/market-data/hooks.tsx +109 -87
  149. package/src/market-data/query-store.ts +3 -3
  150. package/src/market-data/request-types.test.ts +0 -14
  151. package/src/market-data/result-types.ts +1 -0
  152. package/src/news/aggregator.test.ts +267 -0
  153. package/src/news/aggregator.ts +452 -0
  154. package/src/news/hooks.ts +39 -0
  155. package/src/news/ticker-symbols.ts +21 -0
  156. package/src/news/types.ts +77 -0
  157. package/src/notifications/app-notifier.ts +21 -10
  158. package/src/pane-settings.test.ts +0 -2
  159. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +124 -134
  160. package/src/plugins/builtin/ai/gloomberb-cli.ts +14 -13
  161. package/src/plugins/builtin/ai/providers.ts +1 -9
  162. package/src/plugins/builtin/ai/runner.ts +41 -5
  163. package/src/plugins/builtin/ai/screener-pane.test.tsx +37 -55
  164. package/src/plugins/builtin/ai/screener-pane.tsx +231 -276
  165. package/src/plugins/builtin/ai/settings.ts +0 -1
  166. package/src/plugins/builtin/ai/utils.ts +1 -71
  167. package/src/plugins/builtin/alerts/alert-engine.test.ts +1 -38
  168. package/src/plugins/builtin/alerts/index.test.tsx +61 -82
  169. package/src/plugins/builtin/alerts/index.tsx +263 -155
  170. package/src/plugins/builtin/alerts/types.ts +7 -0
  171. package/src/plugins/builtin/analytics/index.test.tsx +13 -8
  172. package/src/plugins/builtin/analytics/index.tsx +78 -120
  173. package/src/plugins/builtin/analytics/metrics.test.ts +0 -4
  174. package/src/plugins/builtin/ask-ai.test.tsx +55 -54
  175. package/src/plugins/builtin/broker-manager/index.test.tsx +168 -0
  176. package/src/plugins/builtin/broker-manager/index.tsx +759 -0
  177. package/src/plugins/builtin/broker-manager/model.test.ts +107 -0
  178. package/src/plugins/builtin/broker-manager/model.ts +142 -0
  179. package/src/plugins/builtin/chat-controller.test.ts +129 -0
  180. package/src/plugins/builtin/chat-controller.ts +85 -14
  181. package/src/plugins/builtin/chat.test.tsx +283 -36
  182. package/src/plugins/builtin/chat.tsx +363 -170
  183. package/src/plugins/builtin/cloud-auth-actions.tsx +72 -0
  184. package/src/plugins/builtin/cloud-tweets.tsx +896 -0
  185. package/src/plugins/builtin/comparison-chart.test.tsx +46 -57
  186. package/src/plugins/builtin/comparison-chart.tsx +11 -11
  187. package/src/plugins/builtin/correlation/compute.test.ts +0 -17
  188. package/src/plugins/builtin/correlation/index.tsx +51 -46
  189. package/src/plugins/builtin/correlation/settings.ts +1 -2
  190. package/src/plugins/builtin/debug.tsx +99 -70
  191. package/src/plugins/builtin/earnings/earnings-cache.test.ts +2 -2
  192. package/src/plugins/builtin/earnings/earnings-cache.ts +12 -3
  193. package/src/plugins/builtin/earnings/earnings-data.test.ts +42 -11
  194. package/src/plugins/builtin/earnings/earnings-data.ts +70 -5
  195. package/src/plugins/builtin/earnings/index.tsx +187 -94
  196. package/src/plugins/builtin/econ/calendar-source.ts +9 -116
  197. package/src/plugins/builtin/econ/fred-client.test.ts +1 -14
  198. package/src/plugins/builtin/econ/fred-series-map.ts +1 -0
  199. package/src/plugins/builtin/econ/index.tsx +236 -421
  200. package/src/plugins/builtin/econ/types.ts +1 -0
  201. package/src/plugins/builtin/fx-matrix/index.tsx +43 -45
  202. package/src/plugins/builtin/fx-matrix/pairs.ts +1 -0
  203. package/src/plugins/builtin/help.test.tsx +120 -60
  204. package/src/plugins/builtin/help.tsx +266 -82
  205. package/src/plugins/builtin/holders/index.test.ts +45 -0
  206. package/src/plugins/builtin/holders/index.tsx +949 -0
  207. package/src/plugins/builtin/insider/index.tsx +207 -253
  208. package/src/plugins/builtin/insider/insider-data.test.ts +1 -11
  209. package/src/plugins/builtin/insider/insider-data.ts +1 -0
  210. package/src/plugins/builtin/layout-manager.tsx +12 -40
  211. package/src/plugins/builtin/market-movers/index.tsx +70 -144
  212. package/src/plugins/builtin/market-movers/screener.test.ts +0 -18
  213. package/src/plugins/builtin/news-wire/breaking-notifications.test.ts +98 -0
  214. package/src/plugins/builtin/news-wire/breaking-notifications.ts +110 -0
  215. package/src/plugins/builtin/news-wire/breaking-pane.tsx +157 -0
  216. package/src/plugins/builtin/news-wire/categories.ts +114 -0
  217. package/src/plugins/builtin/news-wire/default-feeds.ts +3 -0
  218. package/src/plugins/builtin/news-wire/digest-store.ts +44 -0
  219. package/src/plugins/builtin/news-wire/feed-config.test.ts +90 -0
  220. package/src/plugins/builtin/news-wire/feed-config.ts +216 -0
  221. package/src/plugins/builtin/news-wire/feed-pane.tsx +21 -0
  222. package/src/plugins/builtin/news-wire/hash.ts +9 -0
  223. package/src/plugins/builtin/news-wire/index.tsx +92 -0
  224. package/src/plugins/builtin/news-wire/industry-pane.tsx +140 -0
  225. package/src/plugins/builtin/news-wire/news-detail-view.tsx +256 -0
  226. package/src/plugins/builtin/news-wire/news-footer.ts +28 -0
  227. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +94 -0
  228. package/src/plugins/builtin/news-wire/news-query-presets.ts +40 -0
  229. package/src/plugins/builtin/news-wire/news-table.test.tsx +206 -0
  230. package/src/plugins/builtin/news-wire/news-table.tsx +296 -0
  231. package/src/plugins/builtin/news-wire/persisted-articles.ts +92 -0
  232. package/src/plugins/builtin/news-wire/read-state.test.ts +30 -0
  233. package/src/plugins/builtin/news-wire/read-state.ts +76 -0
  234. package/src/plugins/builtin/news-wire/rss-parser.test.ts +184 -0
  235. package/src/plugins/builtin/news-wire/rss-parser.ts +213 -0
  236. package/src/plugins/builtin/news-wire/rss-source.test.ts +159 -0
  237. package/src/plugins/builtin/news-wire/rss-source.ts +181 -0
  238. package/src/plugins/builtin/news-wire/top-pane.tsx +21 -0
  239. package/src/plugins/builtin/news.tsx +98 -25
  240. package/src/plugins/builtin/notes-files.ts +51 -10
  241. package/src/plugins/builtin/notes.tsx +332 -131
  242. package/src/plugins/builtin/options.test.tsx +185 -0
  243. package/src/plugins/builtin/options.tsx +346 -206
  244. package/src/plugins/builtin/plugin-groups.ts +92 -0
  245. package/src/plugins/builtin/portfolio-list/header.tsx +72 -37
  246. package/src/plugins/builtin/portfolio-list/index.tsx +25 -42
  247. package/src/plugins/builtin/portfolio-list/metrics.ts +1 -1
  248. package/src/plugins/builtin/portfolio-list/pane.tsx +246 -164
  249. package/src/plugins/builtin/portfolio-list/settings.ts +19 -51
  250. package/src/plugins/builtin/portfolio-list/summary.tsx +21 -16
  251. package/src/plugins/builtin/portfolio-list/table.tsx +66 -25
  252. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +1 -1
  253. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +1 -1
  254. package/src/plugins/builtin/portfolio-list.test.ts +60 -1
  255. package/src/plugins/builtin/portfolio-list.test.tsx +122 -55
  256. package/src/plugins/builtin/quote-monitor.test.tsx +2 -27
  257. package/src/plugins/builtin/research/index.tsx +724 -0
  258. package/src/plugins/builtin/sec.tsx +67 -19
  259. package/src/plugins/builtin/sectors/index.tsx +42 -80
  260. package/src/plugins/builtin/sectors/sector-data.ts +1 -0
  261. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +25 -39
  262. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +108 -58
  263. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +383 -271
  264. package/src/plugins/builtin/ticker-detail/pane.tsx +135 -49
  265. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +43 -27
  266. package/src/plugins/builtin/ticker-detail/settings.ts +3 -6
  267. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +16 -10
  268. package/src/plugins/builtin/ticker-detail.test.tsx +156 -33
  269. package/src/plugins/builtin/ticker-surface.ts +68 -0
  270. package/src/plugins/builtin/world-indices/index.tsx +44 -82
  271. package/src/plugins/builtin/world-indices/indices.ts +1 -0
  272. package/src/plugins/builtin/yahoo.ts +55 -1
  273. package/src/plugins/builtin/yield-curve/index.tsx +87 -145
  274. package/src/plugins/builtin/yield-curve/treasury-data.ts +6 -77
  275. package/src/plugins/catalog-ui.test.ts +24 -0
  276. package/src/plugins/catalog-ui.ts +44 -0
  277. package/src/plugins/catalog.ts +2 -50
  278. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  279. package/src/plugins/ibkr/account-cache.test.ts +15 -14
  280. package/src/plugins/ibkr/account-cache.ts +15 -120
  281. package/src/plugins/ibkr/broker-adapter.ts +67 -0
  282. package/src/plugins/ibkr/config.test.ts +0 -18
  283. package/src/plugins/ibkr/dialogs.tsx +14 -56
  284. package/src/plugins/ibkr/flex.test.ts +57 -2
  285. package/src/plugins/ibkr/flex.ts +116 -12
  286. package/src/plugins/ibkr/gateway-helpers.ts +8 -3
  287. package/src/plugins/ibkr/gateway-service-native.ts +2381 -0
  288. package/src/plugins/ibkr/gateway-service.test.ts +47 -9
  289. package/src/plugins/ibkr/gateway-service.ts +264 -2235
  290. package/src/plugins/ibkr/gateway-types.ts +27 -0
  291. package/src/plugins/ibkr/index.test.tsx +3 -11
  292. package/src/plugins/ibkr/index.tsx +1 -0
  293. package/src/plugins/ibkr/price-normalization.test.ts +1 -6
  294. package/src/plugins/ibkr/price-normalization.ts +1 -0
  295. package/src/plugins/ibkr/trade-badge.tsx +6 -5
  296. package/src/plugins/ibkr/trade-tab.test.tsx +2 -2
  297. package/src/plugins/ibkr/trade-tab.tsx +164 -117
  298. package/src/plugins/ibkr/trading-pane.tsx +117 -83
  299. package/src/plugins/pane-manager.test.ts +3 -1
  300. package/src/plugins/pane-manager.ts +93 -19
  301. package/src/plugins/plugin-runtime.test.tsx +176 -74
  302. package/src/plugins/plugin-runtime.tsx +228 -10
  303. package/src/plugins/prediction-markets/cache.ts +49 -0
  304. package/src/plugins/prediction-markets/categories.ts +0 -10
  305. package/src/plugins/prediction-markets/chart.test.tsx +1 -1
  306. package/src/plugins/prediction-markets/chart.tsx +21 -20
  307. package/src/plugins/prediction-markets/cli.ts +1 -0
  308. package/src/plugins/prediction-markets/controller-data.ts +135 -89
  309. package/src/plugins/prediction-markets/controller.ts +37 -108
  310. package/src/plugins/prediction-markets/detail/book.tsx +23 -22
  311. package/src/plugins/prediction-markets/detail/outcomes.tsx +33 -32
  312. package/src/plugins/prediction-markets/detail/overview.tsx +17 -12
  313. package/src/plugins/prediction-markets/detail/pane.tsx +59 -45
  314. package/src/plugins/prediction-markets/detail/rules.tsx +22 -7
  315. package/src/plugins/prediction-markets/detail/shared.tsx +3 -2
  316. package/src/plugins/prediction-markets/detail/trades.tsx +12 -11
  317. package/src/plugins/prediction-markets/detail.test.tsx +1 -1
  318. package/src/plugins/prediction-markets/keyboard.ts +1 -0
  319. package/src/plugins/prediction-markets/launch.test.ts +1 -1
  320. package/src/plugins/prediction-markets/launch.ts +1 -1
  321. package/src/plugins/prediction-markets/metrics.ts +25 -12
  322. package/src/plugins/prediction-markets/pane.test.tsx +16 -7
  323. package/src/plugins/prediction-markets/pane.tsx +180 -82
  324. package/src/plugins/prediction-markets/plugin.test.tsx +37 -45
  325. package/src/plugins/prediction-markets/services/fetch.ts +27 -10
  326. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +20 -3
  327. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  328. package/src/plugins/prediction-markets/settings.ts +0 -5
  329. package/src/plugins/prediction-markets/test-helpers.tsx +19 -44
  330. package/src/plugins/registry.test.ts +307 -0
  331. package/src/plugins/registry.ts +293 -82
  332. package/src/react/input.ts +53 -0
  333. package/src/react/plugins/plugin-slot.tsx +21 -0
  334. package/src/react/use-raf-callback.ts +44 -0
  335. package/src/renderers/electrobun/bun/application-menu-click.test.ts +41 -0
  336. package/src/renderers/electrobun/bun/application-menu-click.ts +55 -0
  337. package/src/renderers/electrobun/bun/application-menu.ts +118 -0
  338. package/src/renderers/electrobun/bun/context-menu-click.test.ts +67 -0
  339. package/src/renderers/electrobun/bun/context-menu-click.ts +61 -0
  340. package/src/renderers/electrobun/bun/context-menu-normalize.test.ts +52 -0
  341. package/src/renderers/electrobun/bun/context-menu-normalize.ts +67 -0
  342. package/src/renderers/electrobun/bun/core-capabilities.ts +328 -0
  343. package/src/renderers/electrobun/bun/desktop-workspace.test.ts +63 -0
  344. package/src/renderers/electrobun/bun/desktop-workspace.ts +162 -0
  345. package/src/renderers/electrobun/bun/index.ts +1149 -0
  346. package/src/renderers/electrobun/bun/window-frame.test.ts +40 -0
  347. package/src/renderers/electrobun/bun/window-frame.ts +46 -0
  348. package/src/renderers/electrobun/shared/protocol.ts +72 -0
  349. package/src/renderers/electrobun/view/ai-host.ts +93 -0
  350. package/src/renderers/electrobun/view/app-services.ts +348 -0
  351. package/src/renderers/electrobun/view/application-menu-bridge.ts +12 -0
  352. package/src/renderers/electrobun/view/backend-rpc.ts +173 -0
  353. package/src/renderers/electrobun/view/broker-remote-client.ts +154 -0
  354. package/src/renderers/electrobun/view/config-host.ts +35 -0
  355. package/src/renderers/electrobun/view/context-menu.test.ts +140 -0
  356. package/src/renderers/electrobun/view/context-menu.ts +164 -0
  357. package/src/renderers/electrobun/view/data-table.tsx +773 -0
  358. package/src/renderers/electrobun/view/desktop-controls.tsx +651 -0
  359. package/src/renderers/electrobun/view/desktop-window-bridge.ts +59 -0
  360. package/src/renderers/electrobun/view/desktop-window-snapshot.test.ts +57 -0
  361. package/src/renderers/electrobun/view/desktop-window-snapshot.ts +41 -0
  362. package/src/renderers/electrobun/view/dialog-host.tsx +62 -0
  363. package/src/renderers/electrobun/view/http-fetch.ts +113 -0
  364. package/src/renderers/electrobun/view/input-host.tsx +65 -0
  365. package/src/renderers/electrobun/view/key-event.test.ts +34 -0
  366. package/src/renderers/electrobun/view/key-event.ts +134 -0
  367. package/src/renderers/electrobun/view/main.tsx +104 -0
  368. package/src/renderers/electrobun/view/native-renderer.ts +110 -0
  369. package/src/renderers/electrobun/view/native-stubs/chart-kitty-support.ts +9 -0
  370. package/src/renderers/electrobun/view/native-stubs/chart-surface-manager.ts +25 -0
  371. package/src/renderers/electrobun/view/native-stubs/chart-surface-sync.ts +1 -0
  372. package/src/renderers/electrobun/view/notes-files.ts +56 -0
  373. package/src/renderers/electrobun/view/resource-store.test.ts +44 -0
  374. package/src/renderers/electrobun/view/resource-store.ts +106 -0
  375. package/src/renderers/electrobun/view/rpc-codec.ts +42 -0
  376. package/src/renderers/electrobun/view/scrollbar-activity.ts +23 -0
  377. package/src/renderers/electrobun/view/toast-host.tsx +65 -0
  378. package/src/renderers/electrobun/view/ui-host.tsx +1490 -0
  379. package/src/renderers/electrobun/view/update-host.ts +38 -0
  380. package/src/renderers/opentui/dialog-host.test.tsx +44 -0
  381. package/src/renderers/opentui/dialog-host.tsx +56 -0
  382. package/src/renderers/opentui/host.tsx +134 -0
  383. package/src/renderers/opentui/input-host.tsx +24 -0
  384. package/src/renderers/opentui/start.tsx +71 -0
  385. package/src/renderers/opentui/test-utils.tsx +76 -0
  386. package/src/renderers/opentui/toast-host.tsx +10 -0
  387. package/src/renderers/opentui/ui-host.tsx +97 -0
  388. package/src/sources/gloomberb-cloud.test.ts +288 -17
  389. package/src/sources/gloomberb-cloud.ts +176 -9
  390. package/src/sources/provider-errors.ts +1 -0
  391. package/src/sources/provider-router.test.ts +220 -81
  392. package/src/sources/provider-router.ts +294 -111
  393. package/src/sources/sec-edgar.ts +19 -6
  394. package/src/sources/yahoo-finance.test.ts +0 -5
  395. package/src/sources/yahoo-finance.ts +420 -11
  396. package/src/state/app-activity.ts +7 -7
  397. package/src/state/app-bootstrap.ts +114 -40
  398. package/src/state/app-context-selector.test.tsx +70 -1
  399. package/src/state/app-context.test.ts +71 -3
  400. package/src/state/app-context.test.tsx +14 -7
  401. package/src/state/app-context.tsx +205 -869
  402. package/src/state/config-save-scheduler.ts +35 -0
  403. package/src/state/persist-scheduler.test.ts +67 -0
  404. package/src/state/persist-scheduler.ts +69 -0
  405. package/src/state/selectors-ui.ts +55 -0
  406. package/src/state/selectors.ts +15 -71
  407. package/src/state/session-persistence.test.ts +1 -1
  408. package/src/state/ticker-refresh-queue.ts +52 -3
  409. package/src/state/use-inline-tickers.ts +18 -16
  410. package/src/state/use-quote-streaming.test.ts +0 -6
  411. package/src/state/use-quote-streaming.test.tsx +1 -1
  412. package/src/state/use-quote-streaming.ts +3 -2
  413. package/src/state/use-session-snapshot.ts +32 -17
  414. package/src/test-support/plugin-runtime.ts +149 -0
  415. package/src/theme/color-utils.ts +1 -0
  416. package/src/theme/colors.ts +16 -0
  417. package/src/types/broker.ts +16 -1
  418. package/src/types/capability-route-source.ts +23 -0
  419. package/src/types/config.test.ts +23 -0
  420. package/src/types/config.ts +145 -10
  421. package/src/types/context-menu.ts +95 -0
  422. package/src/types/data-provider.ts +34 -3
  423. package/src/types/desktop-menu.ts +13 -0
  424. package/src/types/desktop-window.ts +29 -0
  425. package/src/types/financials.ts +123 -2
  426. package/src/types/instrument.ts +1 -0
  427. package/src/types/news-source.ts +9 -0
  428. package/src/types/persistence.ts +1 -0
  429. package/src/types/plugin.ts +32 -8
  430. package/src/ui/ascii-font.ts +85 -0
  431. package/src/ui/context-menu.test.ts +94 -0
  432. package/src/ui/context-menu.tsx +433 -0
  433. package/src/ui/dialog.tsx +55 -0
  434. package/src/ui/host.tsx +361 -0
  435. package/src/ui/index.tsx +126 -0
  436. package/src/ui/toast.tsx +40 -0
  437. package/src/updater.test.ts +69 -0
  438. package/src/updater.ts +61 -18
  439. package/src/utils/api-client.test.ts +99 -9
  440. package/src/utils/api-client.ts +378 -18
  441. package/src/utils/back-navigation.ts +6 -0
  442. package/src/utils/cli-output.ts +1 -0
  443. package/src/utils/currency-units.test.ts +1 -6
  444. package/src/utils/currency-units.ts +1 -0
  445. package/src/utils/debug-log.ts +68 -0
  446. package/src/utils/dom-screenshot.ts +326 -0
  447. package/src/utils/exchange-rate-map.ts +1 -0
  448. package/src/utils/exchanges.ts +214 -0
  449. package/src/utils/format.test.ts +7 -0
  450. package/src/utils/format.ts +1 -0
  451. package/src/utils/fuzzy-search.ts +1 -0
  452. package/src/utils/html-entities.test.ts +8 -0
  453. package/src/utils/html-entities.ts +32 -0
  454. package/src/utils/http-transport.ts +11 -0
  455. package/src/utils/main-thread-monitor.ts +127 -0
  456. package/src/utils/perf-marks.ts +54 -0
  457. package/src/utils/quote-freshness.ts +27 -50
  458. package/src/utils/quote-resolution.test.ts +5 -5
  459. package/src/utils/quote-resolution.ts +0 -1
  460. package/src/utils/quote-time.ts +1 -5
  461. package/src/utils/sec.test.ts +0 -12
  462. package/src/utils/selection-clipboard.ts +14 -6
  463. package/src/utils/shortcut-labels.test.ts +20 -0
  464. package/src/utils/shortcut-labels.ts +67 -0
  465. package/src/utils/slugify.ts +1 -0
  466. package/src/utils/terminal-input-reset.ts +1 -0
  467. package/src/utils/text-wrap.ts +82 -0
  468. package/src/utils/throttled-fetch.test.ts +19 -0
  469. package/src/utils/throttled-fetch.ts +11 -9
  470. package/src/utils/ticker-list.test.ts +1 -7
  471. package/src/utils/ticker-list.ts +1 -0
  472. package/src/utils/timestamp.ts +29 -0
  473. package/src/utils/tweet-text.test.ts +13 -0
  474. package/src/utils/tweet-text.ts +18 -0
  475. package/src/utils/url.ts +11 -0
  476. package/src/version.ts +1 -1
  477. package/src/components/chart/chart-control-hint.tsx +0 -39
  478. package/src/components/detail-feed-view.test.tsx +0 -180
  479. package/src/components/detail-feed-view.tsx +0 -531
  480. package/src/components/layout/pixel-mouse-mode.test.ts +0 -122
  481. package/src/components/layout/pixel-mouse.test.ts +0 -40
  482. package/src/components/spinner.tsx +0 -2
  483. package/src/components/tab-bar.tsx +0 -17
  484. package/src/components/ui/multi-select-chips.tsx +0 -69
  485. package/src/plugins/builtin/ai/index.test.tsx +0 -139
  486. package/src/plugins/builtin/ask-ai.tsx +0 -9
  487. package/src/plugins/builtin/correlation/plugin.test.ts +0 -44
  488. package/src/plugins/builtin/correlation/settings.test.ts +0 -34
  489. package/src/plugins/builtin/econ/calendar-source.test.ts +0 -219
  490. package/src/plugins/builtin/econ/fred-client.ts +0 -79
  491. package/src/plugins/builtin/fred-settings.ts +0 -10
  492. package/src/plugins/builtin/options-availability.test.tsx +0 -179
  493. package/src/plugins/builtin/options-availability.ts +0 -158
  494. package/src/plugins/builtin/portfolio-list/metrics.test.ts +0 -61
  495. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +0 -212
  496. package/src/plugins/prediction-markets/status.ts +0 -24
  497. package/src/plugins/prediction-markets/table.tsx +0 -87
  498. package/src/utils/quote-time.test.ts +0 -22
  499. package/src/utils/selection-clipboard.test.ts +0 -43
  500. package/src/utils/terminal-input-reset.test.ts +0 -50
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BrokerAdapter } from "../../../types/broker";
3
+ import { createDefaultConfig, type BrokerInstanceConfig } from "../../../types/config";
4
+ import { ibkrBroker } from "../../ibkr/broker-adapter";
5
+ import { buildBrokerProfileRows, formatBrokerUpdatedAt } from "./model";
6
+
7
+ function createInstance(patch: Partial<BrokerInstanceConfig> = {}): BrokerInstanceConfig {
8
+ return {
9
+ id: "demo-live",
10
+ brokerType: "demo",
11
+ label: "Demo Live",
12
+ connectionMode: "gateway",
13
+ config: { connectionMode: "gateway" },
14
+ enabled: true,
15
+ ...patch,
16
+ };
17
+ }
18
+
19
+ function createAdapter(state: "connected" | "error" = "connected"): BrokerAdapter {
20
+ return {
21
+ id: "demo",
22
+ name: "Demo Broker",
23
+ configSchema: [],
24
+ validate: async () => true,
25
+ importPositions: async () => [],
26
+ getStatus: () => ({ state, updatedAt: 1_000_000, message: state === "error" ? "No route" : "Ready" }),
27
+ };
28
+ }
29
+
30
+ describe("broker manager rows", () => {
31
+ test("derives live status and account summary", () => {
32
+ const config = {
33
+ ...createDefaultConfig("/tmp/gloomberb-broker-manager"),
34
+ brokerInstances: [createInstance()],
35
+ };
36
+ const rows = buildBrokerProfileRows(
37
+ config,
38
+ new Map([["demo", createAdapter()]]),
39
+ {
40
+ "demo-live": [
41
+ { accountId: "DU1", name: "DU1", currency: "USD", netLiquidation: 100 },
42
+ { accountId: "DU2", name: "DU2", currency: "USD", netLiquidation: 50 },
43
+ ],
44
+ },
45
+ );
46
+
47
+ expect(rows[0]).toMatchObject({
48
+ id: "demo-live",
49
+ brokerName: "Demo Broker",
50
+ state: "connected",
51
+ stateLabel: "Connected",
52
+ accountSummary: "$150.00",
53
+ });
54
+ });
55
+
56
+ test("handles disabled and unavailable profiles before adapter status", () => {
57
+ const config = {
58
+ ...createDefaultConfig("/tmp/gloomberb-broker-manager"),
59
+ brokerInstances: [
60
+ createInstance({ id: "disabled", enabled: false }),
61
+ createInstance({ id: "missing", brokerType: "missing" }),
62
+ ],
63
+ };
64
+ const rows = buildBrokerProfileRows(config, new Map([["demo", createAdapter("error")]]), {});
65
+
66
+ expect(rows.map((row) => row.state)).toEqual(["disabled", "unavailable"]);
67
+ });
68
+
69
+ test("labels Flex profiles as sync-only", () => {
70
+ const config = {
71
+ ...createDefaultConfig("/tmp/gloomberb-broker-manager"),
72
+ brokerInstances: [createInstance({ id: "flex", connectionMode: "flex", config: { connectionMode: "flex" } })],
73
+ };
74
+ const rows = buildBrokerProfileRows(config, new Map([["demo", createAdapter()]]), {});
75
+
76
+ expect(rows[0]?.mode).toBe("Flex");
77
+ expect(rows[0]?.stateLabel).toBe("Sync only");
78
+ });
79
+
80
+ test("keeps real IBKR Flex profiles out of gateway status", () => {
81
+ const config = {
82
+ ...createDefaultConfig("/tmp/gloomberb-broker-manager"),
83
+ brokerInstances: [{
84
+ id: "ibkr-flex",
85
+ brokerType: "ibkr",
86
+ label: "IBKR Flex",
87
+ connectionMode: "flex",
88
+ config: {
89
+ connectionMode: "flex",
90
+ flex: { token: "token", queryId: "123", endpoint: "" },
91
+ gateway: { host: "127.0.0.1" },
92
+ },
93
+ enabled: true,
94
+ }],
95
+ };
96
+ const rows = buildBrokerProfileRows(config, new Map([["ibkr", ibkrBroker]]), {});
97
+
98
+ expect(rows[0]?.mode).toBe("Flex");
99
+ expect(rows[0]?.stateLabel).toBe("Sync only");
100
+ });
101
+
102
+ test("formats relative status timestamps", () => {
103
+ expect(formatBrokerUpdatedAt(undefined, 10_000)).toBe("never");
104
+ expect(formatBrokerUpdatedAt(9_000, 10_000)).toBe("just now");
105
+ expect(formatBrokerUpdatedAt(10_000 - 5 * 60_000, 10_000)).toBe("5m ago");
106
+ });
107
+ });
@@ -0,0 +1,142 @@
1
+ import type { BrokerAdapter, BrokerConnectionStatus } from "../../../types/broker";
2
+ import type { AppConfig, BrokerInstanceConfig } from "../../../types/config";
3
+ import type { BrokerAccount } from "../../../types/trading";
4
+ import { formatCurrency } from "../../../utils/format";
5
+
6
+ export type BrokerDisplayState =
7
+ | "disabled"
8
+ | "unavailable"
9
+ | "configured"
10
+ | BrokerConnectionStatus["state"];
11
+
12
+ export interface BrokerProfileRow {
13
+ id: string;
14
+ label: string;
15
+ brokerType: string;
16
+ brokerName: string;
17
+ mode: string;
18
+ state: BrokerDisplayState;
19
+ stateLabel: string;
20
+ message: string;
21
+ updatedAt: number;
22
+ accountCount: number;
23
+ accountSummary: string;
24
+ accountIds: string[];
25
+ instance: BrokerInstanceConfig;
26
+ adapter: BrokerAdapter | null;
27
+ }
28
+
29
+ function titleCase(value: string): string {
30
+ return value
31
+ .split(/[\s_-]+/g)
32
+ .filter(Boolean)
33
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
34
+ .join(" ");
35
+ }
36
+
37
+ export function formatBrokerMode(value: unknown): string {
38
+ const text = typeof value === "string" ? value.trim() : "";
39
+ return text ? titleCase(text) : "Configured";
40
+ }
41
+
42
+ export function formatBrokerUpdatedAt(updatedAt: number | undefined, now = Date.now()): string {
43
+ if (!updatedAt) return "never";
44
+ const ageMs = Math.max(0, now - updatedAt);
45
+ if (ageMs < 60_000) return "just now";
46
+ if (ageMs < 60 * 60_000) return `${Math.floor(ageMs / 60_000)}m ago`;
47
+ if (ageMs < 24 * 60 * 60_000) return `${Math.floor(ageMs / (60 * 60_000))}h ago`;
48
+ return `${Math.floor(ageMs / (24 * 60 * 60_000))}d ago`;
49
+ }
50
+
51
+ export function summarizeBrokerAccounts(accounts: BrokerAccount[]): string {
52
+ if (accounts.length === 0) return "0 acct";
53
+ const currencies = new Set(accounts.map((account) => account.currency || "USD"));
54
+ const canSum = currencies.size === 1 && accounts.every((account) => typeof account.netLiquidation === "number");
55
+ if (!canSum) return `${accounts.length} acct${accounts.length === 1 ? "" : "s"}`;
56
+ const currency = currencies.values().next().value ?? "USD";
57
+ const total = accounts.reduce((sum, account) => sum + (account.netLiquidation ?? 0), 0);
58
+ return formatCurrency(total, currency);
59
+ }
60
+
61
+ function resolveMode(
62
+ adapter: BrokerAdapter | null,
63
+ instance: BrokerInstanceConfig,
64
+ status: BrokerConnectionStatus | null,
65
+ ): string {
66
+ const values = adapter?.toConfigValues?.(instance) ?? instance.config;
67
+ return formatBrokerMode(
68
+ status?.mode
69
+ ?? values.connectionMode
70
+ ?? instance.connectionMode
71
+ ?? instance.config.connectionMode,
72
+ );
73
+ }
74
+
75
+ function resolveState(
76
+ instance: BrokerInstanceConfig,
77
+ adapter: BrokerAdapter | null,
78
+ mode: string,
79
+ status: BrokerConnectionStatus | null,
80
+ ): { state: BrokerDisplayState; label: string; message: string; updatedAt: number } {
81
+ if (instance.enabled === false) {
82
+ return { state: "disabled", label: "Disabled", message: "Profile is disabled", updatedAt: 0 };
83
+ }
84
+ if (!adapter) {
85
+ return { state: "unavailable", label: "Unavailable", message: "Broker plugin is not available", updatedAt: 0 };
86
+ }
87
+ if (mode.toLowerCase() === "flex") {
88
+ return {
89
+ state: "configured",
90
+ label: "Sync only",
91
+ message: "Flex profiles sync on demand",
92
+ updatedAt: status?.updatedAt ?? 0,
93
+ };
94
+ }
95
+ if (!status) {
96
+ return { state: "configured", label: "Configured", message: "Ready to test or sync", updatedAt: 0 };
97
+ }
98
+
99
+ const labels: Record<BrokerConnectionStatus["state"], string> = {
100
+ disconnected: "Disconnected",
101
+ connecting: "Connecting",
102
+ connected: "Connected",
103
+ error: "Error",
104
+ };
105
+ return {
106
+ state: status.state,
107
+ label: labels[status.state],
108
+ message: status.message || "",
109
+ updatedAt: status.updatedAt,
110
+ };
111
+ }
112
+
113
+ export function buildBrokerProfileRows(
114
+ config: AppConfig,
115
+ adapters: ReadonlyMap<string, BrokerAdapter | null>,
116
+ brokerAccounts: Record<string, BrokerAccount[]>,
117
+ ): BrokerProfileRow[] {
118
+ return config.brokerInstances.map((instance) => {
119
+ const adapter = adapters.get(instance.brokerType) ?? null;
120
+ const status = adapter?.getStatus?.(instance) ?? null;
121
+ const mode = resolveMode(adapter, instance, status);
122
+ const state = resolveState(instance, adapter, mode, status);
123
+ const accounts = brokerAccounts[instance.id] ?? [];
124
+
125
+ return {
126
+ id: instance.id,
127
+ label: instance.label,
128
+ brokerType: instance.brokerType,
129
+ brokerName: adapter?.name ?? instance.brokerType.toUpperCase(),
130
+ mode,
131
+ state: state.state,
132
+ stateLabel: state.label,
133
+ message: state.message,
134
+ updatedAt: state.updatedAt,
135
+ accountCount: accounts.length,
136
+ accountSummary: summarizeBrokerAccounts(accounts),
137
+ accountIds: accounts.map((account) => account.accountId),
138
+ instance,
139
+ adapter,
140
+ };
141
+ });
142
+ }
@@ -361,6 +361,60 @@ describe("ChatController", () => {
361
361
  });
362
362
  });
363
363
 
364
+ test("backfills from cached transcript when persisted cursor is ahead of the cache", async () => {
365
+ const persistence = new MemoryPersistence();
366
+ const controller = new ChatController();
367
+ const cached: ChatMessage = {
368
+ id: "m1",
369
+ channelId: "everyone",
370
+ content: "cached",
371
+ replyToId: null,
372
+ createdAt: "2026-03-28T00:00:00.000Z",
373
+ user: { id: "u1", username: "vince", displayName: "Vince" },
374
+ };
375
+ const fresh: ChatMessage = {
376
+ id: "m2",
377
+ channelId: "everyone",
378
+ content: "fresh",
379
+ replyToId: null,
380
+ createdAt: "2026-03-28T00:01:00.000Z",
381
+ user: { id: "u2", username: "bob", displayName: "Bob" },
382
+ };
383
+
384
+ persistence.setState("channel:everyone", {
385
+ draft: "",
386
+ replyToId: null,
387
+ lastCursor: "m99",
388
+ lastViewedMessageId: "m1",
389
+ }, { schemaVersion: 1 });
390
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
391
+ messages: [cached],
392
+ }, {
393
+ sourceKey: TRANSCRIPT_SOURCE,
394
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
395
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
396
+ });
397
+
398
+ controller.attachPersistence(persistence);
399
+
400
+ const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
401
+ apiClient.getMessages = async (channelId, opts) => {
402
+ calls.push({ channelId, opts });
403
+ return opts?.after === "m1" ? [fresh] : [];
404
+ };
405
+
406
+ await controller.refreshMessages();
407
+
408
+ expect(calls).toEqual([{
409
+ channelId: "everyone",
410
+ opts: { limit: 50, after: "m1" },
411
+ }]);
412
+ expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
413
+ expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
414
+ lastCursor: "m2",
415
+ });
416
+ });
417
+
364
418
  test("shows a pending message immediately and replaces it when the send succeeds", async () => {
365
419
  const persistence = new MemoryPersistence();
366
420
  const controller = new ChatController();
@@ -676,4 +730,79 @@ describe("ChatController", () => {
676
730
  lastCursor: "m2",
677
731
  });
678
732
  });
733
+
734
+ test("loads older messages before the oldest cached message without moving the latest cursor", async () => {
735
+ const persistence = new MemoryPersistence();
736
+ const controller = new ChatController();
737
+ const cached: ChatMessage[] = [
738
+ {
739
+ id: "m3",
740
+ channelId: "everyone",
741
+ content: "cached older",
742
+ replyToId: null,
743
+ createdAt: "2026-03-28T00:03:00.000Z",
744
+ user: { id: "u3", username: "cara", displayName: "Cara" },
745
+ },
746
+ {
747
+ id: "m4",
748
+ channelId: "everyone",
749
+ content: "cached newer",
750
+ replyToId: null,
751
+ createdAt: "2026-03-28T00:04:00.000Z",
752
+ user: { id: "u4", username: "drew", displayName: "Drew" },
753
+ },
754
+ ];
755
+ const older: ChatMessage[] = [
756
+ {
757
+ id: "m1",
758
+ channelId: "everyone",
759
+ content: "oldest",
760
+ replyToId: null,
761
+ createdAt: "2026-03-28T00:01:00.000Z",
762
+ user: { id: "u1", username: "alice", displayName: "Alice" },
763
+ },
764
+ {
765
+ id: "m2",
766
+ channelId: "everyone",
767
+ content: "older",
768
+ replyToId: null,
769
+ createdAt: "2026-03-28T00:02:00.000Z",
770
+ user: { id: "u2", username: "bob", displayName: "Bob" },
771
+ },
772
+ ];
773
+
774
+ persistence.setState("channel:everyone", {
775
+ draft: "",
776
+ replyToId: null,
777
+ lastCursor: "m4",
778
+ lastViewedMessageId: "m4",
779
+ }, { schemaVersion: 1 });
780
+ persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
781
+ messages: cached,
782
+ }, {
783
+ sourceKey: TRANSCRIPT_SOURCE,
784
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
785
+ cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
786
+ });
787
+
788
+ controller.attachPersistence(persistence);
789
+
790
+ const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
791
+ apiClient.getMessages = async (channelId, opts) => {
792
+ calls.push({ channelId, opts });
793
+ return opts?.before === "m3" ? older : [];
794
+ };
795
+
796
+ await controller.loadOlderMessages();
797
+
798
+ expect(calls).toEqual([{
799
+ channelId: "everyone",
800
+ opts: { limit: 50, before: "m3" },
801
+ }]);
802
+ expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2", "m3", "m4"]);
803
+ expect(controller.getSnapshot().hasOlderMessages).toBe(false);
804
+ expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
805
+ lastCursor: "m4",
806
+ });
807
+ });
679
808
  });
@@ -7,6 +7,7 @@ const SESSION_STATE_KEY = "session";
7
7
  const CHANNEL_STATE_KEY = "channel:everyone";
8
8
  const TRANSCRIPT_KIND = "channel-transcript";
9
9
  const TRANSCRIPT_SOURCE = "server";
10
+ const MESSAGE_PAGE_SIZE = 50;
10
11
  const MAX_CACHED_MESSAGES = 50;
11
12
  const SESSION_SCHEMA_VERSION = 1;
12
13
  const CHANNEL_SCHEMA_VERSION = 1;
@@ -40,6 +41,8 @@ interface PersistedTranscript {
40
41
 
41
42
  export interface ChatControllerSnapshot {
42
43
  loading: boolean;
44
+ loadingOlderMessages: boolean;
45
+ hasOlderMessages: boolean;
43
46
  hasSavedSession: boolean;
44
47
  user: { id: string; username: string; emailVerified: boolean } | null;
45
48
  messages: ChatMessage[];
@@ -80,6 +83,21 @@ function isLegacyTimestampCursor(cursor: string | null): boolean {
80
83
  return !!cursor && ISO_TIMESTAMP_CURSOR.test(cursor);
81
84
  }
82
85
 
86
+ function getLatestMessageId(messages: ChatMessage[]): string | null {
87
+ return messages[messages.length - 1]?.id ?? null;
88
+ }
89
+
90
+ function resolveHydratedCursor(messages: ChatMessage[], persistedCursor: string | null): string | null {
91
+ const transcriptCursor = getLatestMessageId(messages);
92
+ if (!transcriptCursor) return null;
93
+ if (!persistedCursor || isLegacyTimestampCursor(persistedCursor)) {
94
+ return persistedCursor ?? transcriptCursor;
95
+ }
96
+ // Desktop web can persist channel state without persisting the matching transcript cache.
97
+ // Backfill from the transcript we actually loaded when those two cursors diverge.
98
+ return persistedCursor === transcriptCursor ? persistedCursor : transcriptCursor;
99
+ }
100
+
83
101
  function compareMessages(a: ChatMessage, b: ChatMessage): number {
84
102
  // Preserve server/insertion order inside same-timestamp batches so the bottom-most
85
103
  // rendered message stays the one selected first from the composer.
@@ -93,7 +111,10 @@ export class ChatController {
93
111
  private hydrated = false;
94
112
  private sessionChecked = false;
95
113
  private messagesLoading = false;
114
+ private olderMessagesLoading = false;
96
115
  private refreshMessagesPromise: Promise<void> | null = null;
116
+ private loadOlderMessagesPromise: Promise<void> | null = null;
117
+ private reachedOldestMessage = false;
97
118
  private user: { id: string; username: string; emailVerified: boolean } | null = null;
98
119
  private messages: ChatMessage[] = [];
99
120
  private pendingMessages: ChatMessage[] = [];
@@ -158,11 +179,9 @@ export class ChatController {
158
179
  allowExpired: true,
159
180
  });
160
181
  this.messages = transcript?.value.messages ?? [];
161
- this.lastCursor = this.messages.length > 0
162
- ? persistedCursor ?? this.messages[this.messages.length - 1]?.id ?? null
163
- : null;
182
+ this.lastCursor = resolveHydratedCursor(this.messages, persistedCursor);
164
183
  this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
165
- ? persistedViewedMessageId ?? this.messages[this.messages.length - 1]?.id ?? null
184
+ ? persistedViewedMessageId ?? getLatestMessageId(this.messages)
166
185
  : null;
167
186
  this.syncVerificationPolling();
168
187
  }
@@ -178,6 +197,8 @@ export class ChatController {
178
197
  getSnapshot(): ChatControllerSnapshot {
179
198
  return {
180
199
  loading: !this.sessionChecked || this.messagesLoading,
200
+ loadingOlderMessages: this.olderMessagesLoading,
201
+ hasOlderMessages: this.messages.length > 0 && !this.reachedOldestMessage,
181
202
  hasSavedSession: !!apiClient.getSessionToken(),
182
203
  user: this.user,
183
204
  messages: this.getVisibleMessages(),
@@ -263,6 +284,28 @@ export class ChatController {
263
284
  return request;
264
285
  }
265
286
 
287
+ async loadOlderMessages(): Promise<void> {
288
+ if (this.loadOlderMessagesPromise) return this.loadOlderMessagesPromise;
289
+ const before = this.messages[0]?.id ?? null;
290
+ if (!before || this.reachedOldestMessage) return;
291
+
292
+ this.olderMessagesLoading = true;
293
+ this.emit();
294
+
295
+ const request = this.fetchOlderMessages(before)
296
+ .catch(() => {
297
+ this.persistChannelState();
298
+ })
299
+ .finally(() => {
300
+ this.olderMessagesLoading = false;
301
+ this.loadOlderMessagesPromise = null;
302
+ this.emit();
303
+ });
304
+
305
+ this.loadOlderMessagesPromise = request;
306
+ return request;
307
+ }
308
+
266
309
  setDraft(draft: string): void {
267
310
  if (draft === this.draft) return;
268
311
  this.draft = draft;
@@ -319,6 +362,7 @@ export class ChatController {
319
362
  this.replyToId = null;
320
363
  this.lastCursor = null;
321
364
  this.lastViewedMessageId = null;
365
+ this.reachedOldestMessage = false;
322
366
  this.openViewCount = 0;
323
367
  if (clearSession) {
324
368
  this.user = null;
@@ -342,7 +386,9 @@ export class ChatController {
342
386
  this.wsConnection = null;
343
387
  this.wsConnected = false;
344
388
  this.messagesLoading = false;
389
+ this.olderMessagesLoading = false;
345
390
  this.refreshMessagesPromise = null;
391
+ this.loadOlderMessagesPromise = null;
346
392
  this.openViewCount = 0;
347
393
  this.notifyFn = () => {};
348
394
  this.listeners.clear();
@@ -429,18 +475,25 @@ export class ChatController {
429
475
 
430
476
  private async fetchMessages(): Promise<void> {
431
477
  const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
478
+ const hasIncrementalCursor = !!this.lastCursor;
432
479
 
433
480
  try {
434
481
  const messages = await apiClient.getMessages("everyone", {
435
- limit: MAX_CACHED_MESSAGES,
482
+ limit: MESSAGE_PAGE_SIZE,
436
483
  after: this.lastCursor ?? undefined,
437
484
  });
485
+ if (!hasIncrementalCursor && messages.length < MESSAGE_PAGE_SIZE) {
486
+ this.reachedOldestMessage = true;
487
+ }
438
488
  if (messages.length > 0) {
439
489
  this.mergeMessages(messages);
440
490
  return;
441
491
  }
442
492
  if (legacyTimestampCursor) {
443
- const fullRefresh = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
493
+ const fullRefresh = await apiClient.getMessages("everyone", { limit: MESSAGE_PAGE_SIZE });
494
+ if (fullRefresh.length < MESSAGE_PAGE_SIZE) {
495
+ this.reachedOldestMessage = true;
496
+ }
444
497
  if (fullRefresh.length > 0) {
445
498
  this.mergeMessages(fullRefresh);
446
499
  return;
@@ -450,7 +503,10 @@ export class ChatController {
450
503
  this.persistChannelState();
451
504
  return;
452
505
  } catch {
453
- const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
506
+ const messages = await apiClient.getMessages("everyone", { limit: MESSAGE_PAGE_SIZE });
507
+ if (messages.length < MESSAGE_PAGE_SIZE) {
508
+ this.reachedOldestMessage = true;
509
+ }
454
510
  if (messages.length > 0) {
455
511
  this.mergeMessages(messages);
456
512
  return;
@@ -459,7 +515,22 @@ export class ChatController {
459
515
  }
460
516
  }
461
517
 
462
- private mergeMessages(messages: ChatMessage[]): void {
518
+ private async fetchOlderMessages(before: string): Promise<void> {
519
+ const messages = await apiClient.getMessages("everyone", {
520
+ limit: MESSAGE_PAGE_SIZE,
521
+ before,
522
+ });
523
+ if (messages.length < MESSAGE_PAGE_SIZE) {
524
+ this.reachedOldestMessage = true;
525
+ }
526
+ if (messages.length === 0) {
527
+ this.persistChannelState();
528
+ return;
529
+ }
530
+ this.mergeMessages(messages, { notifyMentions: false });
531
+ }
532
+
533
+ private mergeMessages(messages: ChatMessage[], options?: { notifyMentions?: boolean }): void {
463
534
  this.reconcilePendingMessages(messages);
464
535
  const merged = new Map<string, ChatMessage>();
465
536
  const incoming = new Map<string, ChatMessage>();
@@ -471,13 +542,14 @@ export class ChatController {
471
542
  merged.set(message.id, message);
472
543
  }
473
544
  this.messages = [...merged.values()]
474
- .sort(compareMessages)
475
- .slice(-MAX_CACHED_MESSAGES);
545
+ .sort(compareMessages);
476
546
  this.lastCursor = this.messages[this.messages.length - 1]?.id ?? this.lastCursor;
477
547
  if (this.openViewCount > 0) {
478
548
  this.markViewedThroughLatestMessage(false);
479
549
  }
480
- this.trackUnreadMentions([...incoming.values()]);
550
+ if (options?.notifyMentions !== false) {
551
+ this.trackUnreadMentions([...incoming.values()]);
552
+ }
481
553
  this.persistTranscript();
482
554
  this.emit();
483
555
  }
@@ -518,7 +590,7 @@ export class ChatController {
518
590
  }
519
591
 
520
592
  this.persistence?.setResource(TRANSCRIPT_KIND, "everyone", {
521
- messages: this.messages,
593
+ messages: this.messages.slice(-MAX_CACHED_MESSAGES),
522
594
  } satisfies PersistedTranscript, {
523
595
  sourceKey: TRANSCRIPT_SOURCE,
524
596
  schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
@@ -591,8 +663,7 @@ export class ChatController {
591
663
 
592
664
  private getVisibleMessages(): ChatMessage[] {
593
665
  return [...this.messages, ...this.pendingMessages]
594
- .sort(compareMessages)
595
- .slice(-MAX_CACHED_MESSAGES);
666
+ .sort(compareMessages);
596
667
  }
597
668
 
598
669
  private createPendingMessage(content: string, replyToId?: string): ChatMessage {