gloomberb 0.17.2 → 0.17.4

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 (200) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
@@ -5,6 +5,7 @@ import { stableStringify } from "../utils/hash";
5
5
  import { VERSION } from "../version";
6
6
  import {
7
7
  SYNC_SNAPSHOT_SCHEMA_VERSION,
8
+ SyncRevisionConflictError,
8
9
  type RegisteredSyncContributor,
9
10
  type RegisteredSyncTransport,
10
11
  type SyncBaselineStore,
@@ -35,6 +36,16 @@ interface SyncRuntime {
35
36
 
36
37
  const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
37
38
  const PUSH_DEBOUNCE_MS = 2500;
39
+ /** How soon after a change contributors date it, so an edit made just before quitting still is. */
40
+ const OBSERVE_DEBOUNCE_MS = 250;
41
+ /** Pushes refused because another device wrote first, each followed by a fresh pull, before giving up until the next sync. */
42
+ const MAX_CONFLICT_RETRIES = 3;
43
+
44
+ /** What one pull left behind: the stored revision it merged with (0 for none) and that copy's content. */
45
+ interface PullResult {
46
+ revision: number | null;
47
+ signature: string | null;
48
+ }
38
49
 
39
50
  function nowIso(): string {
40
51
  return new Date().toISOString();
@@ -49,13 +60,17 @@ function resolveClientId(): string {
49
60
  return id;
50
61
  }
51
62
 
63
+ /**
64
+ * The content of a snapshot as it reads once stored, so one assembled here
65
+ * (with fields left undefined) compares equal to the same one pulled back.
66
+ */
52
67
  function snapshotContentSignature(snapshot: SyncSnapshot): string {
53
- return stableStringify(Object.fromEntries(
68
+ return stableStringify(JSON.parse(JSON.stringify(Object.fromEntries(
54
69
  Object.entries(snapshot.contributors).map(([id, contributor]) => [
55
70
  id,
56
71
  { schemaVersion: contributor.schemaVersion, payload: contributor.payload },
57
72
  ]),
58
- ));
73
+ ))));
59
74
  }
60
75
 
61
76
  export class CloudSyncController {
@@ -64,6 +79,10 @@ export class CloudSyncController {
64
79
  private transports = new Map<string, RegisteredSyncTransport>();
65
80
  private listeners = new Set<() => void>();
66
81
  private pushTimer: ReturnType<typeof setTimeout> | null = null;
82
+ private observeTimer: ReturnType<typeof setTimeout> | null = null;
83
+ private observedSinceStart = false;
84
+ private memos: Record<string, unknown> | null = null;
85
+ private memosLoaded = false;
67
86
  private inFlight: Promise<void> | null = null;
68
87
  private syncQueued = false;
69
88
  private clientId: string | null = null;
@@ -141,6 +160,7 @@ export class CloudSyncController {
141
160
  schedulePush(reason: string): void {
142
161
  const runtime = this.runtime;
143
162
  if (!runtime) return;
163
+ this.scheduleObserve(runtime);
144
164
  const registration = runtime.getTransport();
145
165
  if (!registration?.transport.isAvailable()) {
146
166
  this.setStatus({
@@ -176,6 +196,7 @@ export class CloudSyncController {
176
196
  private async syncOnce(options: { reason?: string; force?: boolean }): Promise<void> {
177
197
  const runtime = this.runtime;
178
198
  if (!runtime) return;
199
+ this.observe(runtime);
179
200
  const registration = runtime.getTransport();
180
201
  if (!registration?.transport.isAvailable()) {
181
202
  this.setStatus({
@@ -187,53 +208,72 @@ export class CloudSyncController {
187
208
  }
188
209
  const transport = registration.transport;
189
210
 
190
- // Always pull before push. Another device (TUI, browser, phone) may have
191
- // written since this process last synced, and contributors already merge
192
- // so local-only edits survive the apply.
193
- if (!await this.runPull(runtime, transport)) return;
194
- if (!this.isCurrent(runtime, transport)) return;
211
+ for (let attempt = 0; ; attempt += 1) {
212
+ // Always pull before push. Another device (TUI, browser, phone) may have
213
+ // written since this process last synced, and contributors already merge
214
+ // so local-only edits survive the apply.
215
+ const pulled = await this.runPull(runtime, transport);
216
+ if (!pulled || !this.isCurrent(runtime, transport)) return;
195
217
 
196
- const snapshot = await this.assembleSnapshot(runtime);
197
- if (!this.isCurrent(runtime, transport)) return;
198
- const signature = snapshotContentSignature(snapshot);
199
- if (!options.force && signature === this.lastSignature) return;
200
-
201
- this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
202
- try {
203
- const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
204
- if (!this.isCurrent(runtime, transport)) return;
205
- this.lastSignature = signature;
206
- this.appliedRevision = result.revision;
207
- this.saveBaseline(runtime, snapshot);
208
- this.setStatus({
209
- phase: "synced",
210
- transportId: transport.id,
211
- revision: result.revision,
212
- lastSyncAt: result.updatedAt,
213
- error: null,
214
- });
215
- } catch (error) {
218
+ const snapshot = await this.assembleSnapshot(runtime);
216
219
  if (!this.isCurrent(runtime, transport)) return;
217
- this.setStatus({
218
- phase: "error",
219
- transportId: transport.id,
220
- error: error instanceof Error ? error.message : String(error),
221
- });
220
+ const signature = snapshotContentSignature(snapshot);
221
+ if (!options.force && signature === this.lastSignature) return;
222
+ if (!options.force && signature === pulled.signature) {
223
+ // The cloud already holds exactly this: pushing it back would only
224
+ // bump the revision under another device's next push.
225
+ this.lastSignature = signature;
226
+ this.saveBaseline(runtime, snapshot);
227
+ return;
228
+ }
229
+
230
+ this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
231
+ try {
232
+ // The revision this snapshot was merged with. The cloud refuses the
233
+ // write if another device wrote since, instead of losing that write.
234
+ const result = await transport.pushSnapshot(snapshot, { baseRevision: pulled.revision });
235
+ if (!this.isCurrent(runtime, transport)) return;
236
+ this.lastSignature = signature;
237
+ this.appliedRevision = result.revision;
238
+ this.saveBaseline(runtime, snapshot);
239
+ this.setStatus({
240
+ phase: "synced",
241
+ transportId: transport.id,
242
+ revision: result.revision,
243
+ lastSyncAt: result.updatedAt,
244
+ error: null,
245
+ });
246
+ return;
247
+ } catch (error) {
248
+ if (!this.isCurrent(runtime, transport)) return;
249
+ if (error instanceof SyncRevisionConflictError && attempt < MAX_CONFLICT_RETRIES) {
250
+ // Pull whatever the other device wrote, even an older revision than
251
+ // this process last saw (an account switch, a restored snapshot).
252
+ this.appliedRevision = null;
253
+ continue;
254
+ }
255
+ this.setStatus({
256
+ phase: "error",
257
+ transportId: transport.id,
258
+ error: error instanceof Error ? error.message : String(error),
259
+ });
260
+ return;
261
+ }
222
262
  }
223
263
  }
224
264
 
225
- private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<boolean> {
265
+ private async runPull(runtime: SyncRuntime, transport: SyncTransport): Promise<PullResult | null> {
226
266
  const baselineState = runtime.getState();
227
267
  this.setStatus({ phase: "syncing", transportId: transport.id, error: null });
228
268
  try {
229
269
  const response = await transport.pullSnapshot();
230
- if (!this.isCurrent(runtime, transport)) return false;
270
+ if (!this.isCurrent(runtime, transport)) return null;
231
271
 
232
272
  if (response.snapshot && this.shouldApplyPulledRevision(response.revision)) {
233
273
  this.assertSnapshotCompatible(response.snapshot, runtime.getContributors());
234
274
  this.pulledContributors = response.snapshot.contributors;
235
275
  for (const entry of runtime.getContributors()) {
236
- if (!this.isCurrent(runtime, transport)) return false;
276
+ if (!this.isCurrent(runtime, transport)) return null;
237
277
  const contributorPayload = response.snapshot.contributors[entry.contributor.id];
238
278
  if (!contributorPayload || !entry.contributor.apply) continue;
239
279
  await entry.contributor.apply(contributorPayload.payload, {
@@ -245,12 +285,13 @@ export class CloudSyncController {
245
285
  isCurrent: () => this.isCurrent(runtime, transport),
246
286
  dispatch: runtime.dispatch,
247
287
  tickerRepository: runtime.tickerRepository,
288
+ memo: this.memoFor(runtime, entry.contributor.id),
248
289
  });
249
290
  }
250
291
  if (response.revision != null) this.appliedRevision = response.revision;
251
292
  }
252
293
 
253
- if (!this.isCurrent(runtime, transport)) return false;
294
+ if (!this.isCurrent(runtime, transport)) return null;
254
295
  this.setStatus({
255
296
  phase: response.snapshot ? "synced" : "idle",
256
297
  transportId: transport.id,
@@ -259,15 +300,18 @@ export class CloudSyncController {
259
300
  lastSyncAt: response.updatedAt ?? this.status.lastSyncAt,
260
301
  error: null,
261
302
  });
262
- return true;
303
+ return {
304
+ revision: response.snapshot ? response.revision : 0,
305
+ signature: response.snapshot ? snapshotContentSignature(response.snapshot) : null,
306
+ };
263
307
  } catch (error) {
264
- if (!this.isCurrent(runtime, transport)) return false;
308
+ if (!this.isCurrent(runtime, transport)) return null;
265
309
  this.setStatus({
266
310
  phase: "error",
267
311
  transportId: transport.id,
268
312
  error: error instanceof Error ? error.message : String(error),
269
313
  });
270
- return false;
314
+ return null;
271
315
  }
272
316
  }
273
317
 
@@ -302,7 +346,11 @@ export class CloudSyncController {
302
346
  // carried over instead of being deleted for every other device.
303
347
  const payloads: SyncSnapshot["contributors"] = { ...this.pulledContributors };
304
348
  for (const entry of contributors) {
305
- const payload = await entry.contributor.collect({ state: runtime.getState() });
349
+ const payload = await entry.contributor.collect({
350
+ state: runtime.getState(),
351
+ baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
352
+ memo: this.memoFor(runtime, entry.contributor.id),
353
+ });
306
354
  payloads[entry.contributor.id] = {
307
355
  schemaVersion: entry.contributor.schemaVersion,
308
356
  updatedAt: createdAt,
@@ -327,6 +375,53 @@ export class CloudSyncController {
327
375
  return this.baseline;
328
376
  }
329
377
 
378
+ private scheduleObserve(runtime: SyncRuntime): void {
379
+ if (this.observeTimer) clearTimeout(this.observeTimer);
380
+ this.observeTimer = setTimeout(() => {
381
+ this.observeTimer = null;
382
+ if (this.runtime === runtime) this.observe(runtime);
383
+ }, OBSERVE_DEBOUNCE_MS);
384
+ }
385
+
386
+ /** Lets contributors date local edits as they happen, whether or not the cloud is reachable. */
387
+ private observe(runtime: SyncRuntime): void {
388
+ const launch = !this.observedSinceStart;
389
+ this.observedSinceStart = true;
390
+ for (const entry of runtime.getContributors()) {
391
+ if (!entry.contributor.observe) continue;
392
+ try {
393
+ entry.contributor.observe({
394
+ state: runtime.getState(),
395
+ baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
396
+ memo: this.memoFor(runtime, entry.contributor.id),
397
+ launch,
398
+ });
399
+ } catch {
400
+ // A contributor that cannot date an edit leaves it undated, which only
401
+ // makes it lose a conflict it might have won.
402
+ }
403
+ }
404
+ }
405
+
406
+ private memoFor(runtime: SyncRuntime, contributorId: string) {
407
+ return {
408
+ get: () => this.loadMemos(runtime)[contributorId] ?? null,
409
+ set: (value: unknown) => {
410
+ const memos = { ...this.loadMemos(runtime), [contributorId]: value };
411
+ this.memos = memos;
412
+ runtime.baselineStore?.saveMemos?.(memos);
413
+ },
414
+ };
415
+ }
416
+
417
+ private loadMemos(runtime: SyncRuntime): Record<string, unknown> {
418
+ if (!this.memosLoaded) {
419
+ this.memosLoaded = true;
420
+ this.memos = runtime.baselineStore?.loadMemos?.() ?? null;
421
+ }
422
+ return this.memos ?? {};
423
+ }
424
+
330
425
  private saveBaseline(runtime: SyncRuntime, snapshot: SyncSnapshot): void {
331
426
  const payloads = Object.fromEntries(
332
427
  Object.entries(snapshot.contributors).map(([id, contributor]) => [id, contributor.payload]),
@@ -353,6 +448,11 @@ export class CloudSyncController {
353
448
  private resetOperations(): void {
354
449
  if (this.pushTimer) clearTimeout(this.pushTimer);
355
450
  this.pushTimer = null;
451
+ if (this.observeTimer) clearTimeout(this.observeTimer);
452
+ this.observeTimer = null;
453
+ this.observedSinceStart = false;
454
+ this.memos = null;
455
+ this.memosLoaded = false;
356
456
  this.inFlight = null;
357
457
  this.syncQueued = false;
358
458
  this.lastSignature = null;
@@ -1,6 +1,6 @@
1
1
  import { scheduleConfigSave } from "../state/config-save-scheduler";
2
2
  import { debugLog } from "../utils/debug-log";
3
- import { stableStringify } from "../utils/hash";
3
+ import { fnv1aHex, stableStringify } from "../utils/hash";
4
4
  import type { AppConfig, BrokerInstanceConfig, SavedLayout } from "../types/config";
5
5
  import type { PricePoint, TickerFinancials } from "../types/financials";
6
6
  import type { Portfolio, TickerMetadata, TickerPosition, TickerRecord, Watchlist } from "../types/ticker";
@@ -40,6 +40,9 @@ import { withoutMissingWatchlists } from "../plugins/builtin/portfolio-list/muta
40
40
  */
41
41
  const SESSION_SAVED_LAYOUT_KEYS = ["paneState", "focusedPaneId", "activePanel"] as const;
42
42
 
43
+ /** Together these are the workspace: the tabs, the one showing, and its arrangement. */
44
+ const LAYOUT_SYNC_KEYS = ["layout", "layouts", "activeLayoutIndex"] as const;
45
+
43
46
  const SENSITIVE_KEY_PATTERN = /(token|secret|password|credential|private|api[_-]?key|access[_-]?key|refresh[_-]?key|session|cookie|dataDir|path|directory|localPath)/i;
44
47
 
45
48
  const log = debugLog.createLogger("sync");
@@ -351,6 +354,14 @@ function sanitizeTickerMetadata(metadata: TickerMetadata, financials?: TickerFin
351
354
  };
352
355
  }
353
356
 
357
+ function collectLayoutPayload(config: Pick<AppConfig, typeof LAYOUT_SYNC_KEYS[number]>) {
358
+ return sanitizeUnknown({
359
+ layout: config.layout,
360
+ layouts: config.layouts.map(withoutSessionLayoutState),
361
+ activeLayoutIndex: config.activeLayoutIndex,
362
+ }) as Record<string, unknown>;
363
+ }
364
+
354
365
  function collectCoreConfigPayload(config: AppConfig) {
355
366
  return sanitizeUnknown({
356
367
  configVersion: config.configVersion,
@@ -358,9 +369,7 @@ function collectCoreConfigPayload(config: AppConfig) {
358
369
  refreshIntervalMinutes: config.refreshIntervalMinutes,
359
370
  portfolios: config.portfolios.map(sanitizePortfolio),
360
371
  watchlists: config.watchlists.map(sanitizeWatchlist),
361
- layout: config.layout,
362
- layouts: config.layouts.map(withoutSessionLayoutState),
363
- activeLayoutIndex: config.activeLayoutIndex,
372
+ ...collectLayoutPayload(config),
364
373
  brokerInstances: config.brokerInstances.map(sanitizeBrokerInstance),
365
374
  // This build merges profiles one by one before it pushes, so a profile
366
375
  // missing from its list was removed, not one it never pulled.
@@ -622,11 +631,204 @@ function withLocalSessionLayoutState(
622
631
  });
623
632
  }
624
633
 
634
+ /**
635
+ * A layout as the user arranged it. Stacking order follows whichever floating
636
+ * pane was focused last, and a pane in its own desktop window is one a
637
+ * terminal draws inline as a floating pane, so neither tells two copies apart.
638
+ */
639
+ function arrangedLayout(layout: unknown): unknown {
640
+ if (!isRecord(layout)) return layout;
641
+ const windows = [
642
+ ...(Array.isArray(layout.floating) ? layout.floating : []),
643
+ ...(Array.isArray(layout.detached) ? layout.detached : []),
644
+ ].map((entry) => {
645
+ if (!isRecord(entry)) return entry;
646
+ const { zIndex: _zIndex, ...rest } = entry;
647
+ return rest;
648
+ });
649
+ const instanceId = (entry: unknown) => (isRecord(entry) ? String(entry.instanceId) : "");
650
+ windows.sort((left, right) => instanceId(left).localeCompare(instanceId(right)));
651
+ return { ...layout, floating: windows, detached: [] };
652
+ }
653
+
654
+ // Pulled and last synced payloads are compared on every sync and never change.
655
+ const layoutIntents = new WeakMap<object, string | null>();
656
+
657
+ /**
658
+ * A key for the workspace a payload describes, equal for two copies that only
659
+ * differ in what reflects one device, so a real edit can be told from them.
660
+ * Null when the payload holds no complete workspace.
661
+ */
662
+ function layoutIntent(payload: unknown): string | null {
663
+ if (!isRecord(payload) || !Array.isArray(payload.layouts)) return null;
664
+ if (!LAYOUT_SYNC_KEYS.every((key) => key in payload)) return null;
665
+ const cached = layoutIntents.get(payload);
666
+ if (cached !== undefined) return cached;
667
+ const intent = stableStringify(sanitizeUnknown({
668
+ layout: arrangedLayout(payload.layout),
669
+ layouts: payload.layouts.map((entry) => (
670
+ isRecord(entry)
671
+ ? { ...withoutSessionLayoutState(entry as unknown as SavedLayout), layout: arrangedLayout(entry.layout) }
672
+ : entry
673
+ )),
674
+ activeLayoutIndex: payload.activeLayoutIndex,
675
+ }));
676
+ const key = `${fnv1aHex(intent)}:${intent.length}`;
677
+ layoutIntents.set(payload, key);
678
+ return key;
679
+ }
680
+
681
+ /** The workspace keys of a payload, with the time its layout was last edited when it says. */
682
+ function pickLayoutPayload(payload: Record<string, unknown>): Record<string, unknown> {
683
+ const picked = Object.fromEntries([...LAYOUT_SYNC_KEYS, "layoutEditedAt"].flatMap((key) => (
684
+ key in payload ? [[key, payload[key]]] : []
685
+ )));
686
+ // An older build still sent the view it was looking at with each layout.
687
+ if (Array.isArray(picked.layouts)) {
688
+ picked.layouts = picked.layouts.map((entry) => (isRecord(entry) ? withoutSessionLayoutState(entry as unknown as SavedLayout) : entry));
689
+ }
690
+ return picked;
691
+ }
692
+
693
+ /**
694
+ * What this device remembers about its workspace between syncs: when it last
695
+ * edited it (null when that happened while the app was closed, so the time is
696
+ * unknown), or, when it took the workspace from the cloud, that copy as synced.
697
+ */
698
+ interface LayoutMemo {
699
+ intent: string;
700
+ editedAt: number | null;
701
+ synced?: Record<string, unknown>;
702
+ }
703
+
704
+ function readLayoutMemo(value: unknown): LayoutMemo | null {
705
+ if (!isRecord(value) || typeof value.intent !== "string") return null;
706
+ return {
707
+ intent: value.intent,
708
+ editedAt: typeof value.editedAt === "number" && Number.isFinite(value.editedAt) ? value.editedAt : null,
709
+ ...(isRecord(value.synced) ? { synced: value.synced } : {}),
710
+ };
711
+ }
712
+
713
+ /**
714
+ * When a pulled workspace was last edited. A copy from a build that did not
715
+ * date edits counts as edited when it was pushed, which is never earlier.
716
+ */
717
+ function pulledLayoutEditedAt(payload: Record<string, unknown>, pushedAt: string | undefined): number {
718
+ if (typeof payload.layoutEditedAt === "number" && Number.isFinite(payload.layoutEditedAt)) return payload.layoutEditedAt;
719
+ const pushed = pushedAt ? Date.parse(pushedAt) : Number.NaN;
720
+ return Number.isFinite(pushed) ? pushed : Number.POSITIVE_INFINITY;
721
+ }
722
+
723
+ /**
724
+ * Whether a pulled workspace replaces this device's, three ways: against the
725
+ * copy this device last synced, the one it had when the pull started, and the
726
+ * pulled one. Only a real edit here keeps the local copy; the pane state,
727
+ * focus and window stacking that change on every launch never do.
728
+ */
729
+ function shouldAdoptPulledLayout({
730
+ local,
731
+ atPullStart,
732
+ lastSynced,
733
+ pulled,
734
+ memo,
735
+ pulledEditedAt,
736
+ }: {
737
+ local: string | null;
738
+ atPullStart: string | null;
739
+ lastSynced: string | null;
740
+ pulled: string | null;
741
+ memo: LayoutMemo | null;
742
+ pulledEditedAt: number;
743
+ }): boolean {
744
+ if (pulled === null || pulled === local) return false;
745
+ // Edited while the pull was in flight: newer than anything it carries.
746
+ if (local !== atPullStart) return false;
747
+ const takenFromCloud = !!memo?.synced && memo.intent === local;
748
+ const editedHere = lastSynced !== null && local !== lastSynced && !takenFromCloud;
749
+ if (!editedHere) return true;
750
+ // The cloud still holds what this device last synced: only this side changed.
751
+ if (pulled === lastSynced) return false;
752
+ // Both sides changed since: the later edit wins, and an undated local one loses.
753
+ const localEditedAt = memo?.intent === local ? memo.editedAt : null;
754
+ return localEditedAt === null || localEditedAt <= pulledEditedAt;
755
+ }
756
+
757
+ /**
758
+ * The workspace as the cloud already has it unless this device really edited
759
+ * it, so what only reflects this device never replaces another device's copy,
760
+ * and a real edit goes out dated.
761
+ */
762
+ function withLayoutProvenance(
763
+ payload: Record<string, unknown>,
764
+ lastSyncedPayload: unknown,
765
+ memoValue: unknown,
766
+ ): Record<string, unknown> {
767
+ const local = layoutIntent(payload);
768
+ if (local === null) return payload;
769
+ const memo = readLayoutMemo(memoValue);
770
+ if (memo?.synced && memo.intent === local) return { ...payload, ...memo.synced };
771
+ if (isRecord(lastSyncedPayload) && layoutIntent(lastSyncedPayload) === local) {
772
+ return { ...payload, ...pickLayoutPayload(lastSyncedPayload) };
773
+ }
774
+ return {
775
+ ...payload,
776
+ layoutEditedAt: memo?.intent === local && memo.editedAt !== null ? memo.editedAt : Date.now(),
777
+ };
778
+ }
779
+
780
+ /**
781
+ * Dates a workspace edit when it happens, online or not, so a later conflict
782
+ * with another device's edit goes to the newer one.
783
+ */
784
+ function noteLayoutEdit(config: AppConfig, lastSyncedPayload: unknown, memoValue: unknown, launch: boolean): LayoutMemo | null {
785
+ const lastSynced = layoutIntent(lastSyncedPayload);
786
+ if (lastSynced === null) return null;
787
+ const local = layoutIntent(collectLayoutPayload(config));
788
+ if (local === null || local === lastSynced) return null;
789
+ if (readLayoutMemo(memoValue)?.intent === local) return null;
790
+ // Seen at launch, it was made while the app was closed or just before it
791
+ // quit; when is unknown, so it gives way to a dated edit elsewhere.
792
+ return { intent: local, editedAt: launch ? null : Date.now() };
793
+ }
794
+
795
+ /**
796
+ * Once this device holds the cloud's workspace, by taking it or because the
797
+ * two already matched, it remembers that copy as synced, keyed by how it
798
+ * looks here once applied. The next push then sends the cloud's own copy back
799
+ * rather than this device's rendering of it.
800
+ */
801
+ function rememberCloudLayout(
802
+ payload: Record<string, unknown>,
803
+ pushedAt: string | undefined,
804
+ applied: AppConfig,
805
+ memo: { get(): unknown; set(value: unknown): void },
806
+ adopted: boolean,
807
+ ): void {
808
+ const local = layoutIntent(collectLayoutPayload(applied));
809
+ if (local === null || (!adopted && local !== layoutIntent(payload))) return;
810
+ const current = readLayoutMemo(memo.get());
811
+ if (!adopted && current?.synced && current.intent === local) return;
812
+ const editedAt = pulledLayoutEditedAt(payload, pushedAt);
813
+ memo.set({
814
+ intent: local,
815
+ editedAt: Number.isFinite(editedAt) ? editedAt : null,
816
+ synced: pickLayoutPayload(payload),
817
+ } satisfies LayoutMemo);
818
+ }
819
+
820
+ interface LayoutMergeContext {
821
+ memo?: unknown;
822
+ /** When the pulled copy was pushed, for one from a build that did not date layout edits. */
823
+ pushedAt?: string;
824
+ }
825
+
625
826
  function mergeConfigPayload(
626
827
  config: AppConfig,
627
828
  payload: unknown,
628
829
  baselineConfig: AppConfig = config,
629
830
  lastSyncedPayload?: unknown,
831
+ layoutContext: LayoutMergeContext = {},
630
832
  ): AppConfig | null {
631
833
  if (!isRecord(payload)) return null;
632
834
  const next: AppConfig = { ...config };
@@ -634,11 +836,9 @@ function mergeConfigPayload(
634
836
  // was in flight; lastSyncedPayload catches edits made while the app was not
635
837
  // running at all (CLI writes, offline edits), which otherwise look pristine.
636
838
  const lastSynced = isRecord(lastSyncedPayload) ? lastSyncedPayload : null;
637
- const localPayload = lastSynced
638
- ? collectCoreConfigPayload(config) as Record<string, unknown>
639
- : null;
839
+ const localPayload = collectCoreConfigPayload(config) as Record<string, unknown>;
640
840
  const matchesLastSynced = (key: string) => (
641
- !lastSynced || valuesEqual(localPayload?.[key], lastSynced[key])
841
+ !lastSynced || valuesEqual(localPayload[key], lastSynced[key])
642
842
  );
643
843
  const canApply = <K extends keyof AppConfig>(key: K) => (
644
844
  valuesEqual(config[key], baselineConfig[key]) && matchesLastSynced(key as string)
@@ -689,15 +889,20 @@ function mergeConfigPayload(
689
889
  );
690
890
  }
691
891
 
692
- const layoutStateUntouched = config.layout === baselineConfig.layout
693
- && config.layouts === baselineConfig.layouts
694
- && config.activeLayoutIndex === baselineConfig.activeLayoutIndex
695
- && ["layout", "layouts", "activeLayoutIndex"].every(matchesLastSynced);
696
- if (
697
- layoutStateUntouched
698
- && ["layout", "layouts", "activeLayoutIndex"].every((key) => key in payload)
699
- && Array.isArray(payload.layouts)
700
- ) {
892
+ // Compared by content, not identity: the active layout mirrors pane state,
893
+ // so a cursor settling at launch replaced it mid-pull, the pulled workspace
894
+ // was refused, and the push that followed wrote this device's older one
895
+ // over it on every other device.
896
+ const localIntent = layoutIntent(localPayload);
897
+ const adoptLayout = shouldAdoptPulledLayout({
898
+ local: localIntent,
899
+ atPullStart: baselineConfig === config ? localIntent : layoutIntent(collectLayoutPayload(baselineConfig)),
900
+ lastSynced: layoutIntent(lastSynced),
901
+ pulled: layoutIntent(payload),
902
+ memo: readLayoutMemo(layoutContext.memo),
903
+ pulledEditedAt: pulledLayoutEditedAt(payload, layoutContext.pushedAt),
904
+ });
905
+ if (adoptLayout) {
701
906
  next.layout = payload.layout as unknown as AppConfig["layout"];
702
907
  next.layouts = withLocalSessionLayoutState(payload.layouts as AppConfig["layouts"], config.layouts);
703
908
  next.activeLayoutIndex = payload.activeLayoutIndex as number;
@@ -872,24 +1077,43 @@ function tickerChangedSinceLastSync(
872
1077
  return !valuesEqual(withoutQuote(local), withoutQuote(baseline));
873
1078
  }
874
1079
 
1080
+ const CORE_CONFIG_CONTRIBUTOR_ID = "core.config";
1081
+
875
1082
  export const coreConfigSyncContributor: SyncContributor = {
876
- id: "core.config",
1083
+ id: CORE_CONFIG_CONTRIBUTOR_ID,
877
1084
  schemaVersion: 1,
878
- collect: ({ state }) => collectCoreConfigPayload(state.config),
879
- apply: async (payload, { baselinePayload, baselineState, state, getState, isCurrent, dispatch, tickerRepository }) => {
880
- const nextConfig = mergeConfigPayload(state.config, payload, baselineState.config, baselinePayload);
881
- if (!nextConfig || valuesEqual(nextConfig, state.config)) return;
882
- // Adopting another device's workspace rearranges the screen under the
883
- // user, so it is worth a line in the log when a report says "my panes
884
- // moved on their own".
885
- if (nextConfig.layout !== state.config.layout) {
886
- log.info("config.layout.adopted", {
887
- panes: nextConfig.layout.instances.length,
888
- activeLayoutIndex: nextConfig.activeLayoutIndex,
889
- });
1085
+ collect: ({ state, baselinePayload, memo }) => withLayoutProvenance(
1086
+ collectCoreConfigPayload(state.config) as Record<string, unknown>,
1087
+ baselinePayload,
1088
+ memo?.get(),
1089
+ ),
1090
+ observe: ({ state, baselinePayload, memo, launch }) => {
1091
+ const edit = noteLayoutEdit(state.config, baselinePayload, memo.get(), launch);
1092
+ if (edit) memo.set(edit);
1093
+ },
1094
+ apply: async (payload, { snapshot, baselinePayload, baselineState, state, getState, isCurrent, dispatch, tickerRepository, memo }) => {
1095
+ const pushedAt = snapshot?.contributors[CORE_CONFIG_CONTRIBUTOR_ID]?.updatedAt;
1096
+ const nextConfig = mergeConfigPayload(state.config, payload, baselineState.config, baselinePayload, {
1097
+ memo: memo?.get(),
1098
+ pushedAt,
1099
+ });
1100
+ if (!nextConfig) return;
1101
+ const configChanged = !valuesEqual(nextConfig, state.config);
1102
+ if (configChanged) {
1103
+ // Adopting another device's workspace rearranges the screen under the
1104
+ // user, so it is worth a line in the log when a report says "my panes
1105
+ // moved on their own".
1106
+ if (nextConfig.layout !== state.config.layout) {
1107
+ log.info("config.layout.adopted", {
1108
+ panes: nextConfig.layout.instances.length,
1109
+ activeLayoutIndex: nextConfig.activeLayoutIndex,
1110
+ });
1111
+ }
1112
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
1113
+ scheduleConfigSave(nextConfig);
890
1114
  }
891
- dispatch({ type: "SET_CONFIG", config: nextConfig });
892
- scheduleConfigSave(nextConfig);
1115
+ if (memo && isRecord(payload)) rememberCloudLayout(payload, pushedAt, getState().config, memo, nextConfig.layouts !== state.config.layouts);
1116
+ if (!configChanged) return;
893
1117
 
894
1118
  // A profile removed on another device takes its positions with it here
895
1119
  // too; kept, this device would push them back and recreate the tickers