gloomberb 0.11.0 → 0.11.2

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 (392) hide show
  1. package/README.md +38 -15
  2. package/README.zh-CN.md +12 -12
  3. package/package.json +10 -3
  4. package/src/api-client/auth.ts +14 -10
  5. package/src/api-client/data.ts +54 -0
  6. package/src/api-client/index.test.ts +124 -0
  7. package/src/api-client/index.ts +139 -4
  8. package/src/api-client/paths.ts +89 -0
  9. package/src/api-client/request.ts +15 -2
  10. package/src/api-client/socket-health.test.ts +1 -0
  11. package/src/api-client/socket.ts +2 -1
  12. package/src/api-client/types.ts +163 -0
  13. package/src/app/pane-runtime/index.tsx +4 -1
  14. package/src/app/pane-runtime/plugin-bindings.test.ts +117 -0
  15. package/src/app/pane-runtime/plugin-bindings.ts +25 -4
  16. package/src/app/pane-runtime/ticker-inspector-runtime.ts +25 -14
  17. package/src/app/runtime/desktop-deeplink.test.ts +16 -0
  18. package/src/app/runtime/desktop-deeplink.ts +66 -2
  19. package/src/app/runtime/desktop-menu.ts +3 -0
  20. package/src/app/runtime/plugin-bindings.ts +30 -28
  21. package/src/app/runtime/update.ts +8 -6
  22. package/src/app.tsx +21 -7
  23. package/src/architecture/import-boundaries.test.ts +5 -1
  24. package/src/capabilities/factories.ts +4 -0
  25. package/src/cli/commands/automation.test.ts +9 -0
  26. package/src/cli/commands/automation.ts +22 -73
  27. package/src/cli/index.ts +10 -6
  28. package/src/cli/registry.ts +1 -1
  29. package/src/components/chart/composite/composite-chart.test.tsx +45 -2
  30. package/src/components/chart/composite/composite-chart.tsx +16 -12
  31. package/src/components/chart/composite/format.test.ts +34 -0
  32. package/src/components/chart/composite/format.ts +33 -10
  33. package/src/components/chart/composite/scene.ts +0 -21
  34. package/src/components/chart/composite/tools.ts +49 -0
  35. package/src/components/chart/core/axis-format.ts +13 -3
  36. package/src/components/chart/core/index.test.ts +7 -0
  37. package/src/components/chart/core/renderer.ts +3 -4
  38. package/src/components/command-bar/commands/direct/index.ts +4 -0
  39. package/src/components/command-bar/commands/registry.ts +8 -1
  40. package/src/components/command-bar/layout-items/current-layout.ts +1 -1
  41. package/src/components/command-bar/pane-templates/workflow.ts +4 -0
  42. package/src/components/command-bar/routes/root/shortcut-feedback.ts +3 -0
  43. package/src/components/command-bar/routes/root/shortcuts.test.ts +19 -0
  44. package/src/components/command-bar/routes/root/shortcuts.ts +3 -1
  45. package/src/components/command-bar/surface/index.test.tsx +6 -6
  46. package/src/components/command-bar/surface/index.tsx +4 -1
  47. package/src/components/command-bar/surface/pane.test.tsx +66 -2
  48. package/src/components/command-bar/view-model.test.ts +2 -1
  49. package/src/components/command-bar/workflow/ops.test.ts +54 -0
  50. package/src/components/command-bar/workflow/ops.ts +1 -1
  51. package/src/components/company-logo.test.ts +19 -0
  52. package/src/components/company-logo.tsx +60 -0
  53. package/src/components/feed-data-table/stack-view.tsx +7 -1
  54. package/src/components/index.ts +1 -0
  55. package/src/components/layout/detached-pane-shell.tsx +85 -15
  56. package/src/components/layout/header.tsx +5 -5
  57. package/src/components/layout/pane/title.ts +31 -3
  58. package/src/components/layout/shell/active-drag.ts +4 -11
  59. package/src/components/layout/shell/drag/index.ts +4 -20
  60. package/src/components/layout/shell/drag/runtime.ts +3 -4
  61. package/src/components/layout/shell/index.test.tsx +17 -3
  62. package/src/components/layout/shell/index.tsx +89 -31
  63. package/src/components/layout/shell/menu.test.ts +37 -1
  64. package/src/components/layout/shell/menu.ts +14 -0
  65. package/src/components/layout/shell/native/pointer-runtime.ts +22 -8
  66. package/src/components/layout/shell/pane/actions.ts +0 -5
  67. package/src/components/layout/shell/pane/management-shortcuts.ts +9 -4
  68. package/src/components/layout/shell/shortcuts.ts +6 -3
  69. package/src/components/layout/shell/ticker-link-menu.test.ts +82 -0
  70. package/src/components/layout/shell/ticker-link-menu.ts +78 -0
  71. package/src/components/layout/status-bar.test.tsx +62 -61
  72. package/src/components/layout/status-bar.tsx +53 -75
  73. package/src/components/onboarding/account-step/form-panel.tsx +4 -2
  74. package/src/components/onboarding/account-step/index.tsx +1 -1
  75. package/src/components/onboarding/account-step/signed-in-panel.tsx +1 -1
  76. package/src/components/onboarding/onboarding-wizard.test.tsx +44 -1
  77. package/src/components/onboarding/onboarding-wizard.tsx +13 -5
  78. package/src/components/onboarding/wizard-account.ts +4 -23
  79. package/src/components/table-view-shared.test.ts +11 -0
  80. package/src/components/table-view-shared.tsx +26 -0
  81. package/src/components/ui/data-table/index.tsx +19 -1
  82. package/src/components/ui/fields.tsx +5 -1
  83. package/src/core/state/app/layout-reducer.ts +36 -0
  84. package/src/core/state/app/layout.ts +10 -5
  85. package/src/core/state/app/state.ts +0 -13
  86. package/src/core/state/app/types.ts +6 -4
  87. package/src/data/config/chart-settings.ts +1 -1
  88. package/src/data/config/layout.ts +2 -0
  89. package/src/data/config/store/index.test.ts +27 -6
  90. package/src/data/config/store/normalize.ts +3 -5
  91. package/src/data/plugin-state-store.ts +11 -0
  92. package/src/data/sqlite/cache.test.ts +10 -0
  93. package/src/i18n/es.ts +13 -5
  94. package/src/i18n/ja.ts +13 -5
  95. package/src/i18n/ko.ts +13 -5
  96. package/src/i18n/zh-cn.ts +13 -5
  97. package/src/i18n/zh-tw.ts +13 -5
  98. package/src/layout-marketplace/api.test.ts +54 -0
  99. package/src/layout-marketplace/api.ts +65 -0
  100. package/src/layout-marketplace/gallery-desktop.test.tsx +262 -0
  101. package/src/layout-marketplace/gallery-desktop.tsx +375 -0
  102. package/src/layout-marketplace/gallery-terminal.tsx +376 -0
  103. package/src/layout-marketplace/gallery.test.tsx +84 -0
  104. package/src/layout-marketplace/gallery.tsx +323 -0
  105. package/src/layout-marketplace/geometry.test.ts +47 -0
  106. package/src/layout-marketplace/geometry.ts +80 -0
  107. package/src/layout-marketplace/mini-workspace.test.tsx +122 -0
  108. package/src/layout-marketplace/mini-workspace.tsx +428 -0
  109. package/src/layout-marketplace/model.test.ts +118 -0
  110. package/src/layout-marketplace/model.ts +171 -0
  111. package/src/layout-marketplace/name-dialog.tsx +60 -0
  112. package/src/layout-marketplace/pane-imagery.ts +85 -0
  113. package/src/layout-marketplace/pane.tsx +20 -0
  114. package/src/layout-marketplace/payload.test.ts +246 -0
  115. package/src/layout-marketplace/payload.ts +624 -0
  116. package/src/layout-marketplace/use-marketplace.ts +63 -0
  117. package/src/market-data/coordinator/index.test.ts +2 -2
  118. package/src/market-data/coordinator/index.ts +1 -1
  119. package/src/market-data/hooks.tsx +0 -41
  120. package/src/market-data/market/format.test.ts +7 -0
  121. package/src/market-data/market/freshness.ts +5 -2
  122. package/src/market-data/quotes/freshness.test.ts +16 -0
  123. package/src/news/aggregator.test.ts +92 -3
  124. package/src/news/aggregator.ts +98 -18
  125. package/src/news/hooks.test.tsx +2 -0
  126. package/src/news/hooks.ts +27 -1
  127. package/src/news/news-model.ts +5 -2
  128. package/src/news/types.ts +7 -0
  129. package/src/plugins/broker-sync/broker-sync.test.ts +58 -0
  130. package/src/plugins/broker-sync/normalize.ts +42 -5
  131. package/src/plugins/builtin/account-management/pane.tsx +5 -5
  132. package/src/plugins/builtin/ai/index.tsx +18 -2
  133. package/src/plugins/builtin/ai/screener/pane.test.tsx +47 -4
  134. package/src/plugins/builtin/ai/screener/pane.tsx +35 -12
  135. package/src/plugins/builtin/alerts/alert-engine.test.ts +53 -1
  136. package/src/plugins/builtin/alerts/alert-engine.ts +34 -1
  137. package/src/plugins/builtin/alerts/index.test.tsx +49 -6
  138. package/src/plugins/builtin/alerts/index.tsx +11 -6
  139. package/src/plugins/builtin/alerts/pane.tsx +51 -9
  140. package/src/plugins/builtin/alerts/quotes.ts +11 -3
  141. package/src/plugins/builtin/alerts/types.ts +1 -0
  142. package/src/plugins/builtin/analytics/index.tsx +11 -1
  143. package/src/plugins/builtin/analytics/pane-model.ts +6 -4
  144. package/src/plugins/builtin/broker-manager/index.tsx +3 -0
  145. package/src/plugins/builtin/buildout/model/index.ts +1 -1
  146. package/src/plugins/builtin/buildout/pane/index.tsx +17 -18
  147. package/src/plugins/builtin/cds/client.test.ts +83 -0
  148. package/src/plugins/builtin/cds/client.ts +67 -0
  149. package/src/plugins/builtin/cds/index.tsx +41 -0
  150. package/src/plugins/builtin/cds/model.test.ts +232 -0
  151. package/src/plugins/builtin/cds/model.ts +401 -0
  152. package/src/plugins/builtin/cds/pane.test.tsx +132 -0
  153. package/src/plugins/builtin/cds/pane.tsx +358 -0
  154. package/src/plugins/builtin/chart-composer/chart-spec.ts +31 -0
  155. package/src/plugins/builtin/chart-composer/index.tsx +88 -2
  156. package/src/plugins/builtin/chart-composer/pane-share.test.ts +71 -0
  157. package/src/plugins/builtin/chart-composer/pane.tsx +103 -15
  158. package/src/plugins/builtin/chat/content/index.tsx +4 -1
  159. package/src/plugins/builtin/chat/controller/channels.ts +7 -1
  160. package/src/plugins/builtin/chat/controller/index.ts +14 -9
  161. package/src/plugins/builtin/chat/controller/notifications.ts +13 -3
  162. package/src/plugins/builtin/chat/controller/persistence.ts +0 -3
  163. package/src/plugins/builtin/chat/controller/realtime.ts +3 -3
  164. package/src/plugins/builtin/chat/controller/send.ts +1 -3
  165. package/src/plugins/builtin/chat/controller/session-runtime.ts +18 -11
  166. package/src/plugins/builtin/chat/controller/state.ts +1 -3
  167. package/src/plugins/builtin/chat/controller/storage.ts +0 -2
  168. package/src/plugins/builtin/chat/controller/utils.ts +4 -6
  169. package/src/plugins/builtin/chat/controller/view.ts +2 -2
  170. package/src/plugins/builtin/chat/controller.test.ts +142 -14
  171. package/src/plugins/builtin/chat/message/desktop.test.tsx +88 -0
  172. package/src/plugins/builtin/chat/message/desktop.tsx +3 -1
  173. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +1 -29
  174. package/src/plugins/builtin/chat/profile-popover.ts +53 -22
  175. package/src/plugins/builtin/chat/sidebar.test.tsx +81 -0
  176. package/src/plugins/builtin/chat/sidebar.tsx +63 -0
  177. package/src/plugins/builtin/chat/test-harness.tsx +29 -1
  178. package/src/plugins/builtin/cloud/auth-actions.tsx +14 -7
  179. package/src/plugins/builtin/cloud/auth-commands.ts +21 -51
  180. package/src/plugins/builtin/cloud/auth-dialog.tsx +296 -0
  181. package/src/{components/onboarding/account-step/model.test.ts → plugins/builtin/cloud/auth-model.test.ts} +1 -1
  182. package/src/{components/onboarding/account-step/model.ts → plugins/builtin/cloud/auth-model.ts} +37 -3
  183. package/src/plugins/builtin/cloud/browser.tsx +9 -0
  184. package/src/plugins/builtin/cloud/buildout-module.tsx +23 -0
  185. package/src/plugins/builtin/cloud/device-signin.ts +2 -8
  186. package/src/plugins/builtin/cloud/index.tsx +2 -0
  187. package/src/plugins/builtin/cloud/plugin.tsx +14 -24
  188. package/src/plugins/builtin/cloud/upgrade-command.ts +13 -6
  189. package/src/plugins/builtin/cloud-tweets/pane-share.test.ts +56 -0
  190. package/src/plugins/builtin/cloud-tweets/pane.tsx +20 -5
  191. package/src/plugins/builtin/cloud-tweets/registration.ts +50 -6
  192. package/src/plugins/builtin/composite-plugins.ts +3 -1
  193. package/src/plugins/builtin/congress-trades/detail.tsx +43 -1
  194. package/src/plugins/builtin/congress-trades/model.test.ts +85 -0
  195. package/src/plugins/builtin/congress-trades/model.ts +69 -1
  196. package/src/plugins/builtin/congress-trades/pane.tsx +51 -2
  197. package/src/plugins/builtin/connections/index.ts +3 -0
  198. package/src/plugins/builtin/dividend-yield/client.ts +18 -0
  199. package/src/plugins/builtin/dividend-yield/pane.tsx +7 -6
  200. package/src/plugins/builtin/earnings/index.tsx +5 -3
  201. package/src/plugins/builtin/earnings/model.test.ts +19 -1
  202. package/src/plugins/builtin/earnings/model.ts +17 -15
  203. package/src/plugins/builtin/econ/calendar-model.ts +0 -5
  204. package/src/plugins/builtin/econ/index.test.tsx +6 -1
  205. package/src/plugins/builtin/fear-greed/charts.tsx +12 -8
  206. package/src/plugins/builtin/help/index.tsx +2 -2
  207. package/src/plugins/builtin/holders/thirteenf-match.ts +1 -2
  208. package/src/plugins/builtin/insider/index.tsx +27 -11
  209. package/src/plugins/builtin/kelly-sizer/index.tsx +4 -1
  210. package/src/plugins/builtin/layout-manager/index.tsx +17 -4
  211. package/src/plugins/builtin/news/index.tsx +11 -6
  212. package/src/plugins/builtin/news/wire/breaking/notifications.test.ts +2 -0
  213. package/src/plugins/builtin/news/wire/breaking/pane.test.tsx +2 -0
  214. package/src/plugins/builtin/news/wire/breaking/pane.tsx +4 -1
  215. package/src/plugins/builtin/news/wire/feed-config.test.ts +15 -1
  216. package/src/plugins/builtin/news/wire/feed-config.ts +25 -5
  217. package/src/plugins/builtin/news/wire/index.tsx +3 -3
  218. package/src/plugins/builtin/news/wire/industry-pane.tsx +7 -2
  219. package/src/plugins/builtin/news/wire/news/footer.ts +34 -1
  220. package/src/plugins/builtin/news/wire/news/preset-pane.tsx +4 -1
  221. package/src/plugins/builtin/news/wire/news/table.tsx +8 -2
  222. package/src/plugins/builtin/news/wire/rss/source.test.ts +12 -0
  223. package/src/plugins/builtin/news/wire/rss/source.ts +1 -1
  224. package/src/plugins/builtin/notes/files.ts +80 -0
  225. package/src/plugins/builtin/notes/index.tsx +3 -0
  226. package/src/plugins/builtin/notes/sync.test.ts +49 -0
  227. package/src/plugins/builtin/notes/sync.ts +98 -0
  228. package/src/plugins/builtin/options/index.tsx +1 -0
  229. package/src/plugins/builtin/options/view.test.tsx +25 -3
  230. package/src/plugins/builtin/options/view.tsx +1 -0
  231. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +22 -0
  232. package/src/plugins/builtin/portfolio-list/account-metrics.ts +12 -7
  233. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -3
  234. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -4
  235. package/src/plugins/builtin/portfolio-list/header.tsx +13 -3
  236. package/src/plugins/builtin/portfolio-list/index.tsx +8 -0
  237. package/src/plugins/builtin/portfolio-list/metrics.test.ts +31 -1
  238. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +1 -0
  239. package/src/plugins/builtin/portfolio-list/pane/index.tsx +10 -7
  240. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +6 -1
  241. package/src/plugins/builtin/portfolio-list/position-metrics.ts +25 -6
  242. package/src/plugins/builtin/portfolio-list/summary/index.tsx +35 -21
  243. package/src/plugins/builtin/portfolio-list/summary/totals.ts +20 -6
  244. package/src/plugins/builtin/research/corporate-actions-pane.tsx +5 -12
  245. package/src/plugins/builtin/research/equity-diagnostic-pane.test.tsx +329 -0
  246. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +582 -0
  247. package/src/plugins/builtin/research/index.test.ts +14 -0
  248. package/src/plugins/builtin/research/index.tsx +29 -0
  249. package/src/plugins/builtin/sec/index.tsx +24 -9
  250. package/src/plugins/builtin/sectors/index.tsx +14 -0
  251. package/src/plugins/builtin/shared/cloud-upgrade.ts +18 -11
  252. package/src/plugins/builtin/shared/public-pane.test.ts +12 -0
  253. package/src/plugins/builtin/shared/public-pane.ts +9 -0
  254. package/src/plugins/builtin/shared/public-share.ts +23 -0
  255. package/src/plugins/builtin/shared/ticker-source.ts +32 -0
  256. package/src/plugins/builtin/shared/ticker-surface.test.ts +27 -0
  257. package/src/plugins/builtin/shared/ticker-surface.ts +17 -0
  258. package/src/plugins/builtin/substack/api/cache.ts +7 -3
  259. package/src/plugins/builtin/substack/api/loaders.ts +36 -12
  260. package/src/plugins/builtin/substack/api/types.ts +2 -0
  261. package/src/plugins/builtin/substack/pane-state.ts +1 -1
  262. package/src/plugins/builtin/substack/pane.tsx +55 -12
  263. package/src/plugins/builtin/thirteenf/api.test.ts +1 -1
  264. package/src/plugins/builtin/thirteenf/api.ts +40 -18
  265. package/src/plugins/builtin/thirteenf/data.ts +15 -7
  266. package/src/plugins/builtin/thirteenf/pane.tsx +45 -10
  267. package/src/plugins/builtin/ticker-detail/index.tsx +10 -0
  268. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +12 -4
  269. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +8 -2
  270. package/src/plugins/builtin/ticker-detail/settings.ts +12 -0
  271. package/src/plugins/builtin/treasury-auctions/index.tsx +2 -0
  272. package/src/plugins/builtin/world-indices/index.tsx +2 -0
  273. package/src/plugins/catalog-browser.test.ts +78 -0
  274. package/src/plugins/catalog-browser.ts +136 -0
  275. package/src/plugins/gridlock-notification.ts +2 -1
  276. package/src/plugins/ibkr/flex/index.test.ts +19 -0
  277. package/src/plugins/ibkr/flex/index.ts +1 -1
  278. package/src/plugins/pane-manager/gridlock.ts +87 -3
  279. package/src/plugins/pane-manager.test.ts +30 -0
  280. package/src/plugins/pane-manager.ts +6 -1
  281. package/src/plugins/prediction-markets/chart.tsx +2 -2
  282. package/src/plugins/prediction-markets/controller/catalog.ts +77 -2
  283. package/src/plugins/prediction-markets/controller/data.ts +7 -1
  284. package/src/plugins/prediction-markets/controller/index.ts +9 -2
  285. package/src/plugins/prediction-markets/controller/keyboard.ts +10 -14
  286. package/src/plugins/prediction-markets/detail/overview.tsx +21 -0
  287. package/src/plugins/prediction-markets/detail/pane.tsx +43 -34
  288. package/src/plugins/prediction-markets/detail/trades.tsx +34 -9
  289. package/src/plugins/prediction-markets/detail.test.tsx +29 -2
  290. package/src/plugins/prediction-markets/index.tsx +8 -4
  291. package/src/plugins/prediction-markets/navigation.ts +0 -1
  292. package/src/plugins/prediction-markets/pane.test.tsx +77 -0
  293. package/src/plugins/prediction-markets/pane.tsx +12 -0
  294. package/src/plugins/prediction-markets/plugin.test.tsx +36 -0
  295. package/src/plugins/prediction-markets/services/kalshi/adapter.ts +49 -9
  296. package/src/plugins/prediction-markets/services/polymarket/adapter.ts +31 -0
  297. package/src/plugins/prediction-markets/test-helpers.tsx +9 -0
  298. package/src/plugins/registry/index.ts +2 -0
  299. package/src/plugins/ticker-navigation.test.ts +24 -0
  300. package/src/plugins/ticker-navigation.ts +40 -0
  301. package/src/remote/schema.ts +1 -1
  302. package/src/renderers/browser/app-services.ts +61 -0
  303. package/src/renderers/browser/cloud-transport.test.ts +68 -0
  304. package/src/renderers/browser/cloud-transport.ts +48 -0
  305. package/src/renderers/browser/config-host.ts +58 -0
  306. package/src/renderers/browser/deeplink-bridge.test.ts +41 -0
  307. package/src/renderers/browser/deeplink-bridge.ts +22 -0
  308. package/src/renderers/browser/error-boundary.tsx +23 -0
  309. package/src/renderers/browser/main.tsx +64 -0
  310. package/src/renderers/browser/persistence.ts +74 -0
  311. package/src/renderers/browser/storage.test.ts +66 -0
  312. package/src/renderers/browser/storage.ts +50 -0
  313. package/src/renderers/browser/ticker-repository.ts +59 -0
  314. package/src/renderers/browser/ui-host.tsx +40 -0
  315. package/src/renderers/cloudflare/worker.test.ts +136 -0
  316. package/src/renderers/cloudflare/worker.ts +116 -0
  317. package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
  318. package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
  319. package/src/renderers/electrobun/bun/desktop/plugin-state.ts +7 -1
  320. package/src/renderers/electrobun/view/app-services.ts +14 -3
  321. package/src/renderers/electrobun/view/build-assets.ts +12 -10
  322. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -1
  323. package/src/renderers/electrobun/view/data-table/index.tsx +10 -1
  324. package/src/renderers/electrobun/view/desktop/controls.tsx +2 -0
  325. package/src/renderers/electrobun/view/dom-ui-host.tsx +143 -0
  326. package/src/renderers/electrobun/view/host/box.test.tsx +33 -0
  327. package/src/renderers/electrobun/view/host/box.tsx +11 -6
  328. package/src/renderers/electrobun/view/host/chart-surface.tsx +7 -1
  329. package/src/renderers/electrobun/view/host/input.tsx +11 -7
  330. package/src/renderers/electrobun/view/host/native.ts +0 -5
  331. package/src/renderers/electrobun/view/main.tsx +2 -0
  332. package/src/renderers/electrobun/view/remote/persistence.ts +5 -0
  333. package/src/renderers/electrobun/view/styles.css +107 -7
  334. package/src/renderers/electrobun/view/toast-host.test.tsx +78 -0
  335. package/src/renderers/electrobun/view/toast-host.tsx +69 -43
  336. package/src/renderers/electrobun/view/ui-host.tsx +6 -137
  337. package/src/renderers/opentui/start.tsx +2 -0
  338. package/src/renderers/share/layout-view.test.tsx +58 -0
  339. package/src/renderers/share/layout-view.tsx +133 -0
  340. package/src/renderers/share/main.tsx +102 -0
  341. package/src/renderers/share/styles.css +54 -0
  342. package/src/renderers/share/view.test.tsx +100 -0
  343. package/src/renderers/share/view.tsx +150 -0
  344. package/src/shares/api.test.ts +105 -0
  345. package/src/shares/api.ts +127 -0
  346. package/src/shares/chart-snapshot.test.ts +34 -0
  347. package/src/shares/chart-snapshot.ts +28 -0
  348. package/src/shares/location.test.ts +19 -0
  349. package/src/shares/location.ts +11 -0
  350. package/src/shares/pane.test.ts +110 -0
  351. package/src/shares/pane.ts +54 -0
  352. package/src/shares/payload.ts +177 -0
  353. package/src/sources/gloomberb-cloud/index.test.ts +61 -1
  354. package/src/sources/gloomberb-cloud/index.ts +86 -14
  355. package/src/sources/gloomberb-cloud/normalizers.test.ts +34 -0
  356. package/src/sources/gloomberb-cloud/normalizers.ts +5 -5
  357. package/src/sources/provider-router/history.test.ts +2 -2
  358. package/src/sources/provider-router/news.test.ts +34 -0
  359. package/src/sources/provider-router/news.ts +15 -1
  360. package/src/sources/sec-edgar.test.ts +59 -0
  361. package/src/sources/sec-edgar.ts +63 -23
  362. package/src/sources/yahoo-finance/mappers.ts +3 -0
  363. package/src/sources/yahoo-finance/snapshots.ts +10 -2
  364. package/src/sources/yahoo-finance/symbols.test.ts +10 -0
  365. package/src/sources/yahoo-finance/symbols.ts +13 -8
  366. package/src/sources/yahoo-finance.test.ts +1 -1
  367. package/src/sources/yahoo-finance.ts +6 -2
  368. package/src/state/app/bootstrap.ts +6 -7
  369. package/src/state/app/context/index.test.ts +41 -1
  370. package/src/state/app/context/index.test.tsx +34 -16
  371. package/src/state/selectors-ui.ts +0 -8
  372. package/src/sync/baseline.ts +32 -0
  373. package/src/sync/controller.test.ts +124 -1
  374. package/src/sync/controller.ts +54 -12
  375. package/src/sync/core-contributors.test.ts +44 -0
  376. package/src/sync/core-contributors.ts +59 -10
  377. package/src/sync/react.ts +22 -2
  378. package/src/sync/types.ts +12 -0
  379. package/src/theme/font-scale.ts +0 -11
  380. package/src/tickers/search/index.test.ts +27 -0
  381. package/src/tickers/search/upsert.ts +10 -0
  382. package/src/types/capability-route-source.ts +2 -1
  383. package/src/types/config.ts +35 -5
  384. package/src/types/desktop-menu.ts +1 -0
  385. package/src/types/plugin.ts +36 -0
  386. package/src/ui/context-menu.tsx +1 -1
  387. package/src/ui/host.tsx +4 -0
  388. package/src/ui/toast.tsx +2 -0
  389. package/src/utils/back-navigation.ts +0 -4
  390. package/src/utils/price-history.ts +11 -10
  391. package/src/utils/sec.test.ts +6 -0
  392. package/src/utils/sec.ts +1 -1
@@ -1,3 +1,5 @@
1
+ import { canonicalExchange } from "../../utils/exchanges";
2
+
1
3
  const EXCHANGE_SUFFIX_MAP: Record<string, string> = {
2
4
  NASDAQ: "", NMS: "", NYSE: "", AMEX: "", ARCA: "", NYSEArca: "", BATS: "", BYX: "", IEX: "", PINK: "", OTC: "",
3
5
  TSX: ".TO", VENTURE: ".V", CSE2: ".CN", CNSX: ".CN",
@@ -46,17 +48,19 @@ const KNOWN_SUFFIXES = new Set(
46
48
 
47
49
  export function getYahooSymbol(ticker: string, exchange: string): string {
48
50
  if (tickerHasYahooSuffix(ticker)) return ticker;
49
- const suffix = EXCHANGE_SUFFIX_MAP[exchange] ?? "";
50
- return `${normalizeYahooTicker(ticker, exchange)}${suffix}`;
51
+ const canonical = canonicalExchange(exchange) || exchange;
52
+ const suffix = EXCHANGE_SUFFIX_MAP[canonical] ?? EXCHANGE_SUFFIX_MAP[exchange] ?? "";
53
+ return `${normalizeYahooTicker(ticker, canonical)}${suffix}`;
51
54
  }
52
55
 
53
56
  export function getYahooSymbolsToTry(ticker: string, exchange: string): string[] {
54
57
  if (tickerHasYahooSuffix(ticker)) return [ticker];
55
58
 
56
- const normalized = normalizeYahooTicker(ticker, exchange);
59
+ const canonical = canonicalExchange(exchange) || exchange;
60
+ const normalized = normalizeYahooTicker(ticker, canonical);
57
61
  const dotVariant = normalized.includes(".") ? normalized.replace(/\./g, "-") : null;
58
62
 
59
- if (!exchange) {
63
+ if (!canonical) {
60
64
  const symbols = new Set<string>();
61
65
  const candidates = [normalized];
62
66
  if (dotVariant) candidates.unshift(dotVariant);
@@ -69,16 +73,16 @@ export function getYahooSymbolsToTry(ticker: string, exchange: string): string[]
69
73
  return Array.from(symbols);
70
74
  }
71
75
 
72
- const fallbacks = EXCHANGE_FALLBACKS[exchange];
76
+ const fallbacks = EXCHANGE_FALLBACKS[canonical] ?? EXCHANGE_FALLBACKS[exchange];
73
77
  if (fallbacks) {
74
78
  const results = fallbacks.map((suffix) => `${normalized}${suffix}`);
75
79
  if (dotVariant) results.unshift(...fallbacks.map((suffix) => `${dotVariant}${suffix}`));
76
80
  return results;
77
81
  }
78
82
 
79
- const primary = getYahooSymbol(ticker, exchange);
83
+ const primary = getYahooSymbol(ticker, canonical);
80
84
  if (dotVariant) {
81
- const suffix = EXCHANGE_SUFFIX_MAP[exchange] ?? "";
85
+ const suffix = EXCHANGE_SUFFIX_MAP[canonical] ?? EXCHANGE_SUFFIX_MAP[exchange] ?? "";
82
86
  return [`${dotVariant}${suffix}`, primary];
83
87
  }
84
88
  return [primary];
@@ -98,5 +102,6 @@ function normalizeYahooTicker(ticker: string, exchange: string): string {
98
102
  }
99
103
 
100
104
  function isHongKongExchange(exchange: string): boolean {
101
- return exchange === "HKEX" || exchange === "SEHK" || exchange === "HKG";
105
+ const canonical = canonicalExchange(exchange) || exchange;
106
+ return canonical === "HKEX" || canonical === "SEHK" || canonical === "HKG";
102
107
  }
@@ -282,7 +282,7 @@ describe("YahooFinanceClient exchange aliases", () => {
282
282
  dividends: [{ exDate: "2026-02-03", amount: 0.12 }],
283
283
  splits: [{ date: "2026-01-02", description: "2:1 split", ratio: 2, fromFactor: 1, toFactor: 2 }],
284
284
  earnings: [
285
- { date: "2026-03-16", time: "BMO", epsEstimate: -0.185 },
285
+ { date: "2026-03-16", time: "", epsEstimate: -0.185 },
286
286
  { date: "2026-01-31", epsActual: -0.35, epsEstimate: -0.13, difference: -0.22, surprisePercent: -169.23 },
287
287
  ],
288
288
  });
@@ -137,7 +137,9 @@ export class YahooFinanceClient implements DataProvider {
137
137
  const result = await loadYahooTickerFinancials(symbol, {
138
138
  fetchAssetProfile: (targetSymbol) => this.fetchAssetProfile(targetSymbol),
139
139
  fetchChart: (targetSymbol, range, interval) => this.fetchChart(targetSymbol, range, interval),
140
- fetchExtendedHoursData: (targetSymbol, meta) => this.fetchExtendedHoursData(targetSymbol, meta),
140
+ fetchExtendedHoursData: (targetSymbol, meta, regularClose) => (
141
+ this.fetchExtendedHoursData(targetSymbol, meta, regularClose)
142
+ ),
141
143
  fetchQuoteSupplement: (targetSymbol, currencyDivisor) =>
142
144
  this.fetchQuoteSupplement(targetSymbol, currencyDivisor),
143
145
  fetchTimeseries: (targetSymbol, types, period1) => this.fetchTimeseries(targetSymbol, types, period1),
@@ -176,7 +178,9 @@ export class YahooFinanceClient implements DataProvider {
176
178
  try {
177
179
  return await loadYahooQuote(symbol, {
178
180
  fetchChart: (targetSymbol, range, interval) => this.fetchChart(targetSymbol, range, interval),
179
- fetchExtendedHoursData: (targetSymbol, meta) => this.fetchExtendedHoursData(targetSymbol, meta),
181
+ fetchExtendedHoursData: (targetSymbol, meta, regularClose) => (
182
+ this.fetchExtendedHoursData(targetSymbol, meta, regularClose)
183
+ ),
180
184
  fetchQuoteSupplement: (targetSymbol, currencyDivisor) =>
181
185
  this.fetchQuoteSupplement(targetSymbol, currencyDivisor),
182
186
  providerId: this.id,
@@ -105,13 +105,12 @@ function resolveSymbolForPane(
105
105
  const instance = findPaneInstance(config.layout, instanceId);
106
106
  if (!instance) return null;
107
107
 
108
- if (instance.paneId === "portfolio-list") {
109
- const cursor = paneStateSeed[instanceId]?.cursorSymbol
110
- ?? (typeof sessionSnapshot?.paneState?.[instanceId]?.cursorSymbol === "string"
111
- ? sessionSnapshot.paneState[instanceId]?.cursorSymbol as string
112
- : null);
113
- return cursor && tickerMap.has(cursor) ? cursor : null;
114
- }
108
+ // Any pane can publish a cursor symbol; followers resolve through it.
109
+ const cursor = paneStateSeed[instanceId]?.cursorSymbol
110
+ ?? (typeof sessionSnapshot?.paneState?.[instanceId]?.cursorSymbol === "string"
111
+ ? sessionSnapshot.paneState[instanceId]?.cursorSymbol as string
112
+ : null);
113
+ if (cursor) return tickerMap.has(cursor) ? cursor : null;
115
114
 
116
115
  if (instance.binding?.kind === "fixed") {
117
116
  return tickerMap.has(instance.binding.symbol) ? instance.binding.symbol : null;
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { appReducer, createInitialState, resolveCollectionForPane, resolveTickerForPane } from "./index";
3
- import { cloneLayout, createDefaultConfig, createPaneInstance } from "../../../types/config";
3
+ import { cloneLayout, createDefaultConfig, createPaneInstance, findPaneInstance } from "../../../types/config";
4
4
  import type { AppSessionSnapshot } from "../../../core/state/session-persistence";
5
+ import { removePane } from "../../../plugins/pane-manager";
5
6
  import { buildBrokerPortfolioId } from "../../../utils/broker-instances";
6
7
 
7
8
  describe("resolveTickerForPane", () => {
@@ -18,6 +19,45 @@ describe("resolveTickerForPane", () => {
18
19
  expect(resolveTickerForPane(state, "ticker-detail:main")).toBe("AAPL");
19
20
  });
20
21
 
22
+ test("follows cursor symbols from any ticker source pane", () => {
23
+ const config = createDefaultConfig("/tmp/gloomberb-test");
24
+ const source = createPaneInstance("ai-screener", {
25
+ instanceId: "ai-screener:main",
26
+ binding: { kind: "none" },
27
+ });
28
+ const follower = createPaneInstance("ticker-detail", {
29
+ instanceId: "ticker-detail:screener",
30
+ binding: { kind: "follow", sourceInstanceId: source.instanceId },
31
+ });
32
+ config.layout.instances.push(source, follower);
33
+ config.layout.floating.push(
34
+ { instanceId: source.instanceId, x: 0, y: 0, width: 40, height: 12 },
35
+ { instanceId: follower.instanceId, x: 1, y: 1, width: 40, height: 12 },
36
+ );
37
+
38
+ const state = createInitialState(config);
39
+ state.paneState[source.instanceId] = { cursorSymbol: "NVDA" };
40
+
41
+ expect(resolveTickerForPane(state, source.instanceId)).toBe("NVDA");
42
+ expect(resolveTickerForPane(state, follower.instanceId)).toBe("NVDA");
43
+ });
44
+
45
+ test("pins a follower to its last ticker when its source closes", () => {
46
+ const config = createDefaultConfig("/tmp/gloomberb-test");
47
+ const state = createInitialState(config);
48
+ state.paneState["portfolio-list:main"] = { collectionId: "main", cursorSymbol: "AAPL" };
49
+
50
+ const next = appReducer(state, {
51
+ type: "UPDATE_LAYOUT",
52
+ layout: removePane(state.config.layout, "portfolio-list:main"),
53
+ });
54
+
55
+ expect(findPaneInstance(next.config.layout, "ticker-detail:main")?.binding).toEqual({
56
+ kind: "fixed",
57
+ symbol: "AAPL",
58
+ });
59
+ });
60
+
21
61
  test("uses fixed ticker bindings for pinned panes", () => {
22
62
  const config = createDefaultConfig("/tmp/gloomberb-test");
23
63
  const instance = createPaneInstance("ticker-detail", {
@@ -4,22 +4,6 @@ import { appReducer, createInitialState } from "./index";
4
4
  import { removePane } from "../../../plugins/pane-manager";
5
5
 
6
6
  describe("appReducer command bar state", () => {
7
- test("re-shows the gridlock tip and refreshes its sequence on every trigger", () => {
8
- const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
9
-
10
- const shown = appReducer(initial, { type: "SHOW_GRIDLOCK_TIP" });
11
- expect(shown.gridlockTipVisible).toBe(true);
12
- expect(shown.gridlockTipSequence).toBe(1);
13
-
14
- const dismissed = appReducer(shown, { type: "DISMISS_GRIDLOCK_TIP" });
15
- expect(dismissed.gridlockTipVisible).toBe(false);
16
- expect(dismissed.gridlockTipSequence).toBe(1);
17
-
18
- const repeated = appReducer(dismissed, { type: "SHOW_GRIDLOCK_TIP" });
19
- expect(repeated.gridlockTipVisible).toBe(true);
20
- expect(repeated.gridlockTipSequence).toBe(2);
21
- });
22
-
23
7
  test("tracks layout undo and redo history", () => {
24
8
  const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
25
9
  const defaultRatio = initial.config.layout.dockRoot && initial.config.layout.dockRoot.kind === "split"
@@ -195,6 +179,40 @@ describe("appReducer command bar state", () => {
195
179
  expect(state.config.layouts[state.config.activeLayoutIndex]?.name).toBe("Overview Copy");
196
180
  });
197
181
 
182
+ test("installs marketplace layouts as independent editable copies", () => {
183
+ const config = createDefaultConfig("/tmp/gloomberb-marketplace-install-test");
184
+ config.layouts.push(
185
+ { name: "Research", layout: cloneLayout(config.layout) },
186
+ { name: "Research (2)", layout: cloneLayout(config.layout) },
187
+ );
188
+ const marketplaceLayout = cloneLayout(config.layout);
189
+ if (!marketplaceLayout.dockRoot || marketplaceLayout.dockRoot.kind !== "split") {
190
+ throw new Error("expected split dock root");
191
+ }
192
+ marketplaceLayout.dockRoot.ratio = 0.72;
193
+
194
+ const installed = appReducer(createInitialState(config), {
195
+ type: "INSTALL_LAYOUT_COPY",
196
+ name: "Research",
197
+ layout: marketplaceLayout,
198
+ paneState: {
199
+ "ticker-detail:main": {
200
+ pluginState: { "prediction-markets": { searchQuery: "fed" } },
201
+ },
202
+ },
203
+ });
204
+
205
+ expect(installed.config.layouts.at(-1)?.name).toBe("Research (3)");
206
+ expect(installed.config.activeLayoutIndex).toBe(installed.config.layouts.length - 1);
207
+ expect(installed.config.layout.dockRoot).toMatchObject({ kind: "split", ratio: 0.72 });
208
+ expect(installed.paneState["ticker-detail:main"]).toMatchObject({
209
+ pluginState: { "prediction-markets": { searchQuery: "fed" } },
210
+ });
211
+
212
+ marketplaceLayout.dockRoot.ratio = 0.2;
213
+ expect(installed.config.layout.dockRoot).toMatchObject({ kind: "split", ratio: 0.72 });
214
+ });
215
+
198
216
  test("restores an explicit focus target after a layout removes the focused pane", () => {
199
217
  const config = createDefaultConfig("/tmp/gloomberb-test-focus-restore");
200
218
  const nextLayout = removePane(config.layout, "ticker-detail:main");
@@ -30,14 +30,6 @@ export function selectActiveLayoutIndex(state: AppState): number {
30
30
  return state.config.activeLayoutIndex;
31
31
  }
32
32
 
33
- export function selectGridlockTipVisible(state: AppState): boolean {
34
- return state.gridlockTipVisible;
35
- }
36
-
37
- export function selectGridlockTipSequence(state: AppState): number {
38
- return state.gridlockTipSequence;
39
- }
40
-
41
33
  export function selectUpdateAvailable(state: AppState): ReleaseInfo | null {
42
34
  return state.updateAvailable;
43
35
  }
@@ -0,0 +1,32 @@
1
+ import type { AppPluginStateStorePort } from "../core/app-service-ports";
2
+ import type { SyncBaselineStore } from "./types";
3
+
4
+ const BASELINE_PLUGIN_ID = "core.sync";
5
+ const BASELINE_KEY = "last-synced-payloads";
6
+
7
+ /**
8
+ * Persists what this device last pushed so the next pull can tell "the cloud is
9
+ * newer" from "local changed while the app was closed". Without it every launch
10
+ * treats local state as pristine and the cloud overwrites CLI/offline edits.
11
+ *
12
+ * Storage failures are swallowed: a missing baseline only falls back to keeping
13
+ * local state, never to discarding it.
14
+ */
15
+ export function createSyncBaselineStore(pluginState: AppPluginStateStorePort): SyncBaselineStore {
16
+ return {
17
+ load: () => {
18
+ try {
19
+ return pluginState.get<Record<string, unknown>>(BASELINE_PLUGIN_ID, BASELINE_KEY)?.value ?? null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ },
24
+ save: (payloads) => {
25
+ try {
26
+ pluginState.set(BASELINE_PLUGIN_ID, BASELINE_KEY, payloads);
27
+ } catch {
28
+ // Ignored: a stale baseline only makes the next pull more conservative.
29
+ }
30
+ },
31
+ };
32
+ }
@@ -14,12 +14,79 @@ import {
14
14
  import { CloudSyncController } from "./controller";
15
15
  import {
16
16
  SYNC_SNAPSHOT_SCHEMA_VERSION,
17
+ type SyncBaselineStore,
17
18
  type SyncContributor,
18
19
  type SyncSnapshot,
19
20
  type SyncSnapshotResponse,
20
21
  type SyncTransport,
21
22
  } from "./types";
22
23
 
24
+ test("keeps workspace edits made while the app was closed and uploads them", async () => {
25
+ // The CLI writes config.json directly, so at launch local state looks
26
+ // pristine and the cloud copy used to overwrite it.
27
+ const syncedConfig = createDefaultConfig("/tmp/gloomberb-sync-offline-edit-test");
28
+ syncedConfig.portfolios = [{ id: "main", name: "Main", currency: "USD" }];
29
+ const syncedPayload = __syncContributorInternalsForTests.collectCoreConfigPayload(syncedConfig);
30
+ const stored: Record<string, unknown> = { "core.config": syncedPayload };
31
+ const baselineStore: SyncBaselineStore = {
32
+ load: () => stored,
33
+ save: (payloads) => Object.assign(stored, payloads),
34
+ };
35
+
36
+ let state = createInitialState({
37
+ ...syncedConfig,
38
+ portfolios: [...syncedConfig.portfolios, { id: "research", name: "Research", currency: "USD" }],
39
+ });
40
+ const dispatch = (action: AppAction) => {
41
+ state = appReducer(state, action);
42
+ };
43
+ const pushes: SyncSnapshot[] = [];
44
+ const transport: SyncTransport = {
45
+ id: "offline-edit",
46
+ isAvailable: () => true,
47
+ pullSnapshot: async () => ({
48
+ snapshot: {
49
+ schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
50
+ appId: "gloomberb",
51
+ clientId: "remote-client",
52
+ createdAt: "2026-08-23T10:00:00.000Z",
53
+ contributors: {
54
+ "core.config": {
55
+ schemaVersion: 1,
56
+ updatedAt: "2026-08-23T10:00:00.000Z",
57
+ payload: syncedPayload,
58
+ },
59
+ },
60
+ },
61
+ revision: 4,
62
+ updatedAt: "2026-08-23T10:00:00.000Z",
63
+ }),
64
+ pushSnapshot: async (snapshot) => {
65
+ pushes.push(snapshot);
66
+ return { revision: 5, updatedAt: "2026-08-23T10:00:05.000Z" };
67
+ },
68
+ };
69
+
70
+ const controller = new CloudSyncController();
71
+ controller.setRuntime({
72
+ getState: () => state,
73
+ dispatch,
74
+ tickerRepository: {} as TickerRepository,
75
+ baselineStore,
76
+ getContributors: () => [{ pluginId: "core", contributor: coreConfigSyncContributor }],
77
+ getTransport: () => ({ pluginId: "test", transport }),
78
+ });
79
+
80
+ await controller.requestSync({ reason: "startup" });
81
+
82
+ const pushedPortfolios = (pushes[0]?.contributors["core.config"]?.payload as {
83
+ portfolios: Array<{ id: string }>;
84
+ }).portfolios.map((portfolio) => portfolio.id);
85
+ expect(state.config.portfolios.map((portfolio) => portfolio.id)).toEqual(["main", "research"]);
86
+ expect(pushedPortfolios).toEqual(["main", "research"]);
87
+ expect((stored["core.config"] as { portfolios: Array<{ id: string }> }).portfolios).toHaveLength(2);
88
+ });
89
+
23
90
  test("does not push local state when the initial pull fails", async () => {
24
91
  let pushes = 0;
25
92
  const transport: SyncTransport = {
@@ -240,7 +307,7 @@ test("keeps startup layout changes while serializing pull and push", async () =>
240
307
  const pushedConfig = pushes[0]?.snapshot.contributors["core.config"]?.payload as {
241
308
  layout: AppState["config"]["layout"];
242
309
  };
243
- expect(pulls).toBe(1);
310
+ expect(pulls).toBe(2);
244
311
  expect(pushes).toHaveLength(1);
245
312
  expect(pushes[0]?.options).toEqual({ baseRevision: 7 });
246
313
  expect(state.config.theme).toBe("green");
@@ -314,3 +381,59 @@ test("keeps the latest layout when switching away and back during a pull", async
314
381
  expect(state.config.activeLayoutIndex).toBe(0);
315
382
  expect(pushedConfig.activeLayoutIndex).toBe(0);
316
383
  });
384
+
385
+ test("pulls remote changes on later syncs instead of only once per session", async () => {
386
+ const applied: unknown[] = [];
387
+ const contributor: SyncContributor = {
388
+ id: "test.data",
389
+ schemaVersion: 1,
390
+ collect: () => ({ local: applied.at(-1) ?? null }),
391
+ apply: (payload) => {
392
+ applied.push(payload);
393
+ },
394
+ };
395
+ let remote = { value: 1 };
396
+ let remoteRevision = 4;
397
+ let pulls = 0;
398
+ const transport: SyncTransport = {
399
+ id: "repeat-pull",
400
+ isAvailable: () => true,
401
+ pullSnapshot: async () => {
402
+ pulls += 1;
403
+ return {
404
+ snapshot: {
405
+ schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
406
+ appId: "gloomberb",
407
+ clientId: "remote-client",
408
+ createdAt: "2026-08-27T00:00:00.000Z",
409
+ contributors: {
410
+ "test.data": {
411
+ schemaVersion: 1,
412
+ updatedAt: "2026-08-27T00:00:00.000Z",
413
+ payload: remote,
414
+ },
415
+ },
416
+ },
417
+ revision: remoteRevision,
418
+ updatedAt: "2026-08-27T00:00:00.000Z",
419
+ };
420
+ },
421
+ pushSnapshot: async () => ({ revision: remoteRevision + 1, updatedAt: "2026-08-27T00:00:01.000Z" }),
422
+ };
423
+ const controller = new CloudSyncController();
424
+ controller.setRuntime({
425
+ getState: () => ({} as AppState),
426
+ dispatch: () => {},
427
+ tickerRepository: {} as TickerRepository,
428
+ getContributors: () => [{ pluginId: "test", contributor }],
429
+ getTransport: () => ({ pluginId: "test", transport }),
430
+ });
431
+
432
+ await controller.requestSync({ reason: "startup" });
433
+ remote = { value: 2 };
434
+ remoteRevision = 12;
435
+ await controller.requestSync({ reason: "poll" });
436
+
437
+ expect(pulls).toBe(2);
438
+ expect(applied).toEqual([{ value: 1 }, { value: 2 }]);
439
+ });
@@ -6,6 +6,7 @@ import {
6
6
  SYNC_SNAPSHOT_SCHEMA_VERSION,
7
7
  type RegisteredSyncContributor,
8
8
  type RegisteredSyncTransport,
9
+ type SyncBaselineStore,
9
10
  type SyncContributor,
10
11
  type SyncSnapshot,
11
12
  type SyncTransport,
@@ -28,6 +29,7 @@ interface SyncRuntime {
28
29
  tickerRepository: AppTickerRepositoryPort;
29
30
  getContributors: () => RegisteredSyncContributor[];
30
31
  getTransport: () => RegisteredSyncTransport | null;
32
+ baselineStore?: SyncBaselineStore;
31
33
  }
32
34
 
33
35
  const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
@@ -65,7 +67,10 @@ export class CloudSyncController {
65
67
  private syncQueued = false;
66
68
  private clientId: string | null = null;
67
69
  private lastSignature: string | null = null;
68
- private hasPulledForTransport = new Set<string>();
70
+ private baseline: Record<string, unknown> | null = null;
71
+ private baselineLoaded = false;
72
+ private pulledContributors: SyncSnapshot["contributors"] = {};
73
+ private appliedRevision: number | null = null;
69
74
  private status: CloudSyncStatus = {
70
75
  phase: "idle",
71
76
  transportId: null,
@@ -181,12 +186,11 @@ export class CloudSyncController {
181
186
  }
182
187
  const transport = registration.transport;
183
188
 
184
- if (!this.hasPulledForTransport.has(transport.id)) {
185
- this.lastSignature = null;
186
- if (!await this.runPull(runtime, transport)) return;
187
- if (!this.isCurrent(runtime, transport)) return;
188
- this.hasPulledForTransport.add(transport.id);
189
- }
189
+ // Always pull before push. Another device (TUI, browser, phone) may have
190
+ // written since this process last synced, and contributors already merge
191
+ // so local-only edits survive the apply.
192
+ if (!await this.runPull(runtime, transport)) return;
193
+ if (!this.isCurrent(runtime, transport)) return;
190
194
 
191
195
  const snapshot = await this.assembleSnapshot(runtime);
192
196
  if (!this.isCurrent(runtime, transport)) return;
@@ -198,6 +202,8 @@ export class CloudSyncController {
198
202
  const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
199
203
  if (!this.isCurrent(runtime, transport)) return;
200
204
  this.lastSignature = signature;
205
+ this.appliedRevision = result.revision;
206
+ this.saveBaseline(runtime, snapshot);
201
207
  this.setStatus({
202
208
  phase: "synced",
203
209
  transportId: transport.id,
@@ -222,8 +228,9 @@ export class CloudSyncController {
222
228
  const response = await transport.pullSnapshot();
223
229
  if (!this.isCurrent(runtime, transport)) return false;
224
230
 
225
- if (response.snapshot) {
231
+ if (response.snapshot && this.shouldApplyPulledRevision(response.revision)) {
226
232
  this.assertSnapshotCompatible(response.snapshot, runtime.getContributors());
233
+ this.pulledContributors = response.snapshot.contributors;
227
234
  for (const entry of runtime.getContributors()) {
228
235
  if (!this.isCurrent(runtime, transport)) return false;
229
236
  const contributorPayload = response.snapshot.contributors[entry.contributor.id];
@@ -231,6 +238,7 @@ export class CloudSyncController {
231
238
  await entry.contributor.apply(contributorPayload.payload, {
232
239
  snapshot: response.snapshot,
233
240
  baselineState,
241
+ baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
234
242
  state: runtime.getState(),
235
243
  getState: runtime.getState,
236
244
  isCurrent: () => this.isCurrent(runtime, transport),
@@ -238,15 +246,16 @@ export class CloudSyncController {
238
246
  tickerRepository: runtime.tickerRepository,
239
247
  });
240
248
  }
249
+ if (response.revision != null) this.appliedRevision = response.revision;
241
250
  }
242
251
 
243
252
  if (!this.isCurrent(runtime, transport)) return false;
244
253
  this.setStatus({
245
254
  phase: response.snapshot ? "synced" : "idle",
246
255
  transportId: transport.id,
247
- revision: response.revision,
256
+ revision: this.newerRevision(response.revision),
248
257
  lastPullAt: response.updatedAt ?? nowIso(),
249
- lastSyncAt: response.updatedAt,
258
+ lastSyncAt: response.updatedAt ?? this.status.lastSyncAt,
250
259
  error: null,
251
260
  });
252
261
  return true;
@@ -287,7 +296,10 @@ export class CloudSyncController {
287
296
  private async assembleSnapshot(runtime: SyncRuntime): Promise<SyncSnapshot> {
288
297
  const contributors = runtime.getContributors();
289
298
  const createdAt = nowIso();
290
- const payloads: SyncSnapshot["contributors"] = {};
299
+ // The snapshot replaces the stored one wholesale, so data owned by a
300
+ // contributor this client does not run (disabled plugin, older build) is
301
+ // carried over instead of being deleted for every other device.
302
+ const payloads: SyncSnapshot["contributors"] = { ...this.pulledContributors };
291
303
  for (const entry of contributors) {
292
304
  const payload = await entry.contributor.collect({ state: runtime.getState() });
293
305
  payloads[entry.contributor.id] = {
@@ -305,17 +317,47 @@ export class CloudSyncController {
305
317
  };
306
318
  }
307
319
 
320
+ private loadBaseline(runtime: SyncRuntime): Record<string, unknown> | null {
321
+ if (!this.baselineLoaded) {
322
+ this.baselineLoaded = true;
323
+ this.baseline = runtime.baselineStore?.load() ?? null;
324
+ }
325
+ return this.baseline;
326
+ }
327
+
328
+ private saveBaseline(runtime: SyncRuntime, snapshot: SyncSnapshot): void {
329
+ const payloads = Object.fromEntries(
330
+ Object.entries(snapshot.contributors).map(([id, contributor]) => [id, contributor.payload]),
331
+ );
332
+ this.baseline = payloads;
333
+ this.baselineLoaded = true;
334
+ runtime.baselineStore?.save(payloads);
335
+ }
336
+
308
337
  private isCurrent(runtime: SyncRuntime, transport: SyncTransport): boolean {
309
338
  return this.runtime === runtime && runtime.getTransport()?.transport === transport;
310
339
  }
311
340
 
341
+ private shouldApplyPulledRevision(revision: number | null): boolean {
342
+ return revision == null || this.appliedRevision == null || revision > this.appliedRevision;
343
+ }
344
+
345
+ private newerRevision(revision: number | null): number | null {
346
+ if (revision == null) return this.status.revision;
347
+ if (this.status.revision == null || revision > this.status.revision) return revision;
348
+ return this.status.revision;
349
+ }
350
+
312
351
  private resetOperations(): void {
313
352
  if (this.pushTimer) clearTimeout(this.pushTimer);
314
353
  this.pushTimer = null;
315
354
  this.inFlight = null;
316
355
  this.syncQueued = false;
317
- this.hasPulledForTransport.clear();
318
356
  this.lastSignature = null;
357
+ this.appliedRevision = null;
358
+ this.baseline = null;
359
+ this.baselineLoaded = false;
360
+ this.pulledContributors = {};
319
361
  }
320
362
 
321
363
  private updateAvailability(): void {
@@ -260,6 +260,50 @@ describe("core sync contributors", () => {
260
260
  expect((payload as any).analyticsByPortfolio.main.oneYearReturn).toBe(0.42);
261
261
  });
262
262
 
263
+ test("keeps a position written while the app was closed", async () => {
264
+ const config = createDefaultConfig("/tmp/gloomberb-sync-position-test");
265
+ config.portfolios = [{ id: "main", name: "Main", currency: "USD" }];
266
+ const withoutPosition: TickerRecord = {
267
+ metadata: {
268
+ ticker: "NVDA",
269
+ exchange: "NASDAQ",
270
+ currency: "USD",
271
+ name: "NVIDIA",
272
+ portfolios: ["main"],
273
+ watchlists: [],
274
+ positions: [],
275
+ custom: {},
276
+ tags: [],
277
+ },
278
+ };
279
+ const syncedState = createInitialState(config);
280
+ syncedState.tickers = new Map([["NVDA", withoutPosition]]);
281
+ const syncedPayload = await coreCollectionsSyncContributor.collect({ state: syncedState });
282
+
283
+ // `gloomberb portfolio position set` wrote this straight to the database.
284
+ const state = createInitialState(config);
285
+ state.tickers = new Map([["NVDA", {
286
+ metadata: {
287
+ ...withoutPosition.metadata,
288
+ positions: [{ portfolio: "main", shares: 10, avgCost: 100, broker: "manual", currency: "USD" }],
289
+ },
290
+ }]]);
291
+
292
+ const saved: TickerRecord[] = [];
293
+ await coreCollectionsSyncContributor.apply?.(syncedPayload, {
294
+ baselinePayload: syncedPayload,
295
+ baselineState: state,
296
+ state,
297
+ getState: () => state,
298
+ isCurrent: () => true,
299
+ dispatch: () => {},
300
+ tickerRepository: { saveTicker: async (record: TickerRecord) => { saved.push(record); } },
301
+ } as unknown as Parameters<NonNullable<typeof coreCollectionsSyncContributor.apply>>[1]);
302
+
303
+ expect(saved).toHaveLength(0);
304
+ expect(state.tickers.get("NVDA")?.metadata.positions).toHaveLength(1);
305
+ });
306
+
263
307
  test("syncs only public return and beta analytics", async () => {
264
308
  const config = createDefaultConfig("/tmp/gloomberb-sync-test");
265
309
  config.baseCurrency = "USD";