gloomberb 0.5.0 → 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 -13
  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 +13 -4
  85. package/src/components/layout/pane-title.ts +35 -0
  86. package/src/components/layout/pane.tsx +56 -8
  87. package/src/components/layout/shell.test.tsx +322 -19
  88. package/src/components/layout/shell.tsx +1010 -366
  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 +150 -0
  180. package/src/plugins/builtin/chat-controller.ts +88 -16
  181. package/src/plugins/builtin/chat.test.tsx +283 -36
  182. package/src/plugins/builtin/chat.tsx +373 -172
  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 +186 -6
  440. package/src/utils/api-client.ts +433 -33
  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
+ }
@@ -155,6 +155,27 @@ describe("ChatController", () => {
155
155
  });
156
156
  });
157
157
 
158
+ test("does not restore persisted websocket tokens", () => {
159
+ const persistence = new MemoryPersistence();
160
+ const controller = new ChatController();
161
+
162
+ persistence.setState("session", {
163
+ sessionToken: "token-123",
164
+ websocketToken: "stale-ws-token",
165
+ user: { id: "u1", username: "vince", emailVerified: true },
166
+ }, { schemaVersion: 1 });
167
+
168
+ controller.attachPersistence(persistence);
169
+
170
+ expect(apiClient.getSessionToken()).toBe("token-123");
171
+ expect(apiClient.getWebSocketToken()).toBeNull();
172
+ expect(apiClient.getCurrentUser()).toMatchObject({
173
+ id: "u1",
174
+ username: "vince",
175
+ emailVerified: true,
176
+ });
177
+ });
178
+
158
179
  test("reset clears persisted chat state and session token", () => {
159
180
  const persistence = new MemoryPersistence();
160
181
  const controller = new ChatController();
@@ -340,6 +361,60 @@ describe("ChatController", () => {
340
361
  });
341
362
  });
342
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
+
343
418
  test("shows a pending message immediately and replaces it when the send succeeds", async () => {
344
419
  const persistence = new MemoryPersistence();
345
420
  const controller = new ChatController();
@@ -655,4 +730,79 @@ describe("ChatController", () => {
655
730
  lastCursor: "m2",
656
731
  });
657
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
+ });
658
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[] = [];
@@ -129,7 +150,9 @@ export class ChatController {
129
150
  schemaVersion: SESSION_SCHEMA_VERSION,
130
151
  });
131
152
  apiClient.setSessionToken(session?.sessionToken ?? null);
132
- apiClient.setWebSocketToken(session?.websocketToken ?? null);
153
+ // WebSocket tokens are short-lived connection credentials. Reusing a persisted
154
+ // one can trap reconnects on an expired token even while the session cookie is valid.
155
+ apiClient.setWebSocketToken(null);
133
156
  apiClient.restoreCachedUser(session?.user ?? null);
134
157
  this.user = session?.user
135
158
  ? {
@@ -156,11 +179,9 @@ export class ChatController {
156
179
  allowExpired: true,
157
180
  });
158
181
  this.messages = transcript?.value.messages ?? [];
159
- this.lastCursor = this.messages.length > 0
160
- ? persistedCursor ?? this.messages[this.messages.length - 1]?.id ?? null
161
- : null;
182
+ this.lastCursor = resolveHydratedCursor(this.messages, persistedCursor);
162
183
  this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
163
- ? persistedViewedMessageId ?? this.messages[this.messages.length - 1]?.id ?? null
184
+ ? persistedViewedMessageId ?? getLatestMessageId(this.messages)
164
185
  : null;
165
186
  this.syncVerificationPolling();
166
187
  }
@@ -176,6 +197,8 @@ export class ChatController {
176
197
  getSnapshot(): ChatControllerSnapshot {
177
198
  return {
178
199
  loading: !this.sessionChecked || this.messagesLoading,
200
+ loadingOlderMessages: this.olderMessagesLoading,
201
+ hasOlderMessages: this.messages.length > 0 && !this.reachedOldestMessage,
179
202
  hasSavedSession: !!apiClient.getSessionToken(),
180
203
  user: this.user,
181
204
  messages: this.getVisibleMessages(),
@@ -261,6 +284,28 @@ export class ChatController {
261
284
  return request;
262
285
  }
263
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
+
264
309
  setDraft(draft: string): void {
265
310
  if (draft === this.draft) return;
266
311
  this.draft = draft;
@@ -317,6 +362,7 @@ export class ChatController {
317
362
  this.replyToId = null;
318
363
  this.lastCursor = null;
319
364
  this.lastViewedMessageId = null;
365
+ this.reachedOldestMessage = false;
320
366
  this.openViewCount = 0;
321
367
  if (clearSession) {
322
368
  this.user = null;
@@ -340,7 +386,9 @@ export class ChatController {
340
386
  this.wsConnection = null;
341
387
  this.wsConnected = false;
342
388
  this.messagesLoading = false;
389
+ this.olderMessagesLoading = false;
343
390
  this.refreshMessagesPromise = null;
391
+ this.loadOlderMessagesPromise = null;
344
392
  this.openViewCount = 0;
345
393
  this.notifyFn = () => {};
346
394
  this.listeners.clear();
@@ -427,18 +475,25 @@ export class ChatController {
427
475
 
428
476
  private async fetchMessages(): Promise<void> {
429
477
  const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
478
+ const hasIncrementalCursor = !!this.lastCursor;
430
479
 
431
480
  try {
432
481
  const messages = await apiClient.getMessages("everyone", {
433
- limit: MAX_CACHED_MESSAGES,
482
+ limit: MESSAGE_PAGE_SIZE,
434
483
  after: this.lastCursor ?? undefined,
435
484
  });
485
+ if (!hasIncrementalCursor && messages.length < MESSAGE_PAGE_SIZE) {
486
+ this.reachedOldestMessage = true;
487
+ }
436
488
  if (messages.length > 0) {
437
489
  this.mergeMessages(messages);
438
490
  return;
439
491
  }
440
492
  if (legacyTimestampCursor) {
441
- 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
+ }
442
497
  if (fullRefresh.length > 0) {
443
498
  this.mergeMessages(fullRefresh);
444
499
  return;
@@ -448,7 +503,10 @@ export class ChatController {
448
503
  this.persistChannelState();
449
504
  return;
450
505
  } catch {
451
- 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
+ }
452
510
  if (messages.length > 0) {
453
511
  this.mergeMessages(messages);
454
512
  return;
@@ -457,7 +515,22 @@ export class ChatController {
457
515
  }
458
516
  }
459
517
 
460
- 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 {
461
534
  this.reconcilePendingMessages(messages);
462
535
  const merged = new Map<string, ChatMessage>();
463
536
  const incoming = new Map<string, ChatMessage>();
@@ -469,13 +542,14 @@ export class ChatController {
469
542
  merged.set(message.id, message);
470
543
  }
471
544
  this.messages = [...merged.values()]
472
- .sort(compareMessages)
473
- .slice(-MAX_CACHED_MESSAGES);
545
+ .sort(compareMessages);
474
546
  this.lastCursor = this.messages[this.messages.length - 1]?.id ?? this.lastCursor;
475
547
  if (this.openViewCount > 0) {
476
548
  this.markViewedThroughLatestMessage(false);
477
549
  }
478
- this.trackUnreadMentions([...incoming.values()]);
550
+ if (options?.notifyMentions !== false) {
551
+ this.trackUnreadMentions([...incoming.values()]);
552
+ }
479
553
  this.persistTranscript();
480
554
  this.emit();
481
555
  }
@@ -483,7 +557,6 @@ export class ChatController {
483
557
  private persistSession(): void {
484
558
  const value = {
485
559
  sessionToken: apiClient.getSessionToken(),
486
- websocketToken: apiClient.getWebSocketToken(),
487
560
  user: this.user,
488
561
  } satisfies PersistedSessionState;
489
562
  this.resume?.setState(SESSION_STATE_KEY, value, {
@@ -517,7 +590,7 @@ export class ChatController {
517
590
  }
518
591
 
519
592
  this.persistence?.setResource(TRANSCRIPT_KIND, "everyone", {
520
- messages: this.messages,
593
+ messages: this.messages.slice(-MAX_CACHED_MESSAGES),
521
594
  } satisfies PersistedTranscript, {
522
595
  sourceKey: TRANSCRIPT_SOURCE,
523
596
  schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
@@ -590,8 +663,7 @@ export class ChatController {
590
663
 
591
664
  private getVisibleMessages(): ChatMessage[] {
592
665
  return [...this.messages, ...this.pendingMessages]
593
- .sort(compareMessages)
594
- .slice(-MAX_CACHED_MESSAGES);
666
+ .sort(compareMessages);
595
667
  }
596
668
 
597
669
  private createPendingMessage(content: string, replyToId?: string): ChatMessage {