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
@@ -8,836 +8,70 @@ import {
8
8
  useReducer,
9
9
  useRef,
10
10
  useSyncExternalStore,
11
+ type Dispatch,
11
12
  type SetStateAction,
12
13
  type ReactNode,
13
14
  } from "react";
14
15
  import type { SessionStore } from "../data/session-store";
15
- import { saveConfig } from "../data/config-store";
16
- import { applyTheme, syncTheme } from "../theme/colors";
17
- import { isBrokerPortfolioId } from "../utils/broker-instances";
18
- import { resolveTickerFinancialsQuoteState } from "../utils/quote-resolution";
16
+ import { syncTheme } from "../theme/colors";
19
17
  import {
20
- cloneLayout,
21
- DEFAULT_LAYOUT,
22
18
  findPaneInstance,
23
- isFixedTickerPane,
24
- normalizePaneLayout,
25
- removePaneInstances,
26
- type LayoutConfig,
19
+ materializeDetachedPanesAsFloating,
20
+ type AppConfig,
21
+ type PaneInstanceConfig,
27
22
  } from "../types/config";
23
+ import type { DesktopSharedStateSnapshot, DesktopWindowBridge } from "../types/desktop-window";
28
24
  import { setPaneSetting } from "../pane-settings";
29
- import type { AppConfig, PaneBinding, PaneInstanceConfig, SavedLayout } from "../types/config";
30
- import type { Quote, TickerFinancials } from "../types/financials";
31
- import type { TickerRecord } from "../types/ticker";
32
- import type { BrokerAccount } from "../types/trading";
33
- import type { ReleaseInfo, UpdateProgress } from "../updater";
34
- import { getDockLeafLayouts, getDockedPaneIds } from "../plugins/pane-manager";
35
25
  import { useTickerFinancials } from "../market-data/hooks";
36
26
  import {
37
27
  APP_SESSION_ID,
38
28
  APP_SESSION_SCHEMA_VERSION,
39
29
  type AppSessionSnapshot,
40
- } from "./session-persistence";
30
+ } from "../core/state/session-persistence";
41
31
  import { usePersistSessionSnapshot } from "./use-session-snapshot";
42
-
43
- export interface PaneRuntimeState {
44
- cursorSymbol?: string | null;
45
- collectionId?: string;
46
- activeTabId?: string;
47
- collectionSorts?: Record<string, CollectionSortPreference>;
48
- pluginState?: Record<string, Record<string, unknown>>;
49
- [key: string]: unknown;
50
- }
51
-
52
- export interface LayoutHistoryEntry {
53
- past: LayoutConfig[];
54
- future: LayoutConfig[];
55
- }
56
-
57
- export type SortDirection = "asc" | "desc";
58
-
59
- export interface CollectionSortPreference {
60
- columnId: string | null;
61
- direction: SortDirection;
62
- }
63
-
64
- export interface CommandBarLaunchRequest {
65
- kind: "plugin-command";
66
- commandId: string;
67
- sequence: number;
68
- }
69
-
70
- export interface AppState {
71
- config: AppConfig;
72
- tickers: Map<string, TickerRecord>;
73
- financials: Map<string, TickerFinancials>;
74
- exchangeRates: Map<string, number>;
75
- brokerAccounts: Record<string, BrokerAccount[]>;
76
- activePanel: "left" | "right";
77
- focusedPaneId: string | null;
78
- paneState: Record<string, PaneRuntimeState>;
79
- recentTickers: string[];
80
- commandBarOpen: boolean;
81
- commandBarQuery: string;
82
- commandBarLaunchRequest: CommandBarLaunchRequest | null;
83
- refreshing: Set<string>;
84
- initialized: boolean;
85
- statusBarVisible: boolean;
86
- gridlockTipVisible: boolean;
87
- gridlockTipSequence: number;
88
- inputCaptured: boolean;
89
- updateAvailable: ReleaseInfo | null;
90
- updateProgress: UpdateProgress | null;
91
- updateCheckInProgress: boolean;
92
- updateNotice: string | null;
93
- layoutHistory: Record<number, LayoutHistoryEntry>;
94
- }
95
-
96
- export type AppAction =
97
- | { type: "SET_CONFIG"; config: AppConfig }
98
- | { type: "SET_TICKERS"; tickers: Map<string, TickerRecord> }
99
- | { type: "UPDATE_TICKER"; ticker: TickerRecord }
100
- | { type: "REMOVE_TICKER"; symbol: string }
101
- | { type: "SET_FINANCIALS"; symbol: string; data: TickerFinancials }
102
- | { type: "MERGE_QUOTE"; symbol: string; quote: Quote }
103
- | { type: "HYDRATE_FINANCIALS"; financials: Map<string, TickerFinancials> }
104
- | { type: "TRACK_TICKER"; symbol: string | null }
105
- | { type: "SET_ACTIVE_PANEL"; panel: "left" | "right" }
106
- | { type: "TOGGLE_COMMAND_BAR" }
107
- | {
108
- type: "SET_COMMAND_BAR";
109
- open: boolean;
110
- query?: string;
111
- launch?: { kind: "plugin-command"; commandId: string } | null;
112
- }
113
- | { type: "SET_COMMAND_BAR_QUERY"; query: string }
114
- | { type: "SET_REFRESHING"; symbol: string; refreshing: boolean }
115
- | { type: "SET_BROKER_ACCOUNTS"; instanceId: string; accounts: BrokerAccount[] }
116
- | { type: "SET_INITIALIZED" }
117
- | { type: "TOGGLE_STATUS_BAR" }
118
- | { type: "SHOW_GRIDLOCK_TIP" }
119
- | { type: "DISMISS_GRIDLOCK_TIP" }
120
- | { type: "SET_THEME"; theme: string }
121
- | { type: "SET_UPDATE_AVAILABLE"; release: ReleaseInfo | null }
122
- | { type: "SET_UPDATE_PROGRESS"; progress: UpdateProgress | null }
123
- | { type: "SET_UPDATE_CHECK_IN_PROGRESS"; checking: boolean }
124
- | { type: "SET_UPDATE_NOTICE"; notice: string | null }
125
- | { type: "TOGGLE_PLUGIN"; pluginId: string }
126
- | { type: "SET_INPUT_CAPTURED"; captured: boolean }
127
- | { type: "SET_EXCHANGE_RATE"; currency: string; rate: number }
128
- | { type: "HYDRATE_EXCHANGE_RATES"; exchangeRates: Map<string, number> }
129
- | { type: "PUSH_LAYOUT_HISTORY" }
130
- | { type: "UNDO_LAYOUT" }
131
- | { type: "REDO_LAYOUT" }
132
- | { type: "UPDATE_LAYOUT"; layout: LayoutConfig }
133
- | { type: "SWITCH_LAYOUT"; index: number }
134
- | { type: "NEW_LAYOUT"; name: string }
135
- | { type: "DELETE_LAYOUT"; index: number }
136
- | { type: "RENAME_LAYOUT"; index: number; name: string }
137
- | { type: "DUPLICATE_LAYOUT"; index: number }
138
- | { type: "FOCUS_PANE"; paneId: string }
139
- | { type: "FOCUS_NEXT"; paneOrder: string[] }
140
- | { type: "FOCUS_PREV"; paneOrder: string[] }
141
- | {
142
- type: "UPDATE_PLUGIN_PANE_STATE";
143
- paneId: string;
144
- pluginId: string;
145
- key: string;
146
- value: unknown;
147
- }
148
- | { type: "UPDATE_PANE_STATE"; paneId: string; patch: Partial<PaneRuntimeState> };
149
-
150
- function getDefaultCollectionId(config: AppConfig): string {
151
- return config.portfolios[0]?.id || config.watchlists[0]?.id || "";
152
- }
153
-
154
- function isKnownCollection(config: AppConfig, collectionId: string | undefined): collectionId is string {
155
- if (!collectionId) return false;
156
- return config.portfolios.some((portfolio) => portfolio.id === collectionId)
157
- || config.watchlists.some((watchlist) => watchlist.id === collectionId);
158
- }
159
-
160
- function shouldPreserveUnknownCollectionId(collectionId: string | undefined): boolean {
161
- return isBrokerPortfolioId(collectionId);
162
- }
163
-
164
- function getConfiguredCollectionId(config: AppConfig, instance: PaneInstanceConfig): string {
165
- const candidates = [
166
- instance.params?.collectionId,
167
- typeof instance.settings?.lockedCollectionId === "string" ? instance.settings.lockedCollectionId : undefined,
168
- ...(Array.isArray(instance.settings?.visibleCollectionIds)
169
- ? instance.settings.visibleCollectionIds.filter((value): value is string => typeof value === "string")
170
- : []),
171
- ];
172
-
173
- for (const candidate of candidates) {
174
- if (isKnownCollection(config, candidate) || shouldPreserveUnknownCollectionId(candidate)) {
175
- return candidate!;
176
- }
177
- }
178
-
179
- return getDefaultCollectionId(config);
180
- }
181
-
182
- function defaultPaneStateForInstance(config: AppConfig, instance: PaneInstanceConfig): PaneRuntimeState {
183
- if (instance.paneId === "portfolio-list") {
184
- return {
185
- collectionId: getConfiguredCollectionId(config, instance),
186
- cursorSymbol: null,
187
- };
188
- }
189
- if (instance.paneId === "ticker-detail") {
190
- return { activeTabId: "overview" };
191
- }
192
- return {};
193
- }
194
-
195
- function reconcilePaneState(config: AppConfig, previous: Record<string, PaneRuntimeState>): Record<string, PaneRuntimeState> {
196
- const next: Record<string, PaneRuntimeState> = {};
197
- for (const instance of config.layout.instances) {
198
- const defaults = defaultPaneStateForInstance(config, instance);
199
- const paneState = { ...defaults, ...(previous[instance.instanceId] ?? {}) };
200
- if (
201
- instance.paneId === "portfolio-list"
202
- && !isKnownCollection(config, paneState.collectionId as string | undefined)
203
- && !shouldPreserveUnknownCollectionId(paneState.collectionId as string | undefined)
204
- ) {
205
- paneState.collectionId = defaults.collectionId;
206
- }
207
- next[instance.instanceId] = paneState;
208
- }
209
- return next;
210
- }
211
-
212
- function reconcileBrokerAccounts(
213
- config: AppConfig,
214
- brokerAccounts: Record<string, BrokerAccount[]>,
215
- ): Record<string, BrokerAccount[]> {
216
- const validInstanceIds = new Set(config.brokerInstances.map((instance) => instance.id));
217
- return Object.fromEntries(
218
- Object.entries(brokerAccounts).filter(([instanceId]) => validInstanceIds.has(instanceId)),
219
- );
220
- }
221
-
222
- function resolveTickerFromBinding(
223
- state: Pick<AppState, "config" | "paneState">,
224
- binding: PaneBinding | undefined,
225
- seen: Set<string>,
226
- ): string | null {
227
- if (!binding || binding.kind === "none") return null;
228
- if (binding.kind === "fixed") return binding.symbol;
229
- return resolveTickerForPane(state as AppState, binding.sourceInstanceId, seen);
230
- }
231
-
232
- export function getPaneState(state: Pick<AppState, "paneState">, paneId: string): PaneRuntimeState {
233
- return state.paneState[paneId] ?? {};
234
- }
235
-
236
- export function resolveTickerForPane(state: AppState, paneId: string, seen = new Set<string>()): string | null {
237
- if (seen.has(paneId)) return null;
238
- seen.add(paneId);
239
- const instance = findPaneInstance(state.config.layout, paneId);
240
- if (!instance) return null;
241
- if (instance.paneId === "portfolio-list") {
242
- const paneState = getPaneState(state, paneId);
243
- return typeof paneState.cursorSymbol === "string" ? paneState.cursorSymbol : null;
244
- }
245
- return resolveTickerFromBinding(state, instance.binding, seen);
246
- }
247
-
248
- export function resolveCollectionForPane(state: AppState, paneId: string, seen = new Set<string>()): string | null {
249
- if (seen.has(paneId)) return null;
250
- seen.add(paneId);
251
- const instance = findPaneInstance(state.config.layout, paneId);
252
- if (!instance) return null;
253
- if (instance.paneId === "portfolio-list") {
254
- const paneState = getPaneState(state, paneId);
255
- const collectionId = typeof paneState.collectionId === "string"
256
- ? paneState.collectionId
257
- : getConfiguredCollectionId(state.config, instance);
258
- if (isKnownCollection(state.config, collectionId) || shouldPreserveUnknownCollectionId(collectionId)) {
259
- return collectionId ?? null;
260
- }
261
- return getConfiguredCollectionId(state.config, instance);
262
- }
263
- if (instance.binding?.kind === "follow") {
264
- return resolveCollectionForPane(state, instance.binding.sourceInstanceId, seen);
265
- }
266
- return null;
267
- }
268
-
269
- export function resolveTickerFileForPane(state: AppState, paneId: string): TickerRecord | null {
270
- const symbol = resolveTickerForPane(state, paneId);
271
- return symbol ? state.tickers.get(symbol) ?? null : null;
272
- }
273
-
274
- export function resolveFinancialsForPane(state: AppState, paneId: string): TickerFinancials | null {
275
- const symbol = resolveTickerForPane(state, paneId);
276
- return symbol ? state.financials.get(symbol) ?? null : null;
277
- }
278
-
279
- export function getFocusedTickerSymbol(state: AppState): string | null {
280
- return state.focusedPaneId ? resolveTickerForPane(state, state.focusedPaneId) : null;
281
- }
282
-
283
- export function getFocusedCollectionId(state: AppState): string | null {
284
- return state.focusedPaneId ? resolveCollectionForPane(state, state.focusedPaneId) : null;
285
- }
286
-
287
- function clearTickerBindings(layout: LayoutConfig, symbol: string): LayoutConfig {
288
- return normalizePaneLayout(removePaneInstances(
289
- layout,
290
- layout.instances
291
- .filter((instance) => instance.binding?.kind === "fixed" && isFixedTickerPane(instance) && instance.binding.symbol === symbol)
292
- .map((instance) => instance.instanceId),
293
- ));
294
- }
295
-
296
- function nextRecentTickers(current: string[], symbol: string | null): string[] {
297
- if (!symbol) return current;
298
- return [symbol, ...current.filter((entry) => entry !== symbol)].slice(0, 50);
299
- }
300
-
301
- function syncLayouts(layouts: SavedLayout[], activeLayoutIndex: number, layout: LayoutConfig): SavedLayout[] {
302
- return layouts.map((savedLayout, index) => (
303
- index === activeLayoutIndex ? { ...savedLayout, layout: cloneLayout(layout) } : savedLayout
304
- ));
305
- }
306
-
307
- const PANEL_RESOLUTION_BOUNDS = { x: 0, y: 0, width: 120, height: 40 };
308
-
309
- function getPaneOrder(layout: LayoutConfig): string[] {
310
- return [
311
- ...getDockedPaneIds(layout),
312
- ...layout.floating.map((entry) => entry.instanceId),
313
- ];
314
- }
315
-
316
- function getTopFloatingPaneId(layout: LayoutConfig): string | null {
317
- let topInstanceId: string | null = null;
318
- let topZIndex = Number.NEGATIVE_INFINITY;
319
- let topOrder = -1;
320
-
321
- layout.floating.forEach((entry, index) => {
322
- const zIndex = entry.zIndex ?? 50;
323
- if (zIndex > topZIndex || (zIndex === topZIndex && index > topOrder)) {
324
- topInstanceId = entry.instanceId;
325
- topZIndex = zIndex;
326
- topOrder = index;
327
- }
328
- });
329
-
330
- return topInstanceId;
331
- }
332
-
333
- function resolveFocusedPaneId(
334
- previousLayout: LayoutConfig,
335
- nextLayout: LayoutConfig,
336
- focusedPaneId: string | null,
337
- ): string | null {
338
- const paneOrder = getPaneOrder(nextLayout);
339
- if (paneOrder.length === 0) return null;
340
- if (focusedPaneId && paneOrder.includes(focusedPaneId)) {
341
- return focusedPaneId;
342
- }
343
- const previouslyFloating = focusedPaneId
344
- ? previousLayout.floating.some((entry) => entry.instanceId === focusedPaneId)
345
- : false;
346
- if (previouslyFloating) {
347
- return getTopFloatingPaneId(nextLayout) ?? paneOrder[0] ?? null;
348
- }
349
- return paneOrder[0] ?? null;
350
- }
351
-
352
- function getPanelFocusTarget(layout: LayoutConfig, panel: "left" | "right"): string | null {
353
- const leaves = getDockLeafLayouts(layout, PANEL_RESOLUTION_BOUNDS);
354
- if (leaves.length === 0) return layout.floating[0]?.instanceId ?? null;
355
- const sorted = [...leaves].sort((a, b) => (
356
- panel === "left"
357
- ? a.rect.x - b.rect.x || a.rect.y - b.rect.y
358
- : (b.rect.x + b.rect.width) - (a.rect.x + a.rect.width) || a.rect.y - b.rect.y
359
- ));
360
- return sorted[0]?.instanceId ?? null;
361
- }
362
-
363
- const MAX_LAYOUT_HISTORY = 50;
364
-
365
- function cloneHistoryEntry(entry: LayoutHistoryEntry | undefined): LayoutHistoryEntry {
366
- return {
367
- past: entry?.past.map((layout) => cloneLayout(layout)) ?? [],
368
- future: entry?.future.map((layout) => cloneLayout(layout)) ?? [],
369
- };
370
- }
371
-
372
- function historyForIndex(layoutHistory: Record<number, LayoutHistoryEntry>, index: number): LayoutHistoryEntry {
373
- return cloneHistoryEntry(layoutHistory[index]);
374
- }
375
-
376
- function setHistoryForIndex(
377
- layoutHistory: Record<number, LayoutHistoryEntry>,
378
- index: number,
379
- entry: LayoutHistoryEntry,
380
- ): Record<number, LayoutHistoryEntry> {
381
- return {
382
- ...layoutHistory,
383
- [index]: {
384
- past: entry.past.map((layout) => cloneLayout(layout)),
385
- future: entry.future.map((layout) => cloneLayout(layout)),
386
- },
387
- };
388
- }
389
-
390
- function removeHistoryIndex(layoutHistory: Record<number, LayoutHistoryEntry>, removedIndex: number): Record<number, LayoutHistoryEntry> {
391
- const next: Record<number, LayoutHistoryEntry> = {};
392
- for (const [rawIndex, entry] of Object.entries(layoutHistory)) {
393
- const index = Number.parseInt(rawIndex, 10);
394
- if (Number.isNaN(index) || index === removedIndex) continue;
395
- next[index > removedIndex ? index - 1 : index] = cloneHistoryEntry(entry);
396
- }
397
- return next;
398
- }
399
-
400
- /** If paneId is a floating pane, bump its zIndex to the top. */
401
- function bringFloatingToFront(layout: LayoutConfig, paneId: string): LayoutConfig {
402
- const entry = layout.floating.find((e) => e.instanceId === paneId);
403
- if (!entry) return layout;
404
- const maxZ = layout.floating.reduce((max, e) => Math.max(max, e.zIndex ?? 50), 0);
405
- if ((entry.zIndex ?? 50) >= maxZ) return layout; // already on top
406
- return {
407
- ...layout,
408
- floating: layout.floating.map((e) =>
409
- e.instanceId === paneId ? { ...e, zIndex: maxZ + 1 } : e,
410
- ),
411
- };
412
- }
413
-
414
- function withFocusedPane(state: AppState, config: AppConfig): AppState {
415
- const normalizedLayout = normalizePaneLayout(config.layout);
416
- const nextConfig = normalizedLayout === config.layout
417
- ? config
418
- : {
419
- ...config,
420
- layout: normalizedLayout,
421
- layouts: syncLayouts(config.layouts, config.activeLayoutIndex, normalizedLayout),
422
- };
423
- const nextPaneState = reconcilePaneState(nextConfig, state.paneState);
424
- const focusedPaneId = resolveFocusedPaneId(state.config.layout, nextConfig.layout, state.focusedPaneId);
425
- return {
426
- ...state,
427
- config: nextConfig,
428
- paneState: nextPaneState,
429
- brokerAccounts: reconcileBrokerAccounts(nextConfig, state.brokerAccounts),
430
- focusedPaneId,
431
- };
432
- }
433
-
434
- export function appReducer(state: AppState, action: AppAction): AppState {
435
- switch (action.type) {
436
- case "SET_CONFIG":
437
- return withFocusedPane({ ...state, layoutHistory: {} }, action.config);
438
-
439
- case "SET_TICKERS":
440
- return { ...state, tickers: action.tickers };
441
-
442
- case "UPDATE_TICKER": {
443
- const tickers = new Map(state.tickers);
444
- tickers.set(action.ticker.metadata.ticker, action.ticker);
445
- return { ...state, tickers };
446
- }
447
-
448
- case "REMOVE_TICKER": {
449
- const tickers = new Map(state.tickers);
450
- tickers.delete(action.symbol);
451
- const financials = new Map(state.financials);
452
- financials.delete(action.symbol);
453
- const paneState: Record<string, PaneRuntimeState> = {};
454
- for (const [paneId, currentState] of Object.entries(state.paneState)) {
455
- paneState[paneId] = currentState.cursorSymbol === action.symbol
456
- ? { ...currentState, cursorSymbol: null }
457
- : currentState;
458
- }
459
- const layout = clearTickerBindings(state.config.layout, action.symbol);
460
- const layouts = syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout);
461
- return {
462
- ...state,
463
- tickers,
464
- financials,
465
- paneState,
466
- recentTickers: state.recentTickers.filter((symbol) => symbol !== action.symbol),
467
- config: { ...state.config, layout, layouts },
468
- };
469
- }
470
-
471
- case "SET_FINANCIALS": {
472
- const financials = new Map(state.financials);
473
- financials.set(action.symbol, resolveTickerFinancialsQuoteState(action.data)!);
474
- return { ...state, financials };
475
- }
476
-
477
- case "MERGE_QUOTE": {
478
- const financials = new Map(state.financials);
479
- const current = state.financials.get(action.symbol);
480
- financials.set(action.symbol, resolveTickerFinancialsQuoteState(current, action.quote)!);
481
- return { ...state, financials };
482
- }
483
-
484
- case "HYDRATE_FINANCIALS": {
485
- if (action.financials.size === 0) return state;
486
- const financials = new Map(state.financials);
487
- for (const [symbol, data] of action.financials) {
488
- financials.set(symbol, resolveTickerFinancialsQuoteState(data)!);
489
- }
490
- return { ...state, financials };
491
- }
492
-
493
- case "TRACK_TICKER":
494
- return { ...state, recentTickers: nextRecentTickers(state.recentTickers, action.symbol) };
495
-
496
- case "SET_ACTIVE_PANEL": {
497
- const firstPane = getPanelFocusTarget(state.config.layout, action.panel);
498
- return {
499
- ...state,
500
- activePanel: action.panel,
501
- focusedPaneId: firstPane ?? state.focusedPaneId,
502
- };
503
- }
504
-
505
- case "TOGGLE_COMMAND_BAR":
506
- return state.commandBarOpen
507
- ? { ...state, commandBarOpen: false, commandBarQuery: "", commandBarLaunchRequest: null }
508
- : { ...state, commandBarOpen: true, commandBarQuery: "", commandBarLaunchRequest: null };
509
-
510
- case "SET_COMMAND_BAR": {
511
- const launchRequest = action.open && action.launch
512
- ? {
513
- ...action.launch,
514
- sequence: (state.commandBarLaunchRequest?.sequence ?? 0) + 1,
515
- }
516
- : null;
517
- return {
518
- ...state,
519
- commandBarOpen: action.open,
520
- commandBarQuery: action.open ? (action.query ?? "") : "",
521
- commandBarLaunchRequest: launchRequest,
522
- };
523
- }
524
-
525
- case "SET_COMMAND_BAR_QUERY":
526
- return { ...state, commandBarQuery: action.query };
527
-
528
- case "SET_REFRESHING": {
529
- const refreshing = new Set(state.refreshing);
530
- if (action.refreshing) refreshing.add(action.symbol);
531
- else refreshing.delete(action.symbol);
532
- return { ...state, refreshing };
533
- }
534
-
535
- case "SET_BROKER_ACCOUNTS":
536
- return {
537
- ...state,
538
- brokerAccounts: {
539
- ...state.brokerAccounts,
540
- [action.instanceId]: action.accounts,
541
- },
542
- };
543
-
544
- case "SET_INITIALIZED":
545
- return { ...state, initialized: true };
546
-
547
- case "TOGGLE_STATUS_BAR":
548
- return { ...state, statusBarVisible: !state.statusBarVisible };
549
-
550
- case "SHOW_GRIDLOCK_TIP":
551
- return {
552
- ...state,
553
- gridlockTipVisible: true,
554
- gridlockTipSequence: state.gridlockTipSequence + 1,
555
- };
556
-
557
- case "DISMISS_GRIDLOCK_TIP":
558
- if (!state.gridlockTipVisible) return state;
559
- return { ...state, gridlockTipVisible: false };
560
-
561
- case "SET_THEME":
562
- applyTheme(action.theme);
563
- return { ...state, config: { ...state.config, theme: action.theme } };
564
-
565
- case "SET_UPDATE_AVAILABLE":
566
- return { ...state, updateAvailable: action.release, updateNotice: action.release ? null : state.updateNotice };
567
-
568
- case "SET_UPDATE_PROGRESS":
569
- return { ...state, updateProgress: action.progress, updateNotice: action.progress ? null : state.updateNotice };
570
-
571
- case "SET_UPDATE_CHECK_IN_PROGRESS":
572
- return { ...state, updateCheckInProgress: action.checking };
573
-
574
- case "SET_UPDATE_NOTICE":
575
- return { ...state, updateNotice: action.notice };
576
-
577
- case "TOGGLE_PLUGIN": {
578
- const disabledPlugins = state.config.disabledPlugins.includes(action.pluginId)
579
- ? state.config.disabledPlugins.filter((pluginId) => pluginId !== action.pluginId)
580
- : [...state.config.disabledPlugins, action.pluginId];
581
- return { ...state, config: { ...state.config, disabledPlugins } };
582
- }
583
-
584
- case "SET_INPUT_CAPTURED":
585
- return { ...state, inputCaptured: action.captured };
586
-
587
- case "SET_EXCHANGE_RATE": {
588
- const exchangeRates = new Map(state.exchangeRates);
589
- exchangeRates.set(action.currency, action.rate);
590
- return { ...state, exchangeRates };
591
- }
592
-
593
- case "HYDRATE_EXCHANGE_RATES": {
594
- if (action.exchangeRates.size === 0) return state;
595
- const exchangeRates = new Map(state.exchangeRates);
596
- for (const [currency, rate] of action.exchangeRates) {
597
- exchangeRates.set(currency, rate);
598
- }
599
- return { ...state, exchangeRates };
600
- }
601
-
602
- case "PUSH_LAYOUT_HISTORY": {
603
- const currentIndex = state.config.activeLayoutIndex;
604
- const entry = historyForIndex(state.layoutHistory, currentIndex);
605
- const snapshot = cloneLayout(state.config.layout);
606
- const last = entry.past[entry.past.length - 1];
607
- if (last && JSON.stringify(last) === JSON.stringify(snapshot)) {
608
- return state;
609
- }
610
- entry.past = [...entry.past, snapshot].slice(-MAX_LAYOUT_HISTORY);
611
- entry.future = [];
612
- return {
613
- ...state,
614
- layoutHistory: setHistoryForIndex(state.layoutHistory, currentIndex, entry),
615
- };
616
- }
617
-
618
- case "UNDO_LAYOUT": {
619
- const currentIndex = state.config.activeLayoutIndex;
620
- const entry = historyForIndex(state.layoutHistory, currentIndex);
621
- if (entry.past.length === 0) return state;
622
- const target = entry.past[entry.past.length - 1]!;
623
- entry.past = entry.past.slice(0, -1);
624
- entry.future = [cloneLayout(state.config.layout), ...entry.future].slice(0, MAX_LAYOUT_HISTORY);
625
- const layouts = syncLayouts(state.config.layouts, currentIndex, target);
626
- return withFocusedPane({
627
- ...state,
628
- layoutHistory: setHistoryForIndex(state.layoutHistory, currentIndex, entry),
629
- }, {
630
- ...state.config,
631
- layout: cloneLayout(target),
632
- layouts,
633
- });
634
- }
635
-
636
- case "REDO_LAYOUT": {
637
- const currentIndex = state.config.activeLayoutIndex;
638
- const entry = historyForIndex(state.layoutHistory, currentIndex);
639
- if (entry.future.length === 0) return state;
640
- const target = entry.future[0]!;
641
- entry.future = entry.future.slice(1);
642
- entry.past = [...entry.past, cloneLayout(state.config.layout)].slice(-MAX_LAYOUT_HISTORY);
643
- const layouts = syncLayouts(state.config.layouts, currentIndex, target);
644
- return withFocusedPane({
645
- ...state,
646
- layoutHistory: setHistoryForIndex(state.layoutHistory, currentIndex, entry),
647
- }, {
648
- ...state.config,
649
- layout: cloneLayout(target),
650
- layouts,
651
- });
652
- }
653
-
654
- case "UPDATE_LAYOUT": {
655
- const layouts = syncLayouts(state.config.layouts, state.config.activeLayoutIndex, action.layout);
656
- return withFocusedPane(state, { ...state.config, layout: action.layout, layouts });
657
- }
658
-
659
- case "SWITCH_LAYOUT": {
660
- if (action.index < 0 || action.index >= state.config.layouts.length) return state;
661
- const layouts = syncLayouts(state.config.layouts, state.config.activeLayoutIndex, state.config.layout);
662
- const target = layouts[action.index]!;
663
- return withFocusedPane(state, {
664
- ...state.config,
665
- layout: cloneLayout(target.layout),
666
- layouts,
667
- activeLayoutIndex: action.index,
668
- });
669
- }
670
-
671
- case "NEW_LAYOUT": {
672
- const newLayout: SavedLayout = {
673
- name: action.name,
674
- layout: cloneLayout(DEFAULT_LAYOUT),
675
- };
676
- const layouts = [...syncLayouts(state.config.layouts, state.config.activeLayoutIndex, state.config.layout), newLayout];
677
- return withFocusedPane({
678
- ...state,
679
- layoutHistory: setHistoryForIndex(state.layoutHistory, layouts.length - 1, { past: [], future: [] }),
680
- }, {
681
- ...state.config,
682
- layout: cloneLayout(newLayout.layout),
683
- layouts,
684
- activeLayoutIndex: layouts.length - 1,
685
- });
686
- }
687
-
688
- case "DELETE_LAYOUT": {
689
- if (state.config.layouts.length <= 1) return state;
690
- const layouts = state.config.layouts.filter((_, index) => index !== action.index);
691
- const nextActiveLayoutIndex = action.index <= state.config.activeLayoutIndex
692
- ? Math.max(0, state.config.activeLayoutIndex - 1)
693
- : state.config.activeLayoutIndex;
694
- const nextLayout = layouts[nextActiveLayoutIndex]!;
695
- return withFocusedPane({
696
- ...state,
697
- layoutHistory: removeHistoryIndex(state.layoutHistory, action.index),
698
- }, {
699
- ...state.config,
700
- layout: cloneLayout(nextLayout.layout),
701
- layouts,
702
- activeLayoutIndex: nextActiveLayoutIndex,
703
- });
704
- }
705
-
706
- case "RENAME_LAYOUT": {
707
- if (action.index < 0 || action.index >= state.config.layouts.length) return state;
708
- return {
709
- ...state,
710
- config: {
711
- ...state.config,
712
- layouts: state.config.layouts.map((savedLayout, index) => (
713
- index === action.index ? { ...savedLayout, name: action.name } : savedLayout
714
- )),
715
- },
716
- };
717
- }
718
-
719
- case "DUPLICATE_LAYOUT": {
720
- if (action.index < 0 || action.index >= state.config.layouts.length) return state;
721
- const source = state.config.layouts[action.index]!;
722
- const duplicate: SavedLayout = {
723
- name: `${source.name} Copy`,
724
- layout: cloneLayout(source.layout),
725
- };
726
- const layouts = [...syncLayouts(state.config.layouts, state.config.activeLayoutIndex, state.config.layout), duplicate];
727
- return withFocusedPane({
728
- ...state,
729
- layoutHistory: setHistoryForIndex(state.layoutHistory, layouts.length - 1, { past: [], future: [] }),
730
- }, {
731
- ...state.config,
732
- layout: cloneLayout(duplicate.layout),
733
- layouts,
734
- activeLayoutIndex: layouts.length - 1,
735
- });
736
- }
737
-
738
- case "FOCUS_PANE": {
739
- const layout = bringFloatingToFront(state.config.layout, action.paneId);
740
- const config = layout !== state.config.layout
741
- ? { ...state.config, layout, layouts: syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout) }
742
- : state.config;
743
- return {
744
- ...state,
745
- config,
746
- focusedPaneId: action.paneId,
747
- recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, action.paneId)),
748
- };
749
- }
750
-
751
- case "FOCUS_NEXT": {
752
- if (action.paneOrder.length === 0) return state;
753
- const currentIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : -1;
754
- const nextPaneId = action.paneOrder[(currentIndex + 1) % action.paneOrder.length]!;
755
- const layout = bringFloatingToFront(state.config.layout, nextPaneId);
756
- const config = layout !== state.config.layout
757
- ? { ...state.config, layout, layouts: syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout) }
758
- : state.config;
759
- return {
760
- ...state,
761
- config,
762
- focusedPaneId: nextPaneId,
763
- recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, nextPaneId)),
764
- };
765
- }
766
-
767
- case "FOCUS_PREV": {
768
- if (action.paneOrder.length === 0) return state;
769
- const currentIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : 0;
770
- const nextPaneId = action.paneOrder[(currentIndex - 1 + action.paneOrder.length) % action.paneOrder.length]!;
771
- const layout = bringFloatingToFront(state.config.layout, nextPaneId);
772
- const config = layout !== state.config.layout
773
- ? { ...state.config, layout, layouts: syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout) }
774
- : state.config;
775
- return {
776
- ...state,
777
- config,
778
- focusedPaneId: nextPaneId,
779
- recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, nextPaneId)),
780
- };
781
- }
782
-
783
- case "UPDATE_PANE_STATE": {
784
- const current = state.paneState[action.paneId] ?? {};
785
- if (Object.keys(action.patch).every((key) => Object.is(current[key], action.patch[key]))) {
786
- return state;
787
- }
788
- const nextState = { ...current, ...action.patch };
789
- const recentTickers = Object.prototype.hasOwnProperty.call(action.patch, "cursorSymbol")
790
- ? nextRecentTickers(state.recentTickers, typeof nextState.cursorSymbol === "string" ? nextState.cursorSymbol : null)
791
- : state.recentTickers;
792
- return {
793
- ...state,
794
- paneState: {
795
- ...state.paneState,
796
- [action.paneId]: nextState,
797
- },
798
- recentTickers,
799
- };
800
- }
801
-
802
- case "UPDATE_PLUGIN_PANE_STATE": {
803
- const current = state.paneState[action.paneId] ?? {};
804
- const currentPluginState = current.pluginState ?? {};
805
- const currentPluginValues = currentPluginState[action.pluginId] ?? {};
806
- if (Object.is(currentPluginValues[action.key], action.value)) {
807
- return state;
808
- }
809
- return {
810
- ...state,
811
- paneState: {
812
- ...state.paneState,
813
- [action.paneId]: {
814
- ...current,
815
- pluginState: {
816
- ...currentPluginState,
817
- [action.pluginId]: {
818
- ...currentPluginValues,
819
- [action.key]: action.value,
820
- },
821
- },
822
- },
823
- },
824
- };
825
- }
826
-
827
- default:
828
- return state;
829
- }
830
- }
32
+ import {
33
+ appReducer,
34
+ createInitialState,
35
+ getEffectiveThemeId,
36
+ getFocusedTickerSymbol,
37
+ type PaneRuntimeState,
38
+ resolveCollectionForPane,
39
+ resolveTickerForPane,
40
+ } from "../core/state/app-state";
41
+ import type { AppAction, AppState } from "../core/state/app-state";
42
+ import { scheduleConfigSave } from "./config-save-scheduler";
43
+
44
+ export {
45
+ appReducer,
46
+ createInitialState,
47
+ getEffectiveThemeId,
48
+ getFocusedCollectionId,
49
+ getFocusedTickerSymbol,
50
+ getPaneState,
51
+ resolveCollectionForPane,
52
+ resolveFinancialsForPane,
53
+ resolveTickerFileForPane,
54
+ resolveTickerForPane,
55
+ } from "../core/state/app-state";
56
+ export type {
57
+ AppAction,
58
+ AppState,
59
+ CollectionSortPreference,
60
+ CommandBarLaunchRequest,
61
+ LayoutHistoryEntry,
62
+ PaneRuntimeState,
63
+ SortDirection,
64
+ } from "../core/state/app-state";
831
65
 
832
66
  interface AppContextStoreValue {
833
- dispatch: React.Dispatch<AppAction>;
67
+ dispatch: Dispatch<AppAction>;
834
68
  getState: () => AppState;
835
69
  subscribe: (listener: () => void) => () => void;
836
70
  }
837
71
 
838
72
  interface AppContextLegacyValue {
839
73
  state: AppState;
840
- dispatch: React.Dispatch<AppAction>;
74
+ dispatch: Dispatch<AppAction>;
841
75
  }
842
76
 
843
77
  type AppContextValue = AppContextStoreValue | AppContextLegacyValue;
@@ -855,6 +89,22 @@ function useRequiredAppContext(): AppContextValue {
855
89
  return context;
856
90
  }
857
91
 
92
+ function sameStringList(left: readonly string[], right: readonly string[]): boolean {
93
+ if (left.length !== right.length) return false;
94
+ return left.every((value, index) => value === right[index]);
95
+ }
96
+
97
+ function materializeDetachedConfig(config: AppConfig): AppConfig {
98
+ return {
99
+ ...config,
100
+ layout: materializeDetachedPanesAsFloating(config.layout),
101
+ layouts: config.layouts.map((entry) => ({
102
+ ...entry,
103
+ layout: materializeDetachedPanesAsFloating(entry.layout),
104
+ })),
105
+ };
106
+ }
107
+
858
108
  export function useAppState(): AppContextLegacyValue {
859
109
  const context = useRequiredAppContext();
860
110
  if (!isAppStoreContextValue(context)) {
@@ -865,7 +115,26 @@ export function useAppState(): AppContextLegacyValue {
865
115
  return useMemo(() => ({ state, dispatch: context.dispatch }), [context.dispatch, state]);
866
116
  }
867
117
 
868
- export function useAppDispatch(): React.Dispatch<AppAction> {
118
+ export function useAppStateRef() {
119
+ const context = useRequiredAppContext();
120
+ const stateRef = useRef(isAppStoreContextValue(context) ? context.getState() : context.state);
121
+
122
+ useLayoutEffect(() => {
123
+ if (!isAppStoreContextValue(context)) {
124
+ stateRef.current = context.state;
125
+ return;
126
+ }
127
+
128
+ stateRef.current = context.getState();
129
+ return context.subscribe(() => {
130
+ stateRef.current = context.getState();
131
+ });
132
+ }, [context]);
133
+
134
+ return stateRef;
135
+ }
136
+
137
+ export function useAppDispatch(): Dispatch<AppAction> {
869
138
  return useRequiredAppContext().dispatch;
870
139
  }
871
140
 
@@ -884,7 +153,7 @@ export function useAppSelector<T>(selector: (state: AppState) => T): T {
884
153
  () => {
885
154
  const state = context.getState();
886
155
  const selection = selectorRef.current(state);
887
- const themeId = state.config.theme;
156
+ const themeId = getEffectiveThemeId(state);
888
157
  const previous = lastSnapshotRef.current;
889
158
  if (previous && Object.is(previous.selection, selection) && previous.themeId === themeId) {
890
159
  return previous;
@@ -897,7 +166,7 @@ export function useAppSelector<T>(selector: (state: AppState) => T): T {
897
166
  const state = context.getState();
898
167
  return {
899
168
  selection: selectorRef.current(state),
900
- themeId: state.config.theme,
169
+ themeId: getEffectiveThemeId(state),
901
170
  };
902
171
  },
903
172
  );
@@ -905,7 +174,7 @@ export function useAppSelector<T>(selector: (state: AppState) => T): T {
905
174
  }
906
175
 
907
176
  export function PaneInstanceProvider({ paneId, children }: { paneId: string; children: ReactNode }) {
908
- return <PaneContext value={paneId}>{children}</PaneContext>;
177
+ return <PaneContext.Provider value={paneId}>{children}</PaneContext.Provider>;
909
178
  }
910
179
 
911
180
  export function usePaneInstanceId(): string {
@@ -921,7 +190,8 @@ export function usePaneInstance(): PaneInstanceConfig | null {
921
190
 
922
191
  export function usePaneTicker(paneId?: string) {
923
192
  const paneContextId = useContext(PaneContext);
924
- const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
193
+ const needsFocusedPane = paneId == null && paneContextId == null;
194
+ const focusedPaneId = useAppSelector((state) => (needsFocusedPane ? state.focusedPaneId : null));
925
195
  const scopedPaneId = paneId ?? paneContextId ?? focusedPaneId;
926
196
  const symbol = useAppSelector((state) => (
927
197
  scopedPaneId ? resolveTickerForPane(state, scopedPaneId) : null
@@ -964,7 +234,8 @@ export function useFocusedTicker() {
964
234
 
965
235
  export function usePaneCollection(paneId?: string) {
966
236
  const paneContextId = useContext(PaneContext);
967
- const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
237
+ const needsFocusedPane = paneId == null && paneContextId == null;
238
+ const focusedPaneId = useAppSelector((state) => (needsFocusedPane ? state.focusedPaneId : null));
968
239
  const scopedPaneId = paneId ?? paneContextId ?? focusedPaneId;
969
240
  const collectionId = useAppSelector((state) => (
970
241
  scopedPaneId ? resolveCollectionForPane(state, scopedPaneId) : null
@@ -997,105 +268,170 @@ export function usePaneSettingValue<T>(
997
268
  fallback: T,
998
269
  paneId?: string,
999
270
  ): [T, (value: SetStateAction<T>) => void] {
1000
- const { state, dispatch } = useAppState();
271
+ const dispatch = useAppDispatch();
272
+ const stateRef = useAppStateRef();
1001
273
  const scopedPaneId = paneId ?? usePaneInstanceId();
1002
- const instance = findPaneInstance(state.config.layout, scopedPaneId);
274
+ const instance = useAppSelector((state) => findPaneInstance(state.config.layout, scopedPaneId) ?? null);
1003
275
  const value = (instance?.settings?.[key] as T | undefined) ?? fallback;
1004
276
 
1005
- const setValue = (nextValue: SetStateAction<T>) => {
1006
- const currentValue = (findPaneInstance(state.config.layout, scopedPaneId)?.settings?.[key] as T | undefined) ?? fallback;
277
+ const setValue = useCallback((nextValue: SetStateAction<T>) => {
278
+ const currentState = stateRef.current;
279
+ const currentValue = (findPaneInstance(currentState.config.layout, scopedPaneId)?.settings?.[key] as T | undefined) ?? fallback;
1007
280
  const resolved = typeof nextValue === "function"
1008
281
  ? (nextValue as (previousValue: T) => T)(currentValue)
1009
282
  : nextValue;
1010
- const layout = setPaneSetting(state.config.layout, scopedPaneId, key, resolved);
1011
- const layouts = state.config.layouts.map((savedLayout, index) => (
1012
- index === state.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
283
+ const layout = setPaneSetting(currentState.config.layout, scopedPaneId, key, resolved);
284
+ const layouts = currentState.config.layouts.map((savedLayout, index) => (
285
+ index === currentState.config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
1013
286
  ));
1014
- const nextConfig = { ...state.config, layout, layouts };
287
+ const nextConfig = { ...currentState.config, layout, layouts };
1015
288
  dispatch({ type: "SET_CONFIG", config: nextConfig });
1016
- saveConfig(nextConfig).catch(() => {});
1017
- };
289
+ scheduleConfigSave(nextConfig);
290
+ }, [dispatch, fallback, key, scopedPaneId, stateRef]);
1018
291
 
1019
292
  return [value, setValue];
1020
293
  }
1021
294
 
1022
- export function createInitialState(config: AppConfig, sessionSnapshot: AppSessionSnapshot | null = null): AppState {
1023
- const paneState = reconcilePaneState(config, sessionSnapshot?.paneState ?? {});
1024
- const defaultFocusedPaneId = getPaneOrder(config.layout)[0] ?? null;
1025
- const focusedPaneId = sessionSnapshot?.focusedPaneId
1026
- && config.layout.instances.some((instance) => instance.instanceId === sessionSnapshot.focusedPaneId)
1027
- ? sessionSnapshot.focusedPaneId
1028
- : defaultFocusedPaneId;
1029
- return {
1030
- config,
1031
- tickers: new Map(),
1032
- financials: new Map(),
1033
- exchangeRates: new Map([["USD", 1]]),
1034
- brokerAccounts: {},
1035
- activePanel: sessionSnapshot?.activePanel === "right" ? "right" : "left",
1036
- focusedPaneId,
1037
- paneState,
1038
- recentTickers: config.recentTickers,
1039
- commandBarOpen: false,
1040
- commandBarQuery: "",
1041
- commandBarLaunchRequest: null,
1042
- refreshing: new Set(),
1043
- initialized: false,
1044
- statusBarVisible: sessionSnapshot?.statusBarVisible !== false,
1045
- gridlockTipVisible: false,
1046
- gridlockTipSequence: 0,
1047
- inputCaptured: false,
1048
- updateAvailable: null,
1049
- updateProgress: null,
1050
- updateCheckInProgress: false,
1051
- updateNotice: null,
1052
- layoutHistory: {},
1053
- };
1054
- }
1055
-
1056
295
  export function AppProvider({
1057
296
  config,
1058
297
  children,
1059
298
  sessionStore,
1060
299
  sessionSnapshot = null,
300
+ desktopBridge,
301
+ desktopSnapshot = null,
1061
302
  }: {
1062
303
  config: AppConfig;
1063
304
  children: ReactNode;
1064
305
  sessionStore?: SessionStore;
1065
306
  sessionSnapshot?: AppSessionSnapshot | null;
307
+ desktopBridge?: DesktopWindowBridge;
308
+ desktopSnapshot?: DesktopSharedStateSnapshot | null;
1066
309
  }) {
1067
- // Sync the configured theme before descendants read the shared palette on first render.
1068
- syncTheme(config.theme);
1069
- const [state, dispatch] = useReducer(appReducer, { config, sessionSnapshot }, ({ config, sessionSnapshot: initialSessionSnapshot }) => (
1070
- createInitialState(config, initialSessionSnapshot)
1071
- ));
310
+ const [state, rawDispatch] = useReducer(
311
+ appReducer,
312
+ { config, sessionSnapshot, desktopSnapshot },
313
+ (initialState: {
314
+ config: AppConfig;
315
+ sessionSnapshot?: AppSessionSnapshot | null;
316
+ desktopSnapshot?: DesktopSharedStateSnapshot | null;
317
+ }) => {
318
+ const {
319
+ config: initialConfig,
320
+ sessionSnapshot: initialSessionSnapshot,
321
+ desktopSnapshot: initialDesktopSnapshot,
322
+ } = initialState;
323
+ const nextState = createInitialState(initialConfig, initialSessionSnapshot);
324
+ return initialDesktopSnapshot
325
+ ? appReducer(nextState, { type: "HYDRATE_DESKTOP_SNAPSHOT", snapshot: initialDesktopSnapshot })
326
+ : nextState;
327
+ },
328
+ );
329
+ // Sync the current theme before descendants read the shared palette during this render.
330
+ syncTheme(getEffectiveThemeId(state));
1072
331
  const previousRecentTickers = useRef(state.recentTickers);
1073
332
  const stateRef = useRef(state);
1074
333
  const listenersRef = useRef(new Set<() => void>());
1075
334
  const storeRef = useRef<AppContextStoreValue | null>(null);
335
+ const lastMainSyncRef = useRef<string | null>(null);
336
+ const lastDetachedPaneSyncRef = useRef<string | null>(null);
1076
337
 
1077
338
  stateRef.current = state;
1078
339
 
340
+ const dispatch = useCallback((action: AppAction) => {
341
+ if (desktopBridge) {
342
+ rawDispatch(action);
343
+ return;
344
+ }
345
+
346
+ switch (action.type) {
347
+ case "SET_CONFIG":
348
+ rawDispatch({
349
+ ...action,
350
+ config: materializeDetachedConfig(action.config),
351
+ });
352
+ return;
353
+ case "UPDATE_LAYOUT":
354
+ rawDispatch({
355
+ ...action,
356
+ layout: materializeDetachedPanesAsFloating(action.layout),
357
+ });
358
+ return;
359
+ default:
360
+ rawDispatch(action);
361
+ }
362
+ }, [desktopBridge, rawDispatch]);
363
+
364
+ const buildDesktopSnapshot = useCallback((currentState: AppState): DesktopSharedStateSnapshot => ({
365
+ config: currentState.config,
366
+ paneState: currentState.paneState,
367
+ focusedPaneId: currentState.focusedPaneId,
368
+ activePanel: currentState.activePanel,
369
+ statusBarVisible: currentState.statusBarVisible,
370
+ }), []);
371
+
372
+ const serializeDesktopSnapshot = useCallback((snapshot: DesktopSharedStateSnapshot): string => JSON.stringify({
373
+ config: snapshot.config,
374
+ paneState: snapshot.paneState,
375
+ focusedPaneId: snapshot.focusedPaneId,
376
+ activePanel: snapshot.activePanel,
377
+ statusBarVisible: snapshot.statusBarVisible,
378
+ }), []);
379
+
380
+ const serializePaneState = useCallback((paneState: PaneRuntimeState | undefined): string => JSON.stringify(paneState ?? {}), []);
381
+
1079
382
  if (!storeRef.current) {
1080
383
  storeRef.current = {
1081
384
  dispatch,
1082
385
  getState: () => stateRef.current,
1083
- subscribe: (listener) => {
386
+ subscribe: (listener: () => void) => {
1084
387
  listenersRef.current.add(listener);
1085
388
  return () => {
1086
389
  listenersRef.current.delete(listener);
1087
390
  };
1088
391
  },
1089
392
  };
393
+ } else {
394
+ storeRef.current.dispatch = dispatch;
1090
395
  }
1091
396
 
1092
397
  useEffect(() => {
1093
- if (previousRecentTickers.current !== state.recentTickers) {
1094
- previousRecentTickers.current = state.recentTickers;
1095
- saveConfig({ ...state.config, recentTickers: state.recentTickers }).catch(() => {});
1096
- }
398
+ if (sameStringList(previousRecentTickers.current, state.recentTickers)) return;
399
+ previousRecentTickers.current = state.recentTickers;
400
+ scheduleConfigSave({ ...state.config, recentTickers: state.recentTickers });
1097
401
  }, [state.config, state.recentTickers]);
1098
402
 
403
+ useEffect(() => {
404
+ if (!desktopBridge) return;
405
+ return desktopBridge.subscribeState((snapshot) => {
406
+ const currentSignature = serializeDesktopSnapshot(buildDesktopSnapshot(stateRef.current));
407
+ const nextSignature = serializeDesktopSnapshot(snapshot);
408
+ if (currentSignature === nextSignature) return;
409
+ if (desktopBridge.kind === "main") {
410
+ lastMainSyncRef.current = nextSignature;
411
+ } else if (desktopBridge.paneId) {
412
+ lastDetachedPaneSyncRef.current = serializePaneState(snapshot.paneState[desktopBridge.paneId] as PaneRuntimeState | undefined);
413
+ }
414
+ dispatch({ type: "HYDRATE_DESKTOP_SNAPSHOT", snapshot });
415
+ });
416
+ }, [buildDesktopSnapshot, desktopBridge, serializeDesktopSnapshot, serializePaneState]);
417
+
418
+ useEffect(() => {
419
+ if (desktopBridge?.kind !== "main" || !desktopBridge.syncMainState) return;
420
+ const snapshot = buildDesktopSnapshot(state);
421
+ const signature = serializeDesktopSnapshot(snapshot);
422
+ if (signature === lastMainSyncRef.current) return;
423
+ lastMainSyncRef.current = signature;
424
+ void desktopBridge.syncMainState(snapshot);
425
+ }, [buildDesktopSnapshot, desktopBridge, serializeDesktopSnapshot, state]);
426
+
427
+ useEffect(() => {
428
+ if (desktopBridge?.kind !== "detached" || !desktopBridge.replaceDetachedPaneState || !desktopBridge.paneId) return;
429
+ const paneSignature = serializePaneState(state.paneState[desktopBridge.paneId]);
430
+ if (paneSignature === lastDetachedPaneSyncRef.current) return;
431
+ lastDetachedPaneSyncRef.current = paneSignature;
432
+ void desktopBridge.replaceDetachedPaneState(desktopBridge.paneId, state.paneState[desktopBridge.paneId] ?? {});
433
+ }, [desktopBridge, serializePaneState, state.paneState]);
434
+
1099
435
  useLayoutEffect(() => {
1100
436
  stateRef.current = state;
1101
437
  for (const listener of listenersRef.current) {
@@ -1105,5 +441,5 @@ export function AppProvider({
1105
441
 
1106
442
  usePersistSessionSnapshot(sessionStore, state, APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION);
1107
443
 
1108
- return <AppContext value={storeRef.current}>{children}</AppContext>;
444
+ return <AppContext.Provider value={storeRef.current}>{children}</AppContext.Provider>;
1109
445
  }